test_query_freightower_track_tool.py 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. import io
  2. import unittest
  3. from unittest.mock import patch
  4. from app import GatewayApp
  5. from public_gateway import PublicGatewayApp
  6. from services.output_presenter import OutputPresenter
  7. from tools.query_freightower_track import QueryFreightowerTrackTool
  8. class RecordingApiClient:
  9. def __init__(self):
  10. self.call = None
  11. def call_tool(self, tool_code, route_path, payload, request_id):
  12. self.call = (tool_code, route_path, payload, request_id)
  13. return {'code': 'MCP_0000', 'data': {'ok': True}}
  14. def sample_data():
  15. return {
  16. 'schedule': {
  17. 'bl_number': 'BL1',
  18. 'container_code': 'BOX1',
  19. 'carrier_name': '马士基',
  20. 'vessel': 'VESSEL',
  21. 'voyage': '001E',
  22. 'route_code': 'CVT',
  23. 'current_status': '抵港',
  24. 'current_status_time': '2026-09-20 01:00:00',
  25. 'current_status_place': '洛杉矶',
  26. 'update_time': '2026-09-20 02:00:00',
  27. 'etd': '2026-09-01 00:00:00',
  28. 'atd': '2026-09-02 00:00:00',
  29. 'eta': '2026-09-20 00:00:00',
  30. },
  31. 'ports': [{
  32. 'place_type': '目的港',
  33. 'port_name': '洛杉矶',
  34. 'terminal_name': 'TCCL',
  35. 'etd': '',
  36. 'atd': '',
  37. 'eta': '2026-09-20',
  38. 'ata': '',
  39. }],
  40. 'containers': [{
  41. 'container_no': 'BOX1',
  42. 'container_type': '40HC',
  43. 'seal_no': 'SEAL1',
  44. 'current_status': '抵港',
  45. 'off_load': '否',
  46. }],
  47. 'tracks': [{
  48. 'time': '2026-09-20 01:00:00',
  49. 'location': 'LOS ANGELES',
  50. 'content': '抵港',
  51. 'event_code': 'BDAR',
  52. 'estimate_flag': '实际',
  53. 'vessel_voyage': 'VESSEL 001E',
  54. 'terminal_name': 'TCCL',
  55. }],
  56. 'terminal_plan': {
  57. 'port_name': '上海',
  58. 'terminal_name': '外高桥',
  59. 'open_time': '2026-08-28 00:00:00',
  60. 'close_time': '2026-09-01 03:00:00',
  61. 'etd': '2026-09-01 00:00:00',
  62. 'atd': '',
  63. 'shipping_agent': '中外运',
  64. },
  65. }
  66. class QueryFreightowerTrackToolTest(unittest.TestCase):
  67. def test_metadata_requires_explicit_number_type(self):
  68. metadata = QueryFreightowerTrackTool().metadata()
  69. description = metadata['description']
  70. schema = metadata['input_schema']
  71. self.assertEqual('query_freightower_track', metadata['name'])
  72. self.assertNotIn('required', schema)
  73. self.assertFalse(schema['additionalProperties'])
  74. self.assertIn('使用场景:', description)
  75. self.assertIn('禁止使用:', description)
  76. self.assertIn('号码类型不明确时必须先询问用户', description)
  77. self.assertIn('不得根据号码格式猜测', description)
  78. self.assertIn('不得跨字段或跨工具试查', description)
  79. self.assertIn('query_track', description)
  80. self.assertIn('仅当用户明确', schema['properties']['bl_number']['description'])
  81. self.assertIn('不得放入其他类型号码', schema['properties']['container_code']['description'])
  82. def test_call_forwards_only_provided_numbers(self):
  83. api = RecordingApiClient()
  84. tool = QueryFreightowerTrackTool(api_client=api)
  85. tool.call(bl_number=' BL1 ', container_code=' BOX1 ', request_id='rq_1')
  86. self.assertEqual((
  87. 'query_freightower_track',
  88. '/mcp/tools/queryFreightowerTrack',
  89. {'bl_number': 'BL1', 'container_code': 'BOX1'},
  90. 'rq_1',
  91. ), api.call)
  92. def test_call_accepts_one_number(self):
  93. api = RecordingApiClient()
  94. QueryFreightowerTrackTool(api_client=api).call(container_code='BOX1')
  95. self.assertEqual({'container_code': 'BOX1'}, api.call[2])
  96. def test_invalid_numbers_are_rejected(self):
  97. tool = QueryFreightowerTrackTool(api_client=RecordingApiClient())
  98. with self.assertRaises(ValueError):
  99. tool.call()
  100. with self.assertRaises(ValueError):
  101. tool.call(bl_number=123)
  102. with self.assertRaises(ValueError):
  103. tool.call(container_code='B' * 101)
  104. def test_api_client_is_required(self):
  105. with self.assertRaises(RuntimeError):
  106. QueryFreightowerTrackTool().call(bl_number='BL1')
  107. def test_registries_include_the_tool(self):
  108. local = GatewayApp().registered_tool_names()
  109. public = PublicGatewayApp(None, None).registered_tool_names()
  110. self.assertEqual(local, public)
  111. self.assertIn('query_freightower_track', local)
  112. self.assertEqual(40, len(local))
  113. self.assertEqual(39, len(OutputPresenter.SAFE_TOOLS))
  114. def test_cli_forwards_bill_and_container(self):
  115. app = GatewayApp()
  116. stdout = io.StringIO()
  117. with patch.object(app, 'call_tool', return_value={'code': 'MCP_0000'}) as call_tool:
  118. code = app.run_cli([
  119. 'call',
  120. '--tool', 'query_freightower_track',
  121. '--bl-number', 'BL1',
  122. '--container-code', 'BOX1',
  123. ], stdout=stdout)
  124. self.assertEqual(0, code)
  125. self.assertEqual('query_freightower_track', call_tool.call_args[0][0])
  126. self.assertEqual(
  127. {'bl_number': 'BL1', 'container_code': 'BOX1'},
  128. call_tool.call_args[0][1],
  129. )
  130. def test_cli_requires_a_number(self):
  131. app = GatewayApp()
  132. with self.assertRaises(ValueError):
  133. app.run_cli([
  134. 'call',
  135. '--tool', 'query_freightower_track',
  136. ], stdout=io.StringIO())
  137. def test_presenter_renders_schedule_ports_and_arrival(self):
  138. result = OutputPresenter().present('query_freightower_track', {
  139. 'code': 'MCP_0000',
  140. 'data': sample_data(),
  141. 'meta': {'request_id': 'rq_ft'},
  142. })
  143. self.assertFalse(result['is_error'])
  144. self.assertEqual('洛杉矶', result['structured_content']['港口'][0]['港口'])
  145. self.assertEqual('BDAR', result['structured_content']['轨迹'][0]['状态代码'])
  146. self.assertEqual('马士基', result['structured_content']['船期']['船公司'])
  147. self.assertEqual('中外运', result['structured_content']['港区计划']['船代'])
  148. self.assertIn('抵港', result['text'])
  149. self.assertNotIn('bl_number', result['text'])
  150. self.assertEqual({'request_id': 'rq_ft'}, result['meta'])
  151. def test_presenter_accepts_empty_ports_and_tracks(self):
  152. data = sample_data()
  153. data['ports'] = []
  154. data['containers'] = []
  155. data['tracks'] = []
  156. data['terminal_plan'] = {key: '' for key in data['terminal_plan']}
  157. result = OutputPresenter().present('query_freightower_track', {
  158. 'code': 'MCP_0000',
  159. 'data': data,
  160. })
  161. self.assertFalse(result['is_error'])
  162. self.assertIn('港口\n- 无', result['text'])
  163. self.assertIn('箱子\n- 无', result['text'])
  164. self.assertIn('轨迹\n- 无', result['text'])
  165. self.assertIn('港区计划\n- 无', result['text'])
  166. def test_presenter_rejects_unknown_shape(self):
  167. cases = []
  168. extra = sample_data()
  169. extra['schedule']['secret'] = 'x'
  170. cases.append(extra)
  171. bad_type = sample_data()
  172. bad_type['ports'][0]['place_type'] = '接货地'
  173. cases.append(bad_type)
  174. bad_value = sample_data()
  175. bad_value['tracks'][0]['time'] = 1
  176. cases.append(bad_value)
  177. cases.append({'schedule': {}})
  178. for data in cases:
  179. with self.subTest(data=data):
  180. result = OutputPresenter().present('query_freightower_track', {
  181. 'code': 'MCP_0000',
  182. 'data': data,
  183. })
  184. self.assertTrue(result['is_error'])
  185. self.assertNotIn('secret', result['text'])
  186. def test_presenter_keeps_allowlisted_business_message(self):
  187. result = OutputPresenter().present('query_freightower_track', {
  188. 'code': 'MCP_1501',
  189. 'msg': '暂时没有飞驼轨迹',
  190. 'data': [],
  191. })
  192. self.assertTrue(result['is_error'])
  193. self.assertIn('暂时没有飞驼轨迹', result['text'])
  194. def test_presenter_hides_unknown_upstream_message(self):
  195. result = OutputPresenter().present('query_freightower_track', {
  196. 'code': 'MCP_1501',
  197. 'msg': 'carrier token leaked',
  198. 'data': [],
  199. })
  200. self.assertTrue(result['is_error'])
  201. self.assertNotIn('leaked', result['text'])
  202. self.assertIn('目标数据不可用', result['text'])
  203. if __name__ == '__main__':
  204. unittest.main()