test_outbound_query_tools.py 20 KB

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