test_order_detail_tool.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. import json
  2. import unittest
  3. from app import GatewayApp
  4. from public_gateway import PublicGatewayApp
  5. from services.output_presenter import OutputPresenter
  6. from tools.query_order_detail import QueryOrderDetailTool
  7. class RecordingClient:
  8. def __init__(self):
  9. self.calls = []
  10. def call_tool(self, tool_code, route_path, payload, request_id):
  11. self.calls.append((tool_code, route_path, payload, request_id))
  12. return {'code': 'MCP_0000', 'data': {}, 'meta': {}}
  13. class OrderDetailToolTest(unittest.TestCase):
  14. def test_schema_uses_only_chinese_sections_and_rejects_extra_fields(self):
  15. schema = QueryOrderDetailTool().metadata()['input_schema']
  16. self.assertEqual(['order_number'], schema['required'])
  17. self.assertFalse(schema['additionalProperties'])
  18. self.assertEqual([
  19. '订单概览', '箱单信息', '箱单商品', 'DW授权信息', '附件信息',
  20. '入库信息', '查验信息', '订单轨迹', '操作日志',
  21. '应收与结算日志', '派送信息', '全部',
  22. ], schema['properties']['section']['enum'])
  23. self.assertNotIn('order_id', schema['properties'])
  24. self.assertNotIn('company_id', schema['properties'])
  25. self.assertEqual('全部', schema['properties']['section']['default'])
  26. def test_call_forwards_normalized_payload_to_exact_route(self):
  27. client = RecordingClient()
  28. result = QueryOrderDetailTool(client).call(
  29. order_number=' ORD001 ', section='附件信息', page=2, limit=10,
  30. request_id='rq_detail',
  31. )
  32. self.assertEqual('MCP_0000', result['code'])
  33. self.assertEqual([(
  34. 'query_order_detail', '/mcp/tools/queryOrderDetail',
  35. {'order_number': 'ORD001', 'section': '附件信息', 'page': 2, 'limit': 10},
  36. 'rq_detail',
  37. )], client.calls)
  38. def test_call_rejects_missing_invalid_and_out_of_range_parameters(self):
  39. tool = QueryOrderDetailTool()
  40. with self.assertRaisesRegex(RuntimeError, 'api client'):
  41. tool.call(order_number='ORD001')
  42. tool = QueryOrderDetailTool(RecordingClient())
  43. invalid_calls = (
  44. ({'order_number': None}, 'order_number'),
  45. ({'order_number': ' '}, 'order_number'),
  46. ({'order_number': 'A' * 101}, 'order_number'),
  47. ({'order_number': 'ORD001', 'section': None}, 'section'),
  48. ({'order_number': 'ORD001', 'section': 'unknown'}, 'section'),
  49. ({'order_number': 'ORD001', 'page': True}, 'page'),
  50. ({'order_number': 'ORD001', 'page': '01'}, 'page'),
  51. ({'order_number': 'ORD001', 'page': 'x'}, 'page'),
  52. ({'order_number': 'ORD001', 'limit': 0}, 'limit'),
  53. ({'order_number': 'ORD001', 'limit': 101}, 'limit'),
  54. )
  55. for kwargs, expected in invalid_calls:
  56. with self.subTest(kwargs=kwargs):
  57. with self.assertRaisesRegex(ValueError, expected):
  58. tool.call(**kwargs)
  59. tool.call(order_number='ORD001', page='2', limit='100')
  60. self.assertEqual(2, tool.api_client.calls[-1][2]['page'])
  61. self.assertEqual(100, tool.api_client.calls[-1][2]['limit'])
  62. def test_call_forwards_all_section_without_changing_pagination_contract(self):
  63. client = RecordingClient()
  64. QueryOrderDetailTool(client).call(
  65. order_number='ORD001', section='全部', page=1, limit=20,
  66. request_id='rq_all',
  67. )
  68. self.assertEqual({
  69. 'order_number': 'ORD001', 'section': '全部', 'page': 1, 'limit': 20,
  70. }, client.calls[0][2])
  71. def test_call_defaults_to_all_when_section_is_omitted(self):
  72. client = RecordingClient()
  73. QueryOrderDetailTool(client).call(order_number='ORD001')
  74. self.assertEqual('全部', client.calls[0][2]['section'])
  75. def test_local_and_public_registries_include_order_detail(self):
  76. local = GatewayApp(api_client=object())
  77. public = PublicGatewayApp(session_store=object(), api_client=object())
  78. self.assertIn('query_order_detail', local.registered_tool_names())
  79. self.assertIn('query_order_detail', public.registered_tool_names())
  80. def test_presenter_outputs_attachment_links_with_only_chinese_keys(self):
  81. presenter = OutputPresenter()
  82. result = presenter.present('query_order_detail', {
  83. 'code': 'MCP_0000',
  84. 'data': {
  85. 'section': 'attachments',
  86. 'order_number': 'ORD001',
  87. 'payload': {'records': [{
  88. 'category': '订单附件', 'file_name': 'photo.jpg',
  89. 'file_extension': 'jpg', 'is_image': True,
  90. 'preview_url': 'https://files.example/photo.jpg',
  91. 'download_url': 'https://files.example/download/photo.jpg',
  92. 'cost_name': '',
  93. }]},
  94. },
  95. 'meta': {'request_id': 'rq_detail', 'page': 1, 'limit': 20, 'has_more': False},
  96. })
  97. self.assertFalse(result['is_error'])
  98. content = result['structured_content']
  99. self.assertEqual('ORD001', content['订单号'])
  100. self.assertEqual('附件信息', content['详情模块'])
  101. self.assertEqual('photo.jpg', content['明细'][0]['文件名称'])
  102. self.assertEqual('https://files.example/photo.jpg', content['明细'][0]['预览链接'])
  103. serialized = json.dumps(content, ensure_ascii=False) + result['text']
  104. for internal in ('order_number', 'section', 'payload', 'file_name', 'preview_url', 'page', 'limit', 'has_more'):
  105. self.assertNotIn(internal, serialized)
  106. def test_presenter_outputs_complete_overview_and_translates_enums(self):
  107. presenter = OutputPresenter()
  108. result = presenter.present('query_order_detail', {
  109. 'code': 'MCP_0000',
  110. 'data': {
  111. 'section': 'overview', 'order_number': 'ORD001',
  112. 'payload': {
  113. 'status_nodes': [{
  114. 'stage': '起运段', 'label': '已提交', 'state': 'completed',
  115. 'occurred_at': '2026-01-01', 'time_kind': 'actual',
  116. }],
  117. 'order_info': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['overview']['order_info']),
  118. 'freight_info': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['overview']['freight_info']),
  119. 'package_summary': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['overview']['package_summary']),
  120. 'trader_info': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['overview']['trader_info']),
  121. },
  122. },
  123. 'meta': {'request_id': 'rq_overview'},
  124. })
  125. self.assertFalse(result['is_error'])
  126. content = result['structured_content']
  127. self.assertEqual('已完成', content['状态节点'][0]['状态'])
  128. self.assertEqual('实际', content['状态节点'][0]['时间类型'])
  129. self.assertEqual(
  130. ['订单号', '详情模块', '状态节点', '订单信息', '货运信息', '箱单汇总', '进出口商'],
  131. list(content.keys()),
  132. )
  133. def test_presenter_outputs_all_sections_as_strict_grouped_content(self):
  134. presenter = OutputPresenter()
  135. payload = self.valid_all_payload(presenter)
  136. result = presenter.present('query_order_detail', {
  137. 'code': 'MCP_0000',
  138. 'data': {'section': 'all', 'order_number': 'ORD001', 'payload': payload},
  139. 'meta': {'request_id': 'rq_all'},
  140. })
  141. self.assertFalse(result['is_error'])
  142. content = result['structured_content']
  143. self.assertEqual('全部', content['详情模块'])
  144. self.assertIn('订单概览', content)
  145. self.assertIn('箱单信息', content)
  146. self.assertIn('分页', content['箱单信息'])
  147. serialized = json.dumps(content, ensure_ascii=False) + result['text']
  148. for internal in ('order_number', 'section', 'payload', 'preview_url', 'has_more'):
  149. self.assertNotIn(internal, serialized)
  150. def valid_all_payload(self, presenter):
  151. overview = {
  152. 'status_nodes': [{
  153. 'stage': '起运段', 'label': '已提交', 'state': 'completed',
  154. 'occurred_at': '2026-01-01', 'time_kind': 'actual',
  155. }],
  156. 'order_info': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['overview']['order_info']),
  157. 'freight_info': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['overview']['freight_info']),
  158. 'package_summary': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['overview']['package_summary']),
  159. 'trader_info': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['overview']['trader_info']),
  160. }
  161. pagination = {'page': 1, 'limit': 20, 'has_more': False}
  162. payload = {'overview': overview}
  163. for section in presenter.ORDER_DETAIL_SECTIONS:
  164. if section in ('overview', 'all'):
  165. continue
  166. if section == 'inbound':
  167. value = {
  168. 'summary': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['inbound_summary']),
  169. 'records': [],
  170. }
  171. elif section == 'delivery':
  172. value = {
  173. 'summary': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['delivery_summary']),
  174. 'records': [],
  175. }
  176. else:
  177. value = {'records': []}
  178. value['pagination'] = dict(pagination)
  179. payload[section] = value
  180. return payload
  181. def test_presenter_rejects_malformed_all_section_groups(self):
  182. presenter = OutputPresenter()
  183. cases = []
  184. missing = self.valid_all_payload(presenter)
  185. del missing['checks']
  186. cases.append(missing)
  187. bad_overview = self.valid_all_payload(presenter)
  188. bad_overview['overview'] = []
  189. cases.append(bad_overview)
  190. bad_node = self.valid_all_payload(presenter)
  191. bad_node['overview']['status_nodes'][0]['state'] = 'unknown'
  192. cases.append(bad_node)
  193. bad_node_fields = self.valid_all_payload(presenter)
  194. del bad_node_fields['overview']['status_nodes'][0]['stage']
  195. cases.append(bad_node_fields)
  196. bad_group = self.valid_all_payload(presenter)
  197. bad_group['overview']['order_info']['unexpected'] = ''
  198. cases.append(bad_group)
  199. bad_pagination = self.valid_all_payload(presenter)
  200. bad_pagination['packages']['pagination']['page'] = True
  201. cases.append(bad_pagination)
  202. missing_pagination = self.valid_all_payload(presenter)
  203. del missing_pagination['packages']['pagination']
  204. cases.append(missing_pagination)
  205. bad_inbound_keys = self.valid_all_payload(presenter)
  206. bad_inbound_keys['inbound']['extra'] = ''
  207. cases.append(bad_inbound_keys)
  208. bad_inbound_summary = self.valid_all_payload(presenter)
  209. bad_inbound_summary['inbound']['summary']['extra'] = ''
  210. cases.append(bad_inbound_summary)
  211. bad_delivery_keys = self.valid_all_payload(presenter)
  212. bad_delivery_keys['delivery']['extra'] = ''
  213. cases.append(bad_delivery_keys)
  214. bad_delivery_summary = self.valid_all_payload(presenter)
  215. bad_delivery_summary['delivery']['summary']['extra'] = ''
  216. cases.append(bad_delivery_summary)
  217. bad_rows = self.valid_all_payload(presenter)
  218. bad_rows['packages']['records'] = {}
  219. cases.append(bad_rows)
  220. bad_generic_keys = self.valid_all_payload(presenter)
  221. bad_generic_keys['packages']['extra'] = ''
  222. cases.append(bad_generic_keys)
  223. for payload in cases:
  224. with self.subTest(payload_keys=list(payload)):
  225. result = presenter.present('query_order_detail', {
  226. 'code': 'MCP_0000',
  227. 'data': {'section': 'all', 'order_number': 'ORD001', 'payload': payload},
  228. 'meta': {'request_id': 'rq_all_bad'},
  229. })
  230. self.assertTrue(result['is_error'])
  231. def test_presenter_outputs_inbound_and_delivery_sections(self):
  232. presenter = OutputPresenter()
  233. inbound_mapping = presenter.ORDER_DETAIL_FIELDS['inbound']
  234. inbound_rows = []
  235. for mode, expected_label in (
  236. ('single_box', '单箱入库重量'),
  237. ('total', '总重量KG'),
  238. ):
  239. row = self.fixed_values(inbound_mapping)
  240. row['weight_mode'] = mode
  241. row['weight'] = '12.50'
  242. inbound_rows.append(row)
  243. inbound = presenter.present('query_order_detail', {
  244. 'code': 'MCP_0000',
  245. 'data': {
  246. 'section': 'inbound', 'order_number': ' ORD001 ',
  247. 'payload': {
  248. 'summary': self.fixed_values(
  249. presenter.ORDER_DETAIL_FIELDS['inbound_summary']
  250. ),
  251. 'records': inbound_rows,
  252. },
  253. },
  254. 'meta': {'page': 1, 'limit': 20, 'has_more': True},
  255. })
  256. self.assertFalse(inbound['is_error'])
  257. self.assertEqual('ORD001', inbound['structured_content']['订单号'])
  258. for index, label in enumerate(('单箱入库重量', '总重量KG')):
  259. self.assertEqual('12.50', inbound['structured_content']['明细'][index][label])
  260. delivery = presenter.present('query_order_detail', {
  261. 'code': 'MCP_0000',
  262. 'data': {
  263. 'section': 'delivery', 'order_number': 'ORD001',
  264. 'payload': {
  265. 'summary': self.fixed_values(
  266. presenter.ORDER_DETAIL_FIELDS['delivery_summary']
  267. ),
  268. 'records': [self.fixed_values(
  269. presenter.ORDER_DETAIL_FIELDS['delivery']
  270. )],
  271. },
  272. },
  273. 'meta': {'page': 2, 'limit': 10, 'has_more': False},
  274. })
  275. self.assertFalse(delivery['is_error'])
  276. self.assertIn('派送汇总', delivery['structured_content'])
  277. def test_package_item_product_image_is_exposed_as_count_only(self):
  278. presenter = OutputPresenter()
  279. mapping = presenter.ORDER_DETAIL_FIELDS['package_items']
  280. self.assertEqual('商品图片数量', mapping.get('product_image_count'))
  281. row = self.fixed_values(mapping)
  282. row['product_image_count'] = 1
  283. result = presenter.present('query_order_detail', {
  284. 'code': 'MCP_0000',
  285. 'data': {
  286. 'section': 'package_items', 'order_number': 'ORD001',
  287. 'payload': {'records': [row]},
  288. },
  289. 'meta': {'page': 1, 'limit': 20, 'has_more': False},
  290. })
  291. self.assertFalse(result['is_error'])
  292. self.assertEqual(1, result['structured_content']['明细'][0]['商品图片数量'])
  293. self.assertNotIn('image_url', json.dumps(result, ensure_ascii=False))
  294. def test_unknown_missing_or_extra_fields_fail_closed(self):
  295. presenter = OutputPresenter()
  296. cases = [
  297. {'section': 'secret', 'order_number': 'ORD001', 'payload': {'records': []}},
  298. {'section': 'attachments', 'order_number': 'ORD001', 'payload': {'records': [{'category': 'x'}]}},
  299. {'section': 'attachments', 'order_number': 'ORD001', 'payload': {'records': [{
  300. 'category': '', 'file_name': '', 'file_extension': '', 'is_image': False,
  301. 'preview_url': '', 'download_url': '', 'cost_name': '', 'secret_id': 9,
  302. }]}},
  303. ]
  304. for data in cases:
  305. with self.subTest(data=data):
  306. result = presenter.present('query_order_detail', {
  307. 'code': 'MCP_0000', 'data': data, 'meta': {},
  308. })
  309. self.assertTrue(result['is_error'])
  310. self.assertNotIn('secret_id', json.dumps(result, ensure_ascii=False))
  311. def test_presenter_rejects_every_invalid_order_detail_shape(self):
  312. presenter = OutputPresenter()
  313. valid_meta = {'page': 1, 'limit': 20, 'has_more': False}
  314. overview_groups = {
  315. 'status_nodes': [],
  316. 'order_info': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['overview']['order_info']),
  317. 'freight_info': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['overview']['freight_info']),
  318. 'package_summary': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['overview']['package_summary']),
  319. 'trader_info': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['overview']['trader_info']),
  320. }
  321. invalid_results = [
  322. presenter._present_order_detail({'section': 'attachments'}, valid_meta, {}),
  323. presenter._present_order_detail(
  324. {'section': 'unknown', 'order_number': 'ORD', 'payload': {}}, valid_meta, {}
  325. ),
  326. presenter._present_order_detail(
  327. {'section': 'attachments', 'order_number': 1, 'payload': {}}, valid_meta, {}
  328. ),
  329. presenter._present_order_detail(
  330. {'section': 'attachments', 'order_number': ' ', 'payload': {}}, valid_meta, {}
  331. ),
  332. presenter._present_order_detail(
  333. {'section': 'attachments', 'order_number': 'ORD', 'payload': []}, valid_meta, {}
  334. ),
  335. presenter._present_order_detail(
  336. {'section': 'overview', 'order_number': 'ORD', 'payload': {'status_nodes': []}}, {}, {}
  337. ),
  338. presenter._present_order_detail(
  339. {'section': 'overview', 'order_number': 'ORD', 'payload': dict(overview_groups, status_nodes={})}, {}, {}
  340. ),
  341. ]
  342. bad_node_groups = dict(overview_groups)
  343. bad_node_groups['status_nodes'] = [{'stage': 'x'}]
  344. invalid_results.append(presenter._present_order_detail(
  345. {'section': 'overview', 'order_number': 'ORD', 'payload': bad_node_groups}, {}, {}
  346. ))
  347. bad_enum_groups = dict(overview_groups)
  348. bad_enum_groups['status_nodes'] = [{
  349. 'stage': 'x', 'label': 'x', 'state': 'secret',
  350. 'occurred_at': '', 'time_kind': 'empty',
  351. }]
  352. invalid_results.append(presenter._present_order_detail(
  353. {'section': 'overview', 'order_number': 'ORD', 'payload': bad_enum_groups}, {}, {}
  354. ))
  355. bad_group = dict(overview_groups)
  356. bad_group['order_info'] = {}
  357. invalid_results.append(presenter._present_order_detail(
  358. {'section': 'overview', 'order_number': 'ORD', 'payload': bad_group}, {}, {}
  359. ))
  360. for section, summary_key in (
  361. ('inbound', 'inbound_summary'),
  362. ('delivery', 'delivery_summary'),
  363. ):
  364. mapping = presenter.ORDER_DETAIL_FIELDS[summary_key]
  365. invalid_results.extend([
  366. presenter._present_order_detail(
  367. {'section': section, 'order_number': 'ORD', 'payload': {'records': []}}, valid_meta, {}
  368. ),
  369. presenter._present_order_detail(
  370. {'section': section, 'order_number': 'ORD', 'payload': {'summary': {}, 'records': []}}, valid_meta, {}
  371. ),
  372. presenter._present_order_detail(
  373. {'section': section, 'order_number': 'ORD', 'payload': {
  374. 'summary': self.fixed_values(mapping), 'records': []
  375. }}, {}, {}
  376. ),
  377. ])
  378. invalid_results.extend([
  379. presenter._present_order_detail(
  380. {'section': 'attachments', 'order_number': 'ORD', 'payload': {}}, valid_meta, {}
  381. ),
  382. presenter._present_order_detail(
  383. {'section': 'attachments', 'order_number': 'ORD', 'payload': {'records': {}}}, valid_meta, {}
  384. ),
  385. presenter._present_order_detail(
  386. {'section': 'attachments', 'order_number': 'ORD', 'payload': {'records': []}}, {}, {}
  387. ),
  388. ])
  389. self.assertTrue(all(result['is_error'] for result in invalid_results))
  390. def test_order_detail_translation_helpers_reject_nested_and_invalid_values(self):
  391. presenter = OutputPresenter()
  392. self.assertIsNone(presenter._translate_order_detail_rows('attachments', {}))
  393. self.assertIsNone(presenter._translate_order_detail_rows('unknown', []))
  394. self.assertIsNone(presenter._translate_order_detail_rows('attachments', [{}]))
  395. inbound = self.fixed_values(presenter.ORDER_DETAIL_FIELDS['inbound'])
  396. inbound['weight_mode'] = 'invalid'
  397. self.assertIsNone(presenter._translate_order_detail_rows('inbound', [inbound]))
  398. self.assertIsNone(presenter._translate_exact([], {'key': '标签'}))
  399. self.assertIsNone(presenter._translate_exact({'wrong': 1}, {'key': '标签'}))
  400. self.assertIsNone(presenter._translate_exact({'key': []}, {'key': '标签'}))
  401. self.assertEqual({'标签': ''}, presenter._translate_exact({'key': None}, {'key': '标签'}))
  402. self.assertEqual({}, presenter._translate_exact({'key': 'hidden'}, {'key': None}))
  403. invalid_meta = (
  404. None,
  405. {},
  406. {'page': True, 'limit': 20, 'has_more': False},
  407. {'page': 0, 'limit': 20, 'has_more': False},
  408. {'page': 1, 'limit': True, 'has_more': False},
  409. {'page': 1, 'limit': 0, 'has_more': False},
  410. {'page': 1, 'limit': 20, 'has_more': 1},
  411. )
  412. for meta in invalid_meta:
  413. with self.subTest(meta=meta):
  414. self.assertIsNone(presenter._order_detail_pagination(meta))
  415. def test_parameter_errors_use_chinese_business_labels(self):
  416. presenter = OutputPresenter()
  417. for backend, expected in (
  418. ('order_number is required', '订单号参数不正确'),
  419. ('section is invalid', '详情模块参数不正确'),
  420. ('page is invalid', '页码参数不正确'),
  421. ('limit is invalid', '每页数量参数不正确'),
  422. ):
  423. result = presenter.present('query_order_detail', {
  424. 'code': 'MCP_1401', 'msg': backend, 'data': {},
  425. })
  426. self.assertEqual(expected, result['structured_content']['message'])
  427. self.assertNotIn(backend.split()[0], json.dumps(result, ensure_ascii=False))
  428. @staticmethod
  429. def fixed_values(mapping):
  430. return {key: '' for key in mapping}
  431. if __name__ == '__main__':
  432. unittest.main()