test_outbound_query_tools.py 20 KB

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