| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517 |
- import importlib
- import io
- import json
- import os
- import unittest
- from app import GatewayApp
- from mcp_protocol import McpProtocolHandler
- from public_gateway import PublicGatewayApp
- from services.output_presenter import OutputPresenter
- LIST_KEYS = [
- 'outbound_number', 'direct_send', 'remark', 'cargo_type',
- 'warehouse_name', 'status', 'so_number', 'container_code',
- 'seal_number', 'container_type', 'shipping_method', 'total_volume',
- 'total_weight', 'ship_schedule', 'closing_time', 'est_loading_time',
- 'operator', 'operation_modes', 'operation_time',
- ]
- DETAIL_KEYS = [
- 'order_number', 'cargo_type', 'customs_files', 'reference_number',
- 'customer_name', 'declaration_type', 'merge_declare_number',
- 'order_remark', 'bl_number', 'container_code', 'customer_service',
- 'est_inbound_date', 'inbound_date', 'status', 'pieces', 'weight',
- 'volume', 'pickup_place', 'product_name', 'goods_name', 'closing_time',
- 'clearance_remark', 'import_clearance_remark', 'delivery_address',
- 'delivery_type', 'delivery_way', 'channel', 'country_name',
- 'importer_name', 'vat_type', 'packing_type', 'is_abnormal',
- 'package_method', 'order_reply',
- ]
- class RecordingApiClient:
- def __init__(self):
- self.last_call = None
- def list_enabled_tools(self, request_id=''):
- return {
- 'code': 'MCP_0000',
- 'data': {'tool_codes': [
- 'query_outbound_list', 'query_outbound_detail',
- ]},
- }
- def call_tool(self, tool_code, route_path, payload, request_id):
- self.last_call = {
- 'tool_code': tool_code,
- 'route_path': route_path,
- 'payload': payload,
- 'request_id': request_id,
- }
- return {'code': 'MCP_0000', 'data': {}}
- class PublicSessionStore:
- def get(self, gateway_session_id):
- if gateway_session_id == 'GWS_test':
- return {'mcp_token': 'MT_test', 'company_id': 7, 'admin_id': 9}
- return None
- class PublicApiClient:
- def list_enabled_tools(self, token, request_id=''):
- return {
- 'code': 'MCP_0000',
- 'data': {'tool_codes': [
- 'query_outbound_list', 'query_outbound_detail',
- ]},
- }
- def call_tool(self, **kwargs):
- return {'code': 'MCP_0000', 'data': {}}
- class OutboundQueryToolTest(unittest.TestCase):
- def tool_class(self, module_name, class_name):
- root = os.path.dirname(os.path.dirname(__file__))
- self.assertTrue(os.path.exists(os.path.join(
- root, 'tools', module_name + '.py'
- )))
- return getattr(importlib.import_module('tools.' + module_name), class_name)
- def test_list_metadata_is_bounded_and_never_accepts_identity_overrides(self):
- cls = self.tool_class('query_outbound_list', 'QueryOutboundListTool')
- metadata = cls().metadata()
- schema = metadata['input_schema']
- properties = schema['properties']
- self.assertEqual('query_outbound_list', metadata['name'])
- self.assertFalse(schema['additionalProperties'])
- for forbidden in ('company_id', 'admin_id', 'is_super', 'outbound_id'):
- self.assertNotIn(forbidden, properties)
- for field in (
- 'outbound_numbers', 'order_numbers', 'container_codes',
- 'so_numbers', 'bl_numbers',
- ):
- self.assertEqual('array', properties[field]['type'])
- self.assertEqual(100, properties[field]['maxItems'])
- self.assertEqual(['outbound_status'], schema['required'])
- self.assertNotIn('default', properties['outbound_status'])
- self.assertEqual(
- [20, 30, 60, 70, 80, 90, 100, 110, 120],
- properties['outbound_status']['enum'],
- )
- for label in (
- '国内排舱', '国内拖车', '出库装柜', '出口报关', '干线运输',
- '目的国清关', '海外拖车', '海外仓入库', '完成',
- ):
- self.assertIn(label, properties['outbound_status']['description'])
- self.assertNotIn('10、20', properties['outbound_status']['description'])
- self.assertNotIn('40、45、50、60', properties['outbound_status']['description'])
- self.assertNotIn('default', properties['shipping_method'])
- self.assertIn('未指定时查询全部', properties['shipping_method']['description'])
- self.assertEqual(100, properties['limit']['maximum'])
- self.assertIn('后台排舱单列表', metadata['description'])
- self.assertIn('不得展示内部参数名', metadata['description'])
- def test_list_call_normalizes_and_forwards_only_supported_filters(self):
- cls = self.tool_class('query_outbound_list', 'QueryOutboundListTool')
- client = RecordingApiClient()
- tool = cls(api_client=client)
- tool.call(
- outbound_numbers=[' PC001 ', 'PC001', 'PC002'],
- outbound_status=60,
- shipping_method=2,
- trailer_types=[1, 2],
- page=2,
- limit=50,
- request_id='rq_list',
- )
- self.assertEqual('query_outbound_list', client.last_call['tool_code'])
- self.assertEqual('/mcp/tools/queryOutboundList', client.last_call['route_path'])
- self.assertEqual({
- 'outbound_numbers': ['PC001', 'PC002'],
- 'outbound_status': 60,
- 'shipping_method': 2,
- 'trailer_types': [1, 2],
- 'page': 2,
- 'limit': 50,
- }, client.last_call['payload'])
- def test_list_call_forwards_complete_optional_filter_contract(self):
- cls = self.tool_class('query_outbound_list', 'QueryOutboundListTool')
- client = RecordingApiClient()
- tool = cls(api_client=client)
- tool.call(
- order_numbers=['ORDER001'],
- outbound_status=20,
- container_codes=['CONT001'],
- so_numbers=['SO001'],
- bl_numbers=['BL001'],
- warehouse_id=3,
- is_direct_send=0,
- declaration_types=[1],
- clearance_types=[2],
- closing_time_start='2026-07-01',
- closing_time_end='2026-07-02',
- est_loading_time_start='2026-07-03',
- est_loading_time_end='2026-07-04',
- create_date_start='2026-07-05',
- create_date_end='2026-07-06',
- loading_time_start='2026-07-07',
- loading_time_end='2026-07-08',
- )
- payload = client.last_call['payload']
- self.assertNotIn('shipping_method', payload)
- self.assertEqual(['ORDER001'], payload['order_numbers'])
- self.assertEqual(['CONT001'], payload['container_codes'])
- self.assertEqual(['SO001'], payload['so_numbers'])
- self.assertEqual(['BL001'], payload['bl_numbers'])
- self.assertEqual(3, payload['warehouse_id'])
- self.assertEqual(0, payload['is_direct_send'])
- self.assertEqual([1], payload['declaration_types'])
- self.assertEqual([2], payload['clearance_types'])
- for field in (
- 'closing_time_start', 'closing_time_end',
- 'est_loading_time_start', 'est_loading_time_end',
- 'create_date_start', 'create_date_end',
- 'loading_time_start', 'loading_time_end',
- ):
- self.assertIn(field, payload)
- def test_list_call_rejects_invalid_boundary_shapes(self):
- cls = self.tool_class('query_outbound_list', 'QueryOutboundListTool')
- with self.assertRaises(RuntimeError):
- cls().call()
- client = RecordingApiClient()
- tool = cls(api_client=client)
- invalid_calls = (
- lambda: tool.call(),
- lambda: tool.call(outbound_status=20, outbound_numbers='PC001'),
- lambda: tool.call(outbound_status=20, outbound_numbers=[1]),
- lambda: tool.call(outbound_status=20, outbound_numbers=[' ']),
- lambda: tool.call(outbound_status=20, outbound_numbers=[]),
- lambda: tool.call(
- outbound_status=20,
- outbound_numbers=[str(i) for i in range(101)],
- ),
- lambda: tool.call(outbound_status=20, trailer_types=[]),
- lambda: tool.call(outbound_status=20, page=True),
- lambda: tool.call(outbound_status=20, page='bad'),
- lambda: tool.call(outbound_status=20, page=0),
- lambda: tool.call(outbound_status=True),
- lambda: tool.call(outbound_status='bad'),
- lambda: tool.call(outbound_status=999),
- lambda: tool.call(outbound_status=20, closing_time_start='x' * 20),
- )
- for invalid_call in invalid_calls:
- with self.subTest(invalid_call=invalid_call):
- with self.assertRaises(ValueError):
- invalid_call()
- tool.call(outbound_status=20, trailer_types=[1, 1])
- self.assertEqual([1], client.last_call['payload']['trailer_types'])
- def test_detail_requires_outbound_number_and_caps_page_size_at_twenty(self):
- cls = self.tool_class('query_outbound_detail', 'QueryOutboundDetailTool')
- metadata = cls().metadata()
- schema = metadata['input_schema']
- self.assertEqual(['outbound_number'], schema['required'])
- self.assertEqual(20, schema['properties']['limit']['maximum'])
- self.assertNotIn('outbound_id', schema['properties'])
- client = RecordingApiClient()
- tool = cls(api_client=client)
- with self.assertRaises(ValueError):
- tool.call(outbound_number=' ', limit=10)
- with self.assertRaises(ValueError):
- tool.call(outbound_number='PC001', limit=21)
- tool.call(
- outbound_number=' PC001 ', page=2, limit=20,
- request_id='rq_detail',
- )
- self.assertEqual('/mcp/tools/queryOutboundDetail', client.last_call['route_path'])
- self.assertEqual({
- 'outbound_number': 'PC001', 'page': 2, 'limit': 20,
- }, client.last_call['payload'])
- def test_detail_rejects_missing_client_and_invalid_integer_shapes(self):
- cls = self.tool_class('query_outbound_detail', 'QueryOutboundDetailTool')
- with self.assertRaises(RuntimeError):
- cls().call(outbound_number='PC001')
- tool = cls(api_client=RecordingApiClient())
- invalid_calls = (
- lambda: tool.call(outbound_number=1),
- lambda: tool.call(outbound_number='PC001', page=True),
- lambda: tool.call(outbound_number='PC001', page='bad'),
- lambda: tool.call(outbound_number='PC001', page=0),
- )
- for invalid_call in invalid_calls:
- with self.subTest(invalid_call=invalid_call):
- with self.assertRaises(ValueError):
- invalid_call()
- def test_local_and_public_gateways_register_both_tools(self):
- local = GatewayApp(api_client=RecordingApiClient())
- public = PublicGatewayApp(PublicSessionStore(), PublicApiClient())
- for name in ('query_outbound_list', 'query_outbound_detail'):
- self.assertIn(name, local.registered_tool_names())
- self.assertIn(name, public.registered_tool_names())
- def test_cli_forwards_outbound_list_filters(self):
- client = RecordingApiClient()
- app = GatewayApp(api_client=client)
- output = io.StringIO()
- result = app.run_cli([
- 'call', '--tool', 'query_outbound_list',
- '--outbound-numbers', 'PC001,PC002',
- '--bl-numbers', 'BL001,BL002',
- '--outbound-status', '120',
- '--shipping-method', '2',
- '--warehouse-id', '3',
- '--is-direct-send', '1',
- '--trailer-types', '1,2',
- '--closing-time-start', '2026-07-01',
- '--page', '2', '--limit', '5',
- ], stdout=output)
- self.assertEqual(0, result)
- self.assertEqual({
- 'outbound_numbers': ['PC001', 'PC002'],
- 'bl_numbers': ['BL001', 'BL002'],
- 'outbound_status': 120,
- 'shipping_method': 2,
- 'warehouse_id': 3,
- 'is_direct_send': 1,
- 'trailer_types': [1, 2],
- 'closing_time_start': '2026-07-01',
- 'page': 2,
- 'limit': 5,
- }, client.last_call['payload'])
- def test_cli_outbound_list_requires_stage_and_omits_unspecified_shipping_method(self):
- client = RecordingApiClient()
- app = GatewayApp(api_client=client)
- with self.assertRaises(ValueError):
- app.run_cli([
- 'call', '--tool', 'query_outbound_list',
- ], stdout=io.StringIO())
- result = app.run_cli([
- 'call', '--tool', 'query_outbound_list',
- '--outbound-status', '20',
- ], stdout=io.StringIO())
- self.assertEqual(0, result)
- self.assertEqual({
- 'outbound_status': 20,
- 'page': 1,
- 'limit': 20,
- }, client.last_call['payload'])
- def test_cli_requires_and_forwards_outbound_detail_number(self):
- client = RecordingApiClient()
- app = GatewayApp(api_client=client)
- with self.assertRaises(ValueError):
- app.run_cli([
- 'call', '--tool', 'query_outbound_detail',
- ], stdout=io.StringIO())
- result = app.run_cli([
- 'call', '--tool', 'query_outbound_detail',
- '--outbound-number', 'PC001', '--page', '2', '--limit', '10',
- ], stdout=io.StringIO())
- self.assertEqual(0, result)
- self.assertEqual({
- 'outbound_number': 'PC001', 'page': 2, 'limit': 10,
- }, client.last_call['payload'])
- def test_list_presenter_outputs_exact_nineteen_chinese_columns(self):
- presenter = OutputPresenter()
- data = {
- 'summary': '当前页返回 1 张排舱单',
- 'columns': [
- {'key': key, 'name': 'backend_' + key} for key in LIST_KEYS
- ],
- 'records': [{key: 'display value' for key in LIST_KEYS}],
- }
- result = presenter.present('query_outbound_list', {
- 'code': 'MCP_0000',
- 'data': data,
- 'meta': {'page': 1, 'limit': 20, 'has_more': False},
- })
- self.assertFalse(result['is_error'])
- content = result['structured_content']
- self.assertEqual(19, len(content['headers']))
- self.assertEqual(19, len(content['rows'][0]))
- serialized = json.dumps(content, ensure_ascii=False)
- for key in LIST_KEYS:
- self.assertNotIn(key, serialized)
- self.assertIn('排舱单号', serialized)
- self.assertIn('拖报清方式', serialized)
- def test_detail_presenter_outputs_eleven_item_summary_and_thirty_four_fields(self):
- presenter = OutputPresenter()
- summary = {
- 'bl_number': 'BL001',
- 'container_code': 'CONT001',
- 'container_type': '纸箱',
- 'total_volume': '10',
- 'total_weight': '20',
- 'total_pieces': 3,
- 'sku': 4,
- 'buy_declaration_count': 1,
- 'general_declaration_count': 2,
- 'must_load': '1/5',
- 'backup_load': '2/5',
- }
- detail_record = {key: 'display value' for key in DETAIL_KEYS}
- detail_record['customs_files'] = [{
- 'file_name': 'declaration.pdf',
- 'file_type': 'pdf',
- 'file_url': 'https://files.example/declaration.pdf',
- }]
- result = presenter.present('query_outbound_detail', {
- 'code': 'MCP_0000',
- 'data': {
- 'summary': summary,
- 'columns': [
- {'key': key, 'name': 'backend_' + key}
- for key in DETAIL_KEYS
- ],
- 'records': [detail_record],
- },
- 'meta': {'page': 1, 'limit': 10, 'has_more': False},
- })
- self.assertFalse(result['is_error'])
- content = result['structured_content']
- self.assertEqual(11, len(content['summary']['headers']))
- self.assertEqual(11, len(content['summary']['row']))
- self.assertEqual(34, len(content['details']['headers']))
- self.assertEqual(34, len(content['details']['rows'][0]))
- serialized = json.dumps(content, ensure_ascii=False)
- for key in list(summary) + DETAIL_KEYS:
- self.assertNotIn(key, serialized)
- for key in ('file_name', 'file_type', 'file_url'):
- self.assertNotIn(key, serialized)
- self.assertIn('提单号', serialized)
- self.assertIn('订单号', serialized)
- self.assertIn('报关资料', serialized)
- self.assertIn('文件名称', serialized)
- self.assertIn('文件链接', serialized)
- def test_detail_presenter_rejects_every_malformed_boundary(self):
- presenter = OutputPresenter()
- def valid_data():
- summary = {
- key: '' for key in presenter.OUTBOUND_DETAIL_SUMMARY
- }
- record = {key: '' for key in DETAIL_KEYS}
- record['customs_files'] = []
- return {
- 'summary': summary,
- 'columns': [{'key': key} for key in DETAIL_KEYS],
- 'records': [record],
- }
- malformed = []
- data = valid_data()
- data['summary'] = []
- malformed.append(data)
- data = valid_data()
- data['columns'] = 'bad'
- malformed.append(data)
- data = valid_data()
- data['columns'] = []
- malformed.append(data)
- data = valid_data()
- data['records'] = {}
- malformed.append(data)
- data = valid_data()
- del data['summary']['sku']
- malformed.append(data)
- data = valid_data()
- data['columns'] = [None]
- malformed.append(data)
- data = valid_data()
- data['columns'] = [{'key': 1}]
- malformed.append(data)
- data = valid_data()
- data['columns'] = [{'key': 'unknown'}]
- malformed.append(data)
- data = valid_data()
- data['records'] = [None]
- malformed.append(data)
- data = valid_data()
- data['records'][0]['customs_files'] = 'bad'
- malformed.append(data)
- data = valid_data()
- data['records'][0]['customs_files'] = [None]
- malformed.append(data)
- for data in malformed:
- with self.subTest(data=data):
- result = presenter.present('query_outbound_detail', {
- 'code': 'MCP_0000',
- 'data': data,
- })
- self.assertTrue(result['is_error'])
- self.assertEqual(
- '工具返回格式异常',
- result['structured_content']['message'],
- )
- def test_detail_presenter_renders_tips_without_pagination_and_none_values(self):
- presenter = OutputPresenter()
- summary = {key: '' for key in presenter.OUTBOUND_DETAIL_SUMMARY}
- record = {key: '' for key in DETAIL_KEYS}
- record['order_number'] = None
- record['customs_files'] = []
- result = presenter.present('query_outbound_detail', {
- 'code': 'MCP_0000',
- 'data': {
- 'summary': summary,
- 'columns': [{'key': key} for key in DETAIL_KEYS],
- 'records': [record],
- 'tips': ['没有更多数据'],
- },
- })
- self.assertFalse(result['is_error'])
- details = result['structured_content']['details']
- self.assertNotIn('pagination', details)
- self.assertEqual(['没有更多数据'], details['tips'])
- self.assertEqual('', details['rows'][0][0])
- self.assertIn('提示:没有更多数据', result['text'])
- def test_protocol_safe_error_paths_work_without_trace_request_id(self):
- response = McpProtocolHandler._tool_exception_response(
- 1,
- 'query_outbound_detail',
- ValueError('outbound_number is required'),
- )
- self.assertNotIn('_meta', response['result'])
- error = McpProtocolHandler._error_response(2, -32600, 'Invalid Request')
- self.assertNotIn('data', error['error'])
- if __name__ == '__main__':
- unittest.main()
|