test_outbound_query_tools.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532
  1. import importlib
  2. import io
  3. import json
  4. import os
  5. import unittest
  6. from app import GatewayApp
  7. from mcp_protocol import McpProtocolHandler
  8. from public_gateway import PublicGatewayApp
  9. from services.output_presenter import OutputPresenter
  10. LIST_KEYS = [
  11. 'outbound_number', 'direct_send', 'remark', 'cargo_type',
  12. 'warehouse_name', 'status', 'so_number', 'container_code',
  13. 'seal_number', 'container_type', 'cabinet_type', 'shipping_method',
  14. 'total_volume', 'total_weight', 'ship_schedule', 'closing_time',
  15. 'est_loading_time', 'operator', 'operation_modes', 'handle_method',
  16. 'operation_time', 'ship_company', 'vessel_name', 'route',
  17. 'cutoff_time_si', 'clearance_port', 'clearance_port_eta',
  18. 'loading_port', 'destination_port', 'wharf_name', 'transit_port',
  19. 'etd', 'eta', 'ata', 'clearance_release_time', 'overseas_warehouse',
  20. 'providers_name', 'has_fda', 'has_cpsc', 'has_food',
  21. ]
  22. DETAIL_KEYS = [
  23. 'order_number', 'cargo_type', 'customs_files', 'reference_number',
  24. 'customer_name', 'declaration_type', 'merge_declare_number',
  25. 'order_remark', 'bl_number', 'container_code', 'customer_service',
  26. 'est_inbound_date', 'inbound_date', 'status', 'pieces', 'weight',
  27. 'volume', 'pickup_place', 'product_name', 'goods_name', 'closing_time',
  28. 'clearance_remark', 'import_clearance_remark', 'delivery_address',
  29. 'delivery_type', 'delivery_way', 'channel', 'country_name',
  30. 'importer_name', 'vat_type', 'packing_type', 'is_abnormal',
  31. 'package_method', 'order_reply',
  32. 'is_must_load', 'has_fda', 'has_cpsc', 'has_food',
  33. ]
  34. class RecordingApiClient:
  35. def __init__(self):
  36. self.last_call = None
  37. def list_enabled_tools(self, request_id=''):
  38. return {
  39. 'code': 'MCP_0000',
  40. 'data': {'tool_codes': [
  41. 'query_outbound_list', 'query_outbound_detail',
  42. ]},
  43. }
  44. def call_tool(self, tool_code, route_path, payload, request_id):
  45. self.last_call = {
  46. 'tool_code': tool_code,
  47. 'route_path': route_path,
  48. 'payload': payload,
  49. 'request_id': request_id,
  50. }
  51. return {'code': 'MCP_0000', 'data': {}}
  52. class PublicSessionStore:
  53. def get(self, gateway_session_id):
  54. if gateway_session_id == 'GWS_test':
  55. return {'mcp_token': 'MT_test', 'company_id': 7, 'admin_id': 9}
  56. return None
  57. class PublicApiClient:
  58. def list_enabled_tools(self, token, request_id=''):
  59. return {
  60. 'code': 'MCP_0000',
  61. 'data': {'tool_codes': [
  62. 'query_outbound_list', 'query_outbound_detail',
  63. ]},
  64. }
  65. def call_tool(self, **kwargs):
  66. return {'code': 'MCP_0000', 'data': {}}
  67. class OutboundQueryToolTest(unittest.TestCase):
  68. def tool_class(self, module_name, class_name):
  69. root = os.path.dirname(os.path.dirname(__file__))
  70. self.assertTrue(os.path.exists(os.path.join(
  71. root, 'tools', module_name + '.py'
  72. )))
  73. return getattr(importlib.import_module('tools.' + module_name), class_name)
  74. def test_list_metadata_is_bounded_and_never_accepts_identity_overrides(self):
  75. cls = self.tool_class('query_outbound_list', 'QueryOutboundListTool')
  76. metadata = cls().metadata()
  77. schema = metadata['input_schema']
  78. properties = schema['properties']
  79. self.assertEqual('query_outbound_list', metadata['name'])
  80. self.assertFalse(schema['additionalProperties'])
  81. for forbidden in ('company_id', 'admin_id', 'is_super', 'outbound_id'):
  82. self.assertNotIn(forbidden, properties)
  83. for field in (
  84. 'outbound_numbers', 'order_numbers', 'container_codes',
  85. 'so_numbers', 'bl_numbers',
  86. ):
  87. self.assertEqual('array', properties[field]['type'])
  88. self.assertEqual(100, properties[field]['maxItems'])
  89. self.assertEqual(['outbound_status'], schema['required'])
  90. self.assertNotIn('default', properties['outbound_status'])
  91. self.assertEqual(
  92. [20, 30, 60, 70, 80, 90, 100, 110, 120],
  93. properties['outbound_status']['enum'],
  94. )
  95. for label in (
  96. '国内排舱', '国内拖车', '出库装柜', '出口报关', '干线运输',
  97. '目的国清关', '海外拖车', '海外仓入库', '完成',
  98. ):
  99. self.assertIn(label, properties['outbound_status']['description'])
  100. self.assertNotIn('10、20', properties['outbound_status']['description'])
  101. self.assertNotIn('40、45、50、60', properties['outbound_status']['description'])
  102. self.assertNotIn('default', properties['shipping_method'])
  103. self.assertIn('未指定时查询全部', properties['shipping_method']['description'])
  104. self.assertEqual(100, properties['limit']['maximum'])
  105. self.assertIn('后台排舱单列表', metadata['description'])
  106. self.assertIn('不得展示内部参数名', metadata['description'])
  107. def test_list_call_normalizes_and_forwards_only_supported_filters(self):
  108. cls = self.tool_class('query_outbound_list', 'QueryOutboundListTool')
  109. client = RecordingApiClient()
  110. tool = cls(api_client=client)
  111. tool.call(
  112. outbound_numbers=[' PC001 ', 'PC001', 'PC002'],
  113. outbound_status=60,
  114. shipping_method=2,
  115. trailer_types=[1, 2],
  116. page=2,
  117. limit=50,
  118. request_id='rq_list',
  119. )
  120. self.assertEqual('query_outbound_list', client.last_call['tool_code'])
  121. self.assertEqual('/mcp/tools/queryOutboundList', client.last_call['route_path'])
  122. self.assertEqual({
  123. 'outbound_numbers': ['PC001', 'PC002'],
  124. 'outbound_status': 60,
  125. 'shipping_method': 2,
  126. 'trailer_types': [1, 2],
  127. 'page': 2,
  128. 'limit': 50,
  129. }, client.last_call['payload'])
  130. def test_list_call_forwards_complete_optional_filter_contract(self):
  131. cls = self.tool_class('query_outbound_list', 'QueryOutboundListTool')
  132. client = RecordingApiClient()
  133. tool = cls(api_client=client)
  134. tool.call(
  135. order_numbers=['ORDER001'],
  136. outbound_status=20,
  137. container_codes=['CONT001'],
  138. so_numbers=['SO001'],
  139. bl_numbers=['BL001'],
  140. warehouse_id=3,
  141. is_direct_send=0,
  142. declaration_types=[1],
  143. clearance_types=[2],
  144. closing_time_start='2026-07-01',
  145. closing_time_end='2026-07-02',
  146. est_loading_time_start='2026-07-03',
  147. est_loading_time_end='2026-07-04',
  148. create_date_start='2026-07-05',
  149. create_date_end='2026-07-06',
  150. loading_time_start='2026-07-07',
  151. loading_time_end='2026-07-08',
  152. )
  153. payload = client.last_call['payload']
  154. self.assertNotIn('shipping_method', payload)
  155. self.assertEqual(['ORDER001'], payload['order_numbers'])
  156. self.assertEqual(['CONT001'], payload['container_codes'])
  157. self.assertEqual(['SO001'], payload['so_numbers'])
  158. self.assertEqual(['BL001'], payload['bl_numbers'])
  159. self.assertEqual(3, payload['warehouse_id'])
  160. self.assertEqual(0, payload['is_direct_send'])
  161. self.assertEqual([1], payload['declaration_types'])
  162. self.assertEqual([2], payload['clearance_types'])
  163. for field in (
  164. 'closing_time_start', 'closing_time_end',
  165. 'est_loading_time_start', 'est_loading_time_end',
  166. 'create_date_start', 'create_date_end',
  167. 'loading_time_start', 'loading_time_end',
  168. ):
  169. self.assertIn(field, payload)
  170. def test_list_call_rejects_invalid_boundary_shapes(self):
  171. cls = self.tool_class('query_outbound_list', 'QueryOutboundListTool')
  172. with self.assertRaises(RuntimeError):
  173. cls().call()
  174. client = RecordingApiClient()
  175. tool = cls(api_client=client)
  176. invalid_calls = (
  177. lambda: tool.call(),
  178. lambda: tool.call(outbound_status=20, outbound_numbers='PC001'),
  179. lambda: tool.call(outbound_status=20, outbound_numbers=[1]),
  180. lambda: tool.call(outbound_status=20, outbound_numbers=[' ']),
  181. lambda: tool.call(outbound_status=20, outbound_numbers=[]),
  182. lambda: tool.call(
  183. outbound_status=20,
  184. outbound_numbers=[str(i) for i in range(101)],
  185. ),
  186. lambda: tool.call(outbound_status=20, trailer_types=[]),
  187. lambda: tool.call(outbound_status=20, page=True),
  188. lambda: tool.call(outbound_status=20, page='bad'),
  189. lambda: tool.call(outbound_status=20, page=0),
  190. lambda: tool.call(outbound_status=True),
  191. lambda: tool.call(outbound_status='bad'),
  192. lambda: tool.call(outbound_status=999),
  193. lambda: tool.call(outbound_status=20, closing_time_start='x' * 20),
  194. )
  195. for invalid_call in invalid_calls:
  196. with self.subTest(invalid_call=invalid_call):
  197. with self.assertRaises(ValueError):
  198. invalid_call()
  199. tool.call(outbound_status=20, trailer_types=[1, 1])
  200. self.assertEqual([1], client.last_call['payload']['trailer_types'])
  201. def test_detail_requires_outbound_number_and_caps_page_size_at_twenty(self):
  202. cls = self.tool_class('query_outbound_detail', 'QueryOutboundDetailTool')
  203. metadata = cls().metadata()
  204. schema = metadata['input_schema']
  205. self.assertEqual(['outbound_number'], schema['required'])
  206. self.assertEqual(20, schema['properties']['limit']['maximum'])
  207. self.assertNotIn('outbound_id', schema['properties'])
  208. client = RecordingApiClient()
  209. tool = cls(api_client=client)
  210. with self.assertRaises(ValueError):
  211. tool.call(outbound_number=' ', limit=10)
  212. with self.assertRaises(ValueError):
  213. tool.call(outbound_number='PC001', limit=21)
  214. tool.call(
  215. outbound_number=' PC001 ', page=2, limit=20,
  216. request_id='rq_detail',
  217. )
  218. self.assertEqual('/mcp/tools/queryOutboundDetail', client.last_call['route_path'])
  219. self.assertEqual({
  220. 'outbound_number': 'PC001', 'page': 2, 'limit': 20,
  221. }, client.last_call['payload'])
  222. def test_detail_rejects_missing_client_and_invalid_integer_shapes(self):
  223. cls = self.tool_class('query_outbound_detail', 'QueryOutboundDetailTool')
  224. with self.assertRaises(RuntimeError):
  225. cls().call(outbound_number='PC001')
  226. tool = cls(api_client=RecordingApiClient())
  227. invalid_calls = (
  228. lambda: tool.call(outbound_number=1),
  229. lambda: tool.call(outbound_number='PC001', page=True),
  230. lambda: tool.call(outbound_number='PC001', page='bad'),
  231. lambda: tool.call(outbound_number='PC001', page=0),
  232. )
  233. for invalid_call in invalid_calls:
  234. with self.subTest(invalid_call=invalid_call):
  235. with self.assertRaises(ValueError):
  236. invalid_call()
  237. def test_local_and_public_gateways_register_both_tools(self):
  238. local = GatewayApp(api_client=RecordingApiClient())
  239. public = PublicGatewayApp(PublicSessionStore(), PublicApiClient())
  240. for name in ('query_outbound_list', 'query_outbound_detail'):
  241. self.assertIn(name, local.registered_tool_names())
  242. self.assertIn(name, public.registered_tool_names())
  243. def test_cli_forwards_outbound_list_filters(self):
  244. client = RecordingApiClient()
  245. app = GatewayApp(api_client=client)
  246. output = io.StringIO()
  247. result = app.run_cli([
  248. 'call', '--tool', 'query_outbound_list',
  249. '--outbound-numbers', 'PC001,PC002',
  250. '--bl-numbers', 'BL001,BL002',
  251. '--outbound-status', '120',
  252. '--shipping-method', '2',
  253. '--warehouse-id', '3',
  254. '--is-direct-send', '1',
  255. '--trailer-types', '1,2',
  256. '--closing-time-start', '2026-07-01',
  257. '--page', '2', '--limit', '5',
  258. ], stdout=output)
  259. self.assertEqual(0, result)
  260. self.assertEqual({
  261. 'outbound_numbers': ['PC001', 'PC002'],
  262. 'bl_numbers': ['BL001', 'BL002'],
  263. 'outbound_status': 120,
  264. 'shipping_method': 2,
  265. 'warehouse_id': 3,
  266. 'is_direct_send': 1,
  267. 'trailer_types': [1, 2],
  268. 'closing_time_start': '2026-07-01',
  269. 'page': 2,
  270. 'limit': 5,
  271. }, client.last_call['payload'])
  272. def test_cli_outbound_list_requires_stage_and_omits_unspecified_shipping_method(self):
  273. client = RecordingApiClient()
  274. app = GatewayApp(api_client=client)
  275. with self.assertRaises(ValueError):
  276. app.run_cli([
  277. 'call', '--tool', 'query_outbound_list',
  278. ], stdout=io.StringIO())
  279. result = app.run_cli([
  280. 'call', '--tool', 'query_outbound_list',
  281. '--outbound-status', '20',
  282. ], stdout=io.StringIO())
  283. self.assertEqual(0, result)
  284. self.assertEqual({
  285. 'outbound_status': 20,
  286. 'page': 1,
  287. 'limit': 20,
  288. }, client.last_call['payload'])
  289. def test_cli_requires_and_forwards_outbound_detail_number(self):
  290. client = RecordingApiClient()
  291. app = GatewayApp(api_client=client)
  292. with self.assertRaises(ValueError):
  293. app.run_cli([
  294. 'call', '--tool', 'query_outbound_detail',
  295. ], stdout=io.StringIO())
  296. result = app.run_cli([
  297. 'call', '--tool', 'query_outbound_detail',
  298. '--outbound-number', 'PC001', '--page', '2', '--limit', '10',
  299. ], stdout=io.StringIO())
  300. self.assertEqual(0, result)
  301. self.assertEqual({
  302. 'outbound_number': 'PC001', 'page': 2, 'limit': 10,
  303. }, client.last_call['payload'])
  304. def test_list_presenter_outputs_exact_nineteen_chinese_columns(self):
  305. presenter = OutputPresenter()
  306. data = {
  307. 'summary': '当前页返回 1 张排舱单',
  308. 'columns': [
  309. {'key': key, 'name': 'backend_' + key} for key in LIST_KEYS
  310. ],
  311. 'records': [{key: 'display value' for key in LIST_KEYS}],
  312. }
  313. result = presenter.present('query_outbound_list', {
  314. 'code': 'MCP_0000',
  315. 'data': data,
  316. 'meta': {'page': 1, 'limit': 20, 'has_more': False},
  317. })
  318. self.assertFalse(result['is_error'])
  319. content = result['structured_content']
  320. self.assertEqual(40, len(content['headers']))
  321. self.assertEqual(40, len(content['rows'][0]))
  322. serialized = json.dumps(content, ensure_ascii=False)
  323. for key in LIST_KEYS:
  324. self.assertNotIn(key, serialized)
  325. self.assertIn('排舱单号', serialized)
  326. self.assertIn('拖报清方式', serialized)
  327. self.assertIn('航线', serialized)
  328. self.assertIn('清关口岸ETA', serialized)
  329. self.assertIn('整拼类型', serialized)
  330. self.assertIn('操作方式', serialized)
  331. self.assertIn('清关放行时间', serialized)
  332. self.assertIn('ATA', serialized)
  333. self.assertIn('到港码头', serialized)
  334. self.assertIn('海外仓', serialized)
  335. self.assertIn('拖车行', serialized)
  336. def test_detail_presenter_outputs_eleven_item_summary_and_thirty_four_fields(self):
  337. presenter = OutputPresenter()
  338. summary = {
  339. 'bl_number': 'BL001',
  340. 'container_code': 'CONT001',
  341. 'container_type': '纸箱',
  342. 'total_volume': '10',
  343. 'total_weight': '20',
  344. 'total_pieces': 3,
  345. 'sku': 4,
  346. 'buy_declaration_count': 1,
  347. 'general_declaration_count': 2,
  348. 'must_load': '1/5',
  349. 'backup_load': '2/5',
  350. }
  351. detail_record = {key: 'display value' for key in DETAIL_KEYS}
  352. detail_record['customs_files'] = [{
  353. 'file_name': 'declaration.pdf',
  354. 'file_type': 'pdf',
  355. 'file_url': 'https://files.example/declaration.pdf',
  356. }]
  357. result = presenter.present('query_outbound_detail', {
  358. 'code': 'MCP_0000',
  359. 'data': {
  360. 'summary': summary,
  361. 'columns': [
  362. {'key': key, 'name': 'backend_' + key}
  363. for key in DETAIL_KEYS
  364. ],
  365. 'records': [detail_record],
  366. },
  367. 'meta': {'page': 1, 'limit': 10, 'has_more': False},
  368. })
  369. self.assertFalse(result['is_error'])
  370. content = result['structured_content']
  371. self.assertEqual(11, len(content['summary']['headers']))
  372. self.assertEqual(11, len(content['summary']['row']))
  373. self.assertEqual(38, len(content['details']['headers']))
  374. self.assertEqual(38, len(content['details']['rows'][0]))
  375. serialized = json.dumps(content, ensure_ascii=False)
  376. for key in list(summary) + DETAIL_KEYS:
  377. self.assertNotIn(key, serialized)
  378. for key in ('file_name', 'file_type', 'file_url'):
  379. self.assertNotIn(key, serialized)
  380. self.assertIn('提单号', serialized)
  381. self.assertIn('订单号', serialized)
  382. self.assertIn('报关资料', serialized)
  383. self.assertIn('文件名称', serialized)
  384. self.assertIn('文件链接', serialized)
  385. def test_detail_presenter_rejects_every_malformed_boundary(self):
  386. presenter = OutputPresenter()
  387. def valid_data():
  388. summary = {
  389. key: '' for key in presenter.OUTBOUND_DETAIL_SUMMARY
  390. }
  391. record = {key: '' for key in DETAIL_KEYS}
  392. record['customs_files'] = []
  393. return {
  394. 'summary': summary,
  395. 'columns': [{'key': key} for key in DETAIL_KEYS],
  396. 'records': [record],
  397. }
  398. malformed = []
  399. data = valid_data()
  400. data['summary'] = []
  401. malformed.append(data)
  402. data = valid_data()
  403. data['columns'] = 'bad'
  404. malformed.append(data)
  405. data = valid_data()
  406. data['columns'] = []
  407. malformed.append(data)
  408. data = valid_data()
  409. data['records'] = {}
  410. malformed.append(data)
  411. data = valid_data()
  412. del data['summary']['sku']
  413. malformed.append(data)
  414. data = valid_data()
  415. data['columns'] = [None]
  416. malformed.append(data)
  417. data = valid_data()
  418. data['columns'] = [{'key': 1}]
  419. malformed.append(data)
  420. data = valid_data()
  421. data['columns'] = [{'key': 'unknown'}]
  422. malformed.append(data)
  423. data = valid_data()
  424. data['records'] = [None]
  425. malformed.append(data)
  426. data = valid_data()
  427. data['records'][0]['customs_files'] = 'bad'
  428. malformed.append(data)
  429. data = valid_data()
  430. data['records'][0]['customs_files'] = [None]
  431. malformed.append(data)
  432. for data in malformed:
  433. with self.subTest(data=data):
  434. result = presenter.present('query_outbound_detail', {
  435. 'code': 'MCP_0000',
  436. 'data': data,
  437. })
  438. self.assertTrue(result['is_error'])
  439. self.assertEqual(
  440. '工具返回格式异常',
  441. result['structured_content']['message'],
  442. )
  443. def test_detail_presenter_renders_tips_without_pagination_and_none_values(self):
  444. presenter = OutputPresenter()
  445. summary = {key: '' for key in presenter.OUTBOUND_DETAIL_SUMMARY}
  446. record = {key: '' for key in DETAIL_KEYS}
  447. record['order_number'] = None
  448. record['customs_files'] = []
  449. result = presenter.present('query_outbound_detail', {
  450. 'code': 'MCP_0000',
  451. 'data': {
  452. 'summary': summary,
  453. 'columns': [{'key': key} for key in DETAIL_KEYS],
  454. 'records': [record],
  455. 'tips': ['没有更多数据'],
  456. },
  457. })
  458. self.assertFalse(result['is_error'])
  459. details = result['structured_content']['details']
  460. self.assertNotIn('pagination', details)
  461. self.assertEqual(['没有更多数据'], details['tips'])
  462. self.assertEqual('', details['rows'][0][0])
  463. self.assertIn('提示:没有更多数据', result['text'])
  464. def test_protocol_safe_error_paths_work_without_trace_request_id(self):
  465. response = McpProtocolHandler._tool_exception_response(
  466. 1,
  467. 'query_outbound_detail',
  468. ValueError('outbound_number is required'),
  469. )
  470. self.assertNotIn('_meta', response['result'])
  471. error = McpProtocolHandler._error_response(2, -32600, 'Invalid Request')
  472. self.assertNotIn('data', error['error'])
  473. if __name__ == '__main__':
  474. unittest.main()