|
|
@@ -0,0 +1,306 @@
|
|
|
+import io
|
|
|
+import unittest
|
|
|
+
|
|
|
+from app import GatewayApp
|
|
|
+from public_gateway import PublicGatewayApp
|
|
|
+from services.output_presenter import OutputPresenter
|
|
|
+from tools.query_container_timeliness_list import (
|
|
|
+ COLUMNS,
|
|
|
+ QueryContainerTimelinessListTool,
|
|
|
+)
|
|
|
+from tools.export_container_timeliness_report import (
|
|
|
+ ExportContainerTimelinessReportTool,
|
|
|
+)
|
|
|
+
|
|
|
+
|
|
|
+class RecordingApiClient:
|
|
|
+ def __init__(self):
|
|
|
+ self.calls = []
|
|
|
+
|
|
|
+ def list_enabled_tools(self, request_id=''):
|
|
|
+ return {
|
|
|
+ 'code': 'MCP_0000',
|
|
|
+ 'data': {
|
|
|
+ 'tool_codes': [
|
|
|
+ 'query_container_timeliness_list',
|
|
|
+ 'export_container_timeliness_report',
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ def call_tool(self, tool_code, route_path, payload, request_id):
|
|
|
+ self.calls.append((tool_code, route_path, payload, request_id))
|
|
|
+ return {'code': 'MCP_0000', 'data': {}, 'meta': {}}
|
|
|
+
|
|
|
+
|
|
|
+class ContainerTimelinessToolContractTest(unittest.TestCase):
|
|
|
+ def test_query_schema_is_closed_without_identity_or_page_filters(self):
|
|
|
+ metadata = QueryContainerTimelinessListTool().metadata()
|
|
|
+ schema = metadata['input_schema']
|
|
|
+ self.assertEqual('query_container_timeliness_list', metadata['name'])
|
|
|
+ self.assertEqual(
|
|
|
+ '/mcp/tools/queryContainerTimelinessList',
|
|
|
+ QueryContainerTimelinessListTool.route_path,
|
|
|
+ )
|
|
|
+ self.assertFalse(schema['additionalProperties'])
|
|
|
+ self.assertEqual([], schema['required'])
|
|
|
+ self.assertEqual(
|
|
|
+ {
|
|
|
+ 'container_codes',
|
|
|
+ 'bl_numbers',
|
|
|
+ 'departure_time_start',
|
|
|
+ 'departure_time_end',
|
|
|
+ 'page',
|
|
|
+ 'limit',
|
|
|
+ },
|
|
|
+ set(schema['properties']),
|
|
|
+ )
|
|
|
+ for forbidden in (
|
|
|
+ 'company_id', 'admin_id', 'is_super', 'outbound_numbers',
|
|
|
+ 'ship_company', 'loading_id',
|
|
|
+ ):
|
|
|
+ self.assertNotIn(forbidden, schema['properties'])
|
|
|
+ description = metadata['description']
|
|
|
+ self.assertIn('使用场景:', description)
|
|
|
+ self.assertIn('禁止使用:', description)
|
|
|
+ self.assertIn('admin/Report/outboundReport', description)
|
|
|
+ self.assertIn('query_outbound_list', description)
|
|
|
+ self.assertIn('干线实际出发时间', description)
|
|
|
+ self.assertIn('54列', description)
|
|
|
+ self.assertIn('号码类型不明确时必须先询问用户', description)
|
|
|
+ self.assertIn('不得根据号码格式猜测', description)
|
|
|
+ self.assertIn('不得跨字段或跨工具试查', description)
|
|
|
+
|
|
|
+ def test_query_forwards_numbers_and_optional_departure_window(self):
|
|
|
+ client = RecordingApiClient()
|
|
|
+ QueryContainerTimelinessListTool(client).call(
|
|
|
+ container_codes=['CONT-1', ' CONT-1 '],
|
|
|
+ bl_numbers=['BL-1'],
|
|
|
+ departure_time_start='2026-09-01',
|
|
|
+ departure_time_end='2026-09-30',
|
|
|
+ page=2,
|
|
|
+ limit=10,
|
|
|
+ )
|
|
|
+ self.assertEqual(
|
|
|
+ (
|
|
|
+ 'query_container_timeliness_list',
|
|
|
+ '/mcp/tools/queryContainerTimelinessList',
|
|
|
+ {
|
|
|
+ 'container_codes': ['CONT-1'],
|
|
|
+ 'bl_numbers': ['BL-1'],
|
|
|
+ 'departure_time_start': '2026-09-01',
|
|
|
+ 'departure_time_end': '2026-09-30',
|
|
|
+ 'page': 2,
|
|
|
+ 'limit': 10,
|
|
|
+ },
|
|
|
+ 'rq_query_container_timeliness_list',
|
|
|
+ ),
|
|
|
+ client.calls[-1],
|
|
|
+ )
|
|
|
+
|
|
|
+ def test_query_rejects_empty_and_oversize_window(self):
|
|
|
+ tool = QueryContainerTimelinessListTool(RecordingApiClient())
|
|
|
+ with self.assertRaisesRegex(ValueError, 'provide'):
|
|
|
+ tool.call()
|
|
|
+ with self.assertRaisesRegex(ValueError, 'departure time range requires both'):
|
|
|
+ tool.call(departure_time_start='2026-09-01')
|
|
|
+ with self.assertRaisesRegex(ValueError, 'departure time window'):
|
|
|
+ tool.call(
|
|
|
+ departure_time_start='2026-08-01',
|
|
|
+ departure_time_end='2026-09-01',
|
|
|
+ )
|
|
|
+ with self.assertRaisesRegex(ValueError, 'is invalid'):
|
|
|
+ tool.call(departure_time_start='09/01/2026', departure_time_end='2026-09-02')
|
|
|
+ with self.assertRaisesRegex(ValueError, 'at most 200'):
|
|
|
+ tool.call(
|
|
|
+ container_codes=['C1'],
|
|
|
+ bl_numbers=['B{0}'.format(i) for i in range(200)],
|
|
|
+ )
|
|
|
+ with self.assertRaisesRegex(ValueError, 'non-empty'):
|
|
|
+ tool.call(container_codes=[])
|
|
|
+ with self.assertRaisesRegex(ValueError, 'strings'):
|
|
|
+ tool.call(container_codes=[1])
|
|
|
+ with self.assertRaisesRegex(ValueError, '1 to 100 chars'):
|
|
|
+ tool.call(container_codes=[''])
|
|
|
+ with self.assertRaisesRegex(ValueError, 'at most 200 container_codes'):
|
|
|
+ tool.call(container_codes=['C{0}'.format(i) for i in range(201)])
|
|
|
+ with self.assertRaisesRegex(ValueError, 'page is invalid'):
|
|
|
+ tool.call(container_codes=['CONT-1'], page=True)
|
|
|
+ with self.assertRaisesRegex(ValueError, 'page is invalid'):
|
|
|
+ tool.call(container_codes=['CONT-1'], page=0)
|
|
|
+ with self.assertRaisesRegex(ValueError, 'limit is invalid'):
|
|
|
+ tool.call(container_codes=['CONT-1'], limit='20')
|
|
|
+ with self.assertRaisesRegex(ValueError, '1 to 100 chars'):
|
|
|
+ tool.call(container_codes=['x' * 101])
|
|
|
+ with self.assertRaisesRegex(ValueError, 'departure time window'):
|
|
|
+ tool.call(
|
|
|
+ departure_time_start='2026-09-30',
|
|
|
+ departure_time_end='2026-09-01',
|
|
|
+ )
|
|
|
+ with self.assertRaisesRegex(RuntimeError, 'api client is required'):
|
|
|
+ QueryContainerTimelinessListTool().call(container_codes=['CONT-1'])
|
|
|
+ with self.assertRaisesRegex(RuntimeError, 'api client is required'):
|
|
|
+ ExportContainerTimelinessReportTool().call(container_codes=['CONT-1'])
|
|
|
+
|
|
|
+ def test_export_schema_forbids_pagination_and_requires_async_follow_up(self):
|
|
|
+ metadata = ExportContainerTimelinessReportTool().metadata()
|
|
|
+ schema = metadata['input_schema']
|
|
|
+ self.assertEqual('export_container_timeliness_report', metadata['name'])
|
|
|
+ self.assertFalse(schema['additionalProperties'])
|
|
|
+ self.assertEqual(
|
|
|
+ {
|
|
|
+ 'container_codes',
|
|
|
+ 'bl_numbers',
|
|
|
+ 'departure_time_start',
|
|
|
+ 'departure_time_end',
|
|
|
+ },
|
|
|
+ set(schema['properties']),
|
|
|
+ )
|
|
|
+ self.assertNotIn('page', schema['properties'])
|
|
|
+ description = metadata['description']
|
|
|
+ self.assertIn('明确要求导出', description)
|
|
|
+ self.assertIn('柜子时效', description)
|
|
|
+ self.assertIn('异步导出任务', description)
|
|
|
+ self.assertIn('query_export_task', description)
|
|
|
+ self.assertIn('不会在本次调用中等待文件生成', description)
|
|
|
+ self.assertIn('query_container_timeliness_list', description)
|
|
|
+
|
|
|
+ def test_export_forwards_locator_without_page(self):
|
|
|
+ client = RecordingApiClient()
|
|
|
+ ExportContainerTimelinessReportTool(client).call(
|
|
|
+ bl_numbers=['BL-1'],
|
|
|
+ )
|
|
|
+ self.assertEqual(
|
|
|
+ {
|
|
|
+ 'bl_numbers': ['BL-1'],
|
|
|
+ },
|
|
|
+ client.calls[-1][2],
|
|
|
+ )
|
|
|
+ self.assertNotIn('page', client.calls[-1][2])
|
|
|
+
|
|
|
+ def test_cli_forwards_query_and_export_filters(self):
|
|
|
+ client = RecordingApiClient()
|
|
|
+ app = GatewayApp(api_client=client)
|
|
|
+ code = app.run_cli([
|
|
|
+ 'call', '--tool', 'query_container_timeliness_list',
|
|
|
+ '--container-codes', 'CONT-1',
|
|
|
+ '--departure-time-start', '2026-09-01',
|
|
|
+ '--departure-time-end', '2026-09-14',
|
|
|
+ '--page', '2',
|
|
|
+ '--limit', '10',
|
|
|
+ ], stdout=io.StringIO())
|
|
|
+ self.assertEqual(0, code)
|
|
|
+ self.assertEqual(
|
|
|
+ {
|
|
|
+ 'container_codes': ['CONT-1'],
|
|
|
+ 'departure_time_start': '2026-09-01',
|
|
|
+ 'departure_time_end': '2026-09-14',
|
|
|
+ 'page': 2,
|
|
|
+ 'limit': 10,
|
|
|
+ },
|
|
|
+ client.calls[-1][2],
|
|
|
+ )
|
|
|
+
|
|
|
+ code = app.run_cli([
|
|
|
+ 'call', '--tool', 'export_container_timeliness_report',
|
|
|
+ '--bl-numbers', 'BL-1, BL-2',
|
|
|
+ ], stdout=io.StringIO())
|
|
|
+ self.assertEqual(0, code)
|
|
|
+ self.assertEqual(
|
|
|
+ {'bl_numbers': ['BL-1', 'BL-2']},
|
|
|
+ client.calls[-1][2],
|
|
|
+ )
|
|
|
+ self.assertNotIn('page', client.calls[-1][2])
|
|
|
+
|
|
|
+ def test_presenter_accepts_fifty_four_string_columns(self):
|
|
|
+ presenter = OutputPresenter()
|
|
|
+ columns = [{'key': key, 'name': name} for key, name in COLUMNS]
|
|
|
+ record = {key: 'v_{0}'.format(key) for key, _ in COLUMNS}
|
|
|
+ ok = presenter.present('query_container_timeliness_list', {
|
|
|
+ 'code': 'MCP_0000',
|
|
|
+ 'data': {'columns': columns, 'records': [record]},
|
|
|
+ 'meta': {
|
|
|
+ 'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
|
|
|
+ },
|
|
|
+ })
|
|
|
+ self.assertFalse(ok['is_error'])
|
|
|
+ self.assertEqual(54, len(ok['structured_content']['headers']))
|
|
|
+ self.assertEqual('v_container_code', ok['structured_content']['rows'][0][2])
|
|
|
+
|
|
|
+ numeric = dict(record)
|
|
|
+ numeric['total_volume'] = 1.2
|
|
|
+ self.assertTrue(presenter.present('query_container_timeliness_list', {
|
|
|
+ 'code': 'MCP_0000',
|
|
|
+ 'data': {'columns': columns, 'records': [numeric]},
|
|
|
+ 'meta': {
|
|
|
+ 'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
|
|
|
+ },
|
|
|
+ })['is_error'])
|
|
|
+ self.assertTrue(presenter.present('query_container_timeliness_list', {
|
|
|
+ 'code': 'MCP_0000',
|
|
|
+ 'data': {'columns': columns, 'records': [], 'extra': 1},
|
|
|
+ 'meta': {
|
|
|
+ 'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
|
|
|
+ },
|
|
|
+ })['is_error'])
|
|
|
+ self.assertTrue(presenter.present('query_container_timeliness_list', {
|
|
|
+ 'code': 'MCP_0000',
|
|
|
+ 'data': {'columns': columns, 'records': []},
|
|
|
+ 'meta': {
|
|
|
+ 'page': 1, 'limit': 20, 'has_more': False,
|
|
|
+ 'request_id': 'rq_x', 'total': 1,
|
|
|
+ },
|
|
|
+ })['is_error'])
|
|
|
+ bad_columns = list(columns)
|
|
|
+ bad_columns[0] = {'key': 'ship_company', 'name': '错'}
|
|
|
+ self.assertTrue(presenter.present('query_container_timeliness_list', {
|
|
|
+ 'code': 'MCP_0000',
|
|
|
+ 'data': {'columns': bad_columns, 'records': []},
|
|
|
+ 'meta': {
|
|
|
+ 'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
|
|
|
+ },
|
|
|
+ })['is_error'])
|
|
|
+ extra = dict(record)
|
|
|
+ extra['seq'] = '1'
|
|
|
+ self.assertTrue(presenter.present('query_container_timeliness_list', {
|
|
|
+ 'code': 'MCP_0000',
|
|
|
+ 'data': {'columns': columns, 'records': [extra]},
|
|
|
+ 'meta': {
|
|
|
+ 'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
|
|
|
+ },
|
|
|
+ })['is_error'])
|
|
|
+ self.assertTrue(presenter.present('query_container_timeliness_list', {
|
|
|
+ 'code': 'MCP_0000',
|
|
|
+ 'data': {'columns': columns, 'records': 'bad'},
|
|
|
+ 'meta': {
|
|
|
+ 'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
|
|
|
+ },
|
|
|
+ })['is_error'])
|
|
|
+ short_columns = columns[:-1]
|
|
|
+ self.assertTrue(presenter.present('query_container_timeliness_list', {
|
|
|
+ 'code': 'MCP_0000',
|
|
|
+ 'data': {'columns': short_columns, 'records': []},
|
|
|
+ 'meta': {
|
|
|
+ 'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
|
|
|
+ },
|
|
|
+ })['is_error'])
|
|
|
+ self.assertTrue(presenter.present('query_container_timeliness_list', {
|
|
|
+ 'code': 'MCP_0000',
|
|
|
+ 'data': {'columns': columns, 'records': ['bad']},
|
|
|
+ 'meta': {
|
|
|
+ 'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
|
|
|
+ },
|
|
|
+ })['is_error'])
|
|
|
+
|
|
|
+ def test_local_and_public_registries_include_both_tools(self):
|
|
|
+ local = GatewayApp().registered_tool_names()
|
|
|
+ public = PublicGatewayApp(None, None).registered_tool_names()
|
|
|
+ self.assertEqual(local, public)
|
|
|
+ self.assertEqual(34, len(local))
|
|
|
+ self.assertEqual(33, len(OutputPresenter.SAFE_TOOLS))
|
|
|
+ self.assertIn('query_container_timeliness_list', local)
|
|
|
+ self.assertIn('export_container_timeliness_report', local)
|
|
|
+
|
|
|
+
|
|
|
+if __name__ == '__main__':
|
|
|
+ unittest.main()
|