|
@@ -0,0 +1,516 @@
|
|
|
|
|
+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()
|