| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307 |
- 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()
|