|
@@ -0,0 +1,194 @@
|
|
|
|
|
+import io
|
|
|
|
|
+import unittest
|
|
|
|
|
+from unittest.mock import patch
|
|
|
|
|
+
|
|
|
|
|
+from app import GatewayApp
|
|
|
|
|
+from public_gateway import PublicGatewayApp
|
|
|
|
|
+from services.output_presenter import OutputPresenter
|
|
|
|
|
+from tools.query_freightower_track import QueryFreightowerTrackTool
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class RecordingApiClient:
|
|
|
|
|
+ def __init__(self):
|
|
|
|
|
+ self.call = None
|
|
|
|
|
+
|
|
|
|
|
+ def call_tool(self, tool_code, route_path, payload, request_id):
|
|
|
|
|
+ self.call = (tool_code, route_path, payload, request_id)
|
|
|
|
|
+ return {'code': 'MCP_0000', 'data': {'ok': True}}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def sample_data():
|
|
|
|
|
+ return {
|
|
|
|
|
+ 'schedule': {
|
|
|
|
|
+ 'bl_number': 'BL1',
|
|
|
|
|
+ 'container_code': 'BOX1',
|
|
|
|
|
+ 'vessel': 'VESSEL',
|
|
|
|
|
+ 'voyage': '001E',
|
|
|
|
|
+ 'etd': '2026-09-01 00:00:00',
|
|
|
|
|
+ 'eta': '2026-09-20 00:00:00',
|
|
|
|
|
+ },
|
|
|
|
|
+ 'ports': [{
|
|
|
|
|
+ 'place_type': '目的港',
|
|
|
|
|
+ 'port_name': '洛杉矶',
|
|
|
|
|
+ 'eta': '2026-09-20',
|
|
|
|
|
+ 'ata': '',
|
|
|
|
|
+ }],
|
|
|
|
|
+ 'tracks': [{
|
|
|
|
|
+ 'time': '2026-09-20 01:00:00',
|
|
|
|
|
+ 'location': 'LOS ANGELES',
|
|
|
|
|
+ 'content': '抵港',
|
|
|
|
|
+ 'event_code': 'BDAR',
|
|
|
|
|
+ }],
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class QueryFreightowerTrackToolTest(unittest.TestCase):
|
|
|
|
|
+ def test_metadata_requires_explicit_number_type(self):
|
|
|
|
|
+ metadata = QueryFreightowerTrackTool().metadata()
|
|
|
|
|
+ description = metadata['description']
|
|
|
|
|
+ schema = metadata['input_schema']
|
|
|
|
|
+
|
|
|
|
|
+ self.assertEqual('query_freightower_track', metadata['name'])
|
|
|
|
|
+ self.assertNotIn('required', schema)
|
|
|
|
|
+ self.assertFalse(schema['additionalProperties'])
|
|
|
|
|
+ self.assertIn('使用场景:', description)
|
|
|
|
|
+ self.assertIn('禁止使用:', description)
|
|
|
|
|
+ self.assertIn('号码类型不明确时必须先询问用户', description)
|
|
|
|
|
+ self.assertIn('不得根据号码格式猜测', description)
|
|
|
|
|
+ self.assertIn('不得跨字段或跨工具试查', description)
|
|
|
|
|
+ self.assertIn('query_track', description)
|
|
|
|
|
+ self.assertIn('仅当用户明确', schema['properties']['bl_number']['description'])
|
|
|
|
|
+ self.assertIn('不得放入其他类型号码', schema['properties']['container_code']['description'])
|
|
|
|
|
+
|
|
|
|
|
+ def test_call_forwards_only_provided_numbers(self):
|
|
|
|
|
+ api = RecordingApiClient()
|
|
|
|
|
+ tool = QueryFreightowerTrackTool(api_client=api)
|
|
|
|
|
+
|
|
|
|
|
+ tool.call(bl_number=' BL1 ', container_code=' BOX1 ', request_id='rq_1')
|
|
|
|
|
+
|
|
|
|
|
+ self.assertEqual((
|
|
|
|
|
+ 'query_freightower_track',
|
|
|
|
|
+ '/mcp/tools/queryFreightowerTrack',
|
|
|
|
|
+ {'bl_number': 'BL1', 'container_code': 'BOX1'},
|
|
|
|
|
+ 'rq_1',
|
|
|
|
|
+ ), api.call)
|
|
|
|
|
+
|
|
|
|
|
+ def test_call_accepts_one_number(self):
|
|
|
|
|
+ api = RecordingApiClient()
|
|
|
|
|
+ QueryFreightowerTrackTool(api_client=api).call(container_code='BOX1')
|
|
|
|
|
+ self.assertEqual({'container_code': 'BOX1'}, api.call[2])
|
|
|
|
|
+
|
|
|
|
|
+ def test_invalid_numbers_are_rejected(self):
|
|
|
|
|
+ tool = QueryFreightowerTrackTool(api_client=RecordingApiClient())
|
|
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
|
|
+ tool.call()
|
|
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
|
|
+ tool.call(bl_number=123)
|
|
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
|
|
+ tool.call(container_code='B' * 101)
|
|
|
|
|
+
|
|
|
|
|
+ def test_api_client_is_required(self):
|
|
|
|
|
+ with self.assertRaises(RuntimeError):
|
|
|
|
|
+ QueryFreightowerTrackTool().call(bl_number='BL1')
|
|
|
|
|
+
|
|
|
|
|
+ def test_registries_include_the_tool(self):
|
|
|
|
|
+ local = GatewayApp().registered_tool_names()
|
|
|
|
|
+ public = PublicGatewayApp(None, None).registered_tool_names()
|
|
|
|
|
+ self.assertEqual(local, public)
|
|
|
|
|
+ self.assertIn('query_freightower_track', local)
|
|
|
|
|
+ self.assertEqual(35, len(local))
|
|
|
|
|
+ self.assertEqual(34, len(OutputPresenter.SAFE_TOOLS))
|
|
|
|
|
+
|
|
|
|
|
+ def test_cli_forwards_bill_and_container(self):
|
|
|
|
|
+ app = GatewayApp()
|
|
|
|
|
+ stdout = io.StringIO()
|
|
|
|
|
+ with patch.object(app, 'call_tool', return_value={'code': 'MCP_0000'}) as call_tool:
|
|
|
|
|
+ code = app.run_cli([
|
|
|
|
|
+ 'call',
|
|
|
|
|
+ '--tool', 'query_freightower_track',
|
|
|
|
|
+ '--bl-number', 'BL1',
|
|
|
|
|
+ '--container-code', 'BOX1',
|
|
|
|
|
+ ], stdout=stdout)
|
|
|
|
|
+ self.assertEqual(0, code)
|
|
|
|
|
+ self.assertEqual('query_freightower_track', call_tool.call_args[0][0])
|
|
|
|
|
+ self.assertEqual(
|
|
|
|
|
+ {'bl_number': 'BL1', 'container_code': 'BOX1'},
|
|
|
|
|
+ call_tool.call_args[0][1],
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ def test_cli_requires_a_number(self):
|
|
|
|
|
+ app = GatewayApp()
|
|
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
|
|
+ app.run_cli([
|
|
|
|
|
+ 'call',
|
|
|
|
|
+ '--tool', 'query_freightower_track',
|
|
|
|
|
+ ], stdout=io.StringIO())
|
|
|
|
|
+
|
|
|
|
|
+ def test_presenter_renders_schedule_ports_and_arrival(self):
|
|
|
|
|
+ result = OutputPresenter().present('query_freightower_track', {
|
|
|
|
|
+ 'code': 'MCP_0000',
|
|
|
|
|
+ 'data': sample_data(),
|
|
|
|
|
+ 'meta': {'request_id': 'rq_ft'},
|
|
|
|
|
+ })
|
|
|
|
|
+ self.assertFalse(result['is_error'])
|
|
|
|
|
+ self.assertEqual('洛杉矶', result['structured_content']['港口'][0]['港口'])
|
|
|
|
|
+ self.assertEqual('BDAR', result['structured_content']['轨迹'][0]['状态代码'])
|
|
|
|
|
+ self.assertIn('抵港', result['text'])
|
|
|
|
|
+ self.assertNotIn('bl_number', result['text'])
|
|
|
|
|
+ self.assertEqual({'request_id': 'rq_ft'}, result['meta'])
|
|
|
|
|
+
|
|
|
|
|
+ def test_presenter_accepts_empty_ports_and_tracks(self):
|
|
|
|
|
+ data = sample_data()
|
|
|
|
|
+ data['ports'] = []
|
|
|
|
|
+ data['tracks'] = []
|
|
|
|
|
+ result = OutputPresenter().present('query_freightower_track', {
|
|
|
|
|
+ 'code': 'MCP_0000',
|
|
|
|
|
+ 'data': data,
|
|
|
|
|
+ })
|
|
|
|
|
+ self.assertFalse(result['is_error'])
|
|
|
|
|
+ self.assertIn('港口\n- 无', result['text'])
|
|
|
|
|
+ self.assertIn('轨迹\n- 无', result['text'])
|
|
|
|
|
+
|
|
|
|
|
+ def test_presenter_rejects_unknown_shape(self):
|
|
|
|
|
+ cases = []
|
|
|
|
|
+ extra = sample_data()
|
|
|
|
|
+ extra['schedule']['secret'] = 'x'
|
|
|
|
|
+ cases.append(extra)
|
|
|
|
|
+ bad_type = sample_data()
|
|
|
|
|
+ bad_type['ports'][0]['place_type'] = '起运港'
|
|
|
|
|
+ cases.append(bad_type)
|
|
|
|
|
+ bad_value = sample_data()
|
|
|
|
|
+ bad_value['tracks'][0]['time'] = 1
|
|
|
|
|
+ cases.append(bad_value)
|
|
|
|
|
+ cases.append({'schedule': {}})
|
|
|
|
|
+ for data in cases:
|
|
|
|
|
+ with self.subTest(data=data):
|
|
|
|
|
+ result = OutputPresenter().present('query_freightower_track', {
|
|
|
|
|
+ 'code': 'MCP_0000',
|
|
|
|
|
+ 'data': data,
|
|
|
|
|
+ })
|
|
|
|
|
+ self.assertTrue(result['is_error'])
|
|
|
|
|
+ self.assertNotIn('secret', result['text'])
|
|
|
|
|
+
|
|
|
|
|
+ def test_presenter_keeps_allowlisted_business_message(self):
|
|
|
|
|
+ result = OutputPresenter().present('query_freightower_track', {
|
|
|
|
|
+ 'code': 'MCP_1501',
|
|
|
|
|
+ 'msg': '暂时没有飞驼轨迹',
|
|
|
|
|
+ 'data': [],
|
|
|
|
|
+ })
|
|
|
|
|
+ self.assertTrue(result['is_error'])
|
|
|
|
|
+ self.assertIn('暂时没有飞驼轨迹', result['text'])
|
|
|
|
|
+
|
|
|
|
|
+ def test_presenter_hides_unknown_upstream_message(self):
|
|
|
|
|
+ result = OutputPresenter().present('query_freightower_track', {
|
|
|
|
|
+ 'code': 'MCP_1501',
|
|
|
|
|
+ 'msg': 'carrier token leaked',
|
|
|
|
|
+ 'data': [],
|
|
|
|
|
+ })
|
|
|
|
|
+ self.assertTrue(result['is_error'])
|
|
|
|
|
+ self.assertNotIn('leaked', result['text'])
|
|
|
|
|
+ self.assertIn('目标数据不可用', result['text'])
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+if __name__ == '__main__':
|
|
|
|
|
+ unittest.main()
|