test_query_freightower_track_tool.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  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. 'vessel': 'VESSEL',
  20. 'voyage': '001E',
  21. 'etd': '2026-09-01 00:00:00',
  22. 'eta': '2026-09-20 00:00:00',
  23. },
  24. 'ports': [{
  25. 'place_type': '目的港',
  26. 'port_name': '洛杉矶',
  27. 'eta': '2026-09-20',
  28. 'ata': '',
  29. }],
  30. 'tracks': [{
  31. 'time': '2026-09-20 01:00:00',
  32. 'location': 'LOS ANGELES',
  33. 'content': '抵港',
  34. 'event_code': 'BDAR',
  35. }],
  36. }
  37. class QueryFreightowerTrackToolTest(unittest.TestCase):
  38. def test_metadata_requires_explicit_number_type(self):
  39. metadata = QueryFreightowerTrackTool().metadata()
  40. description = metadata['description']
  41. schema = metadata['input_schema']
  42. self.assertEqual('query_freightower_track', metadata['name'])
  43. self.assertNotIn('required', schema)
  44. self.assertFalse(schema['additionalProperties'])
  45. self.assertIn('使用场景:', description)
  46. self.assertIn('禁止使用:', description)
  47. self.assertIn('号码类型不明确时必须先询问用户', description)
  48. self.assertIn('不得根据号码格式猜测', description)
  49. self.assertIn('不得跨字段或跨工具试查', description)
  50. self.assertIn('query_track', description)
  51. self.assertIn('仅当用户明确', schema['properties']['bl_number']['description'])
  52. self.assertIn('不得放入其他类型号码', schema['properties']['container_code']['description'])
  53. def test_call_forwards_only_provided_numbers(self):
  54. api = RecordingApiClient()
  55. tool = QueryFreightowerTrackTool(api_client=api)
  56. tool.call(bl_number=' BL1 ', container_code=' BOX1 ', request_id='rq_1')
  57. self.assertEqual((
  58. 'query_freightower_track',
  59. '/mcp/tools/queryFreightowerTrack',
  60. {'bl_number': 'BL1', 'container_code': 'BOX1'},
  61. 'rq_1',
  62. ), api.call)
  63. def test_call_accepts_one_number(self):
  64. api = RecordingApiClient()
  65. QueryFreightowerTrackTool(api_client=api).call(container_code='BOX1')
  66. self.assertEqual({'container_code': 'BOX1'}, api.call[2])
  67. def test_invalid_numbers_are_rejected(self):
  68. tool = QueryFreightowerTrackTool(api_client=RecordingApiClient())
  69. with self.assertRaises(ValueError):
  70. tool.call()
  71. with self.assertRaises(ValueError):
  72. tool.call(bl_number=123)
  73. with self.assertRaises(ValueError):
  74. tool.call(container_code='B' * 101)
  75. def test_api_client_is_required(self):
  76. with self.assertRaises(RuntimeError):
  77. QueryFreightowerTrackTool().call(bl_number='BL1')
  78. def test_registries_include_the_tool(self):
  79. local = GatewayApp().registered_tool_names()
  80. public = PublicGatewayApp(None, None).registered_tool_names()
  81. self.assertEqual(local, public)
  82. self.assertIn('query_freightower_track', local)
  83. self.assertEqual(35, len(local))
  84. self.assertEqual(34, len(OutputPresenter.SAFE_TOOLS))
  85. def test_cli_forwards_bill_and_container(self):
  86. app = GatewayApp()
  87. stdout = io.StringIO()
  88. with patch.object(app, 'call_tool', return_value={'code': 'MCP_0000'}) as call_tool:
  89. code = app.run_cli([
  90. 'call',
  91. '--tool', 'query_freightower_track',
  92. '--bl-number', 'BL1',
  93. '--container-code', 'BOX1',
  94. ], stdout=stdout)
  95. self.assertEqual(0, code)
  96. self.assertEqual('query_freightower_track', call_tool.call_args[0][0])
  97. self.assertEqual(
  98. {'bl_number': 'BL1', 'container_code': 'BOX1'},
  99. call_tool.call_args[0][1],
  100. )
  101. def test_cli_requires_a_number(self):
  102. app = GatewayApp()
  103. with self.assertRaises(ValueError):
  104. app.run_cli([
  105. 'call',
  106. '--tool', 'query_freightower_track',
  107. ], stdout=io.StringIO())
  108. def test_presenter_renders_schedule_ports_and_arrival(self):
  109. result = OutputPresenter().present('query_freightower_track', {
  110. 'code': 'MCP_0000',
  111. 'data': sample_data(),
  112. 'meta': {'request_id': 'rq_ft'},
  113. })
  114. self.assertFalse(result['is_error'])
  115. self.assertEqual('洛杉矶', result['structured_content']['港口'][0]['港口'])
  116. self.assertEqual('BDAR', result['structured_content']['轨迹'][0]['状态代码'])
  117. self.assertIn('抵港', result['text'])
  118. self.assertNotIn('bl_number', result['text'])
  119. self.assertEqual({'request_id': 'rq_ft'}, result['meta'])
  120. def test_presenter_accepts_empty_ports_and_tracks(self):
  121. data = sample_data()
  122. data['ports'] = []
  123. data['tracks'] = []
  124. result = OutputPresenter().present('query_freightower_track', {
  125. 'code': 'MCP_0000',
  126. 'data': data,
  127. })
  128. self.assertFalse(result['is_error'])
  129. self.assertIn('港口\n- 无', result['text'])
  130. self.assertIn('轨迹\n- 无', result['text'])
  131. def test_presenter_rejects_unknown_shape(self):
  132. cases = []
  133. extra = sample_data()
  134. extra['schedule']['secret'] = 'x'
  135. cases.append(extra)
  136. bad_type = sample_data()
  137. bad_type['ports'][0]['place_type'] = '起运港'
  138. cases.append(bad_type)
  139. bad_value = sample_data()
  140. bad_value['tracks'][0]['time'] = 1
  141. cases.append(bad_value)
  142. cases.append({'schedule': {}})
  143. for data in cases:
  144. with self.subTest(data=data):
  145. result = OutputPresenter().present('query_freightower_track', {
  146. 'code': 'MCP_0000',
  147. 'data': data,
  148. })
  149. self.assertTrue(result['is_error'])
  150. self.assertNotIn('secret', result['text'])
  151. def test_presenter_keeps_allowlisted_business_message(self):
  152. result = OutputPresenter().present('query_freightower_track', {
  153. 'code': 'MCP_1501',
  154. 'msg': '暂时没有飞驼轨迹',
  155. 'data': [],
  156. })
  157. self.assertTrue(result['is_error'])
  158. self.assertIn('暂时没有飞驼轨迹', result['text'])
  159. def test_presenter_hides_unknown_upstream_message(self):
  160. result = OutputPresenter().present('query_freightower_track', {
  161. 'code': 'MCP_1501',
  162. 'msg': 'carrier token leaked',
  163. 'data': [],
  164. })
  165. self.assertTrue(result['is_error'])
  166. self.assertNotIn('leaked', result['text'])
  167. self.assertIn('目标数据不可用', result['text'])
  168. if __name__ == '__main__':
  169. unittest.main()