test_output_presenter.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. import json
  2. import unittest
  3. from services.output_presenter import OutputPresenter
  4. from tools.export_out_of_province_port_data import ExportOutOfProvincePortDataTool
  5. from tools.export_pending_outbound_orders import ExportPendingOutboundOrdersTool
  6. from tools.list_order_filter_options import ListOrderFilterOptionsTool
  7. from tools.list_pending_outbound_export_filter_options import (
  8. ListPendingOutboundExportFilterOptionsTool,
  9. )
  10. from tools.query_order import QueryOrderTool
  11. from tools.query_order_exact import QueryOrderExactTool
  12. from tools.query_track import QueryTrackTool
  13. class OutputPresenterTest(unittest.TestCase):
  14. def setUp(self):
  15. self.presenter = OutputPresenter()
  16. def test_exact_order_uses_labels_and_drops_internal_fields(self):
  17. result = self.presenter.present(
  18. 'query_order_exact',
  19. {
  20. 'code': 'MCP_0000',
  21. 'data': {
  22. 'summary': '当前页返回 1 条订单',
  23. 'columns': [
  24. {'key': 'order_number', 'name': '订单号'},
  25. {
  26. 'key': 'tracking_number',
  27. 'name': '快递单号',
  28. 'description': '承运商跟踪号码',
  29. },
  30. ],
  31. 'records': [{
  32. 'order_number': 'SO001',
  33. 'tracking_number': 'TN001',
  34. 'order_id': 99,
  35. 'time_zone': '8.00',
  36. }],
  37. 'tips': [],
  38. },
  39. 'meta': {
  40. 'page': 1,
  41. 'limit': 20,
  42. 'has_more': False,
  43. 'request_id': 'rq_exact',
  44. },
  45. },
  46. )
  47. self.assertFalse(result['is_error'])
  48. self.assertEqual(
  49. [
  50. {'label': '订单号'},
  51. {'label': '快递单号', 'description': '承运商跟踪号码'},
  52. ],
  53. result['structured_content']['headers'],
  54. )
  55. self.assertEqual(
  56. [['SO001', 'TN001']],
  57. result['structured_content']['rows'],
  58. )
  59. self.assertEqual(
  60. {'page': 1, 'limit': 20, 'has_more': False},
  61. result['structured_content']['pagination'],
  62. )
  63. self.assertEqual({'request_id': 'rq_exact'}, result['meta'])
  64. serialized = json.dumps(result, ensure_ascii=False)
  65. self.assertNotIn('order_number', serialized)
  66. self.assertNotIn('tracking_number', serialized)
  67. self.assertNotIn('order_id', serialized)
  68. self.assertNotIn('time_zone', serialized)
  69. self.assertIn('订单号', result['text'])
  70. self.assertIn('SO001', result['text'])
  71. def test_table_missing_value_is_empty_and_duplicate_labels_are_preserved(self):
  72. result = self.presenter.present(
  73. 'query_track',
  74. {
  75. 'code': '0',
  76. 'data': {
  77. 'columns': [
  78. {'key': 'status', 'name': '状态'},
  79. {'key': 'content', 'name': '状态'},
  80. ],
  81. 'records': [{'status': '已发货'}],
  82. 'tips': ['仅展示已有轨迹'],
  83. },
  84. 'meta': {'request_id': 'rq_track'},
  85. },
  86. )
  87. self.assertEqual(
  88. [{'label': '状态'}, {'label': '状态'}],
  89. result['structured_content']['headers'],
  90. )
  91. self.assertEqual([['已发货', '']], result['structured_content']['rows'])
  92. self.assertEqual(['仅展示已有轨迹'], result['structured_content']['tips'])
  93. def test_empty_table_keeps_safe_headers_and_tips(self):
  94. result = self.presenter.present(
  95. 'query_track',
  96. {
  97. 'code': 'MCP_0000',
  98. 'data': {
  99. 'columns': [{'key': 'status', 'name': '轨迹节点'}],
  100. 'records': [],
  101. 'tips': ['未查询到轨迹信息'],
  102. },
  103. 'meta': {'page': 1, 'limit': 5, 'total': 0},
  104. },
  105. )
  106. self.assertEqual([{'label': '轨迹节点'}], result['structured_content']['headers'])
  107. self.assertEqual([], result['structured_content']['rows'])
  108. self.assertIn('未查询到轨迹信息', result['text'])
  109. without_tips = self.presenter.present(
  110. 'query_track',
  111. {
  112. 'code': 'MCP_0000',
  113. 'data': {
  114. 'columns': [{'key': 'status', 'name': '轨迹节点'}],
  115. 'records': [],
  116. 'tips': 'not-a-list',
  117. },
  118. },
  119. )
  120. self.assertNotIn('tips', without_tips['structured_content'])
  121. def test_filter_options_preserve_values_for_follow_up_calls(self):
  122. result = self.presenter.present(
  123. 'list_order_filter_options',
  124. {
  125. 'code': 'MCP_0000',
  126. 'data': {
  127. 'records': [
  128. {'value': 101, 'label': '客户甲', 'code': 'CUS-A'},
  129. {'value': -1, 'label': '客户仓', 'code': '-1'},
  130. ],
  131. },
  132. 'meta': {'page': 2, 'limit': 20, 'has_more': True},
  133. },
  134. )
  135. self.assertEqual(
  136. [[101, '客户甲', 'CUS-A'], [-1, '客户仓', '-1']],
  137. result['structured_content']['rows'],
  138. )
  139. self.assertEqual(
  140. ['可传值', '显示名称', '业务编码'],
  141. [item['label'] for item in result['structured_content']['headers']],
  142. )
  143. self.assertNotIn('"value"', json.dumps(result, ensure_ascii=False))
  144. def test_pending_filter_options_use_same_safe_shape(self):
  145. result = self.presenter.present(
  146. 'list_pending_outbound_export_filter_options',
  147. {
  148. 'code': 'MCP_0000',
  149. 'data': {'records': [{'value': 'US', 'label': '美国', 'code': 'US'}]},
  150. 'meta': {},
  151. },
  152. )
  153. self.assertEqual([['US', '美国', 'US']], result['structured_content']['rows'])
  154. def test_export_result_preserves_url_without_file_url_key(self):
  155. for tool_name in (
  156. 'export_pending_outbound_orders',
  157. 'export_out_of_province_port_data',
  158. ):
  159. with self.subTest(tool_name=tool_name):
  160. result = self.presenter.present(
  161. tool_name,
  162. {
  163. 'code': 'MCP_0000',
  164. 'data': {'file_url': 'https://files.test/result.xlsx'},
  165. 'meta': {'request_id': 'rq_export'},
  166. },
  167. )
  168. self.assertEqual(
  169. [{
  170. 'label': '导出文件',
  171. 'url': 'https://files.test/result.xlsx',
  172. }],
  173. result['structured_content']['files'],
  174. )
  175. self.assertNotIn('file_url', json.dumps(result, ensure_ascii=False))
  176. def test_known_parameter_error_uses_business_label(self):
  177. result = self.presenter.present(
  178. 'query_track',
  179. {
  180. 'code': 'MCP_1401',
  181. 'msg': 'tracking_number is required',
  182. 'data': {'tracking_number': 'bad'},
  183. 'meta': {'request_id': 'rq_error'},
  184. },
  185. )
  186. self.assertTrue(result['is_error'])
  187. self.assertEqual('快递单号参数不正确', result['structured_content']['message'])
  188. self.assertNotIn('tracking_number', json.dumps(result, ensure_ascii=False))
  189. self.assertEqual({'request_id': 'rq_error'}, result['meta'])
  190. def test_system_error_hides_backend_message_and_data(self):
  191. result = self.presenter.present(
  192. 'query_order_exact',
  193. {
  194. 'code': 'MCP_9001',
  195. 'msg': 'SQLSTATE table st_order order_number failed',
  196. 'data': {'sql': 'select * from st_order'},
  197. },
  198. )
  199. serialized = json.dumps(result, ensure_ascii=False)
  200. self.assertIn('工具调用失败,请稍后重试', serialized)
  201. self.assertNotIn('SQLSTATE', serialized)
  202. self.assertNotIn('st_order', serialized)
  203. self.assertNotIn('order_number', serialized)
  204. def test_unknown_tool_and_malformed_success_fail_closed(self):
  205. unknown = self.presenter.present(
  206. 'future_tool',
  207. {'code': 'MCP_0000', 'data': {'secret_field': 'secret'}},
  208. )
  209. malformed = self.presenter.present(
  210. 'query_track',
  211. {'code': 'MCP_0000', 'data': {'records': []}},
  212. )
  213. for result in (unknown, malformed):
  214. self.assertTrue(result['is_error'])
  215. self.assertEqual('工具返回格式异常', result['structured_content']['message'])
  216. self.assertNotIn('secret', json.dumps(result, ensure_ascii=False))
  217. def test_present_exception_maps_value_error_and_hides_unknown_exception(self):
  218. parameter = self.presenter.present_exception(
  219. 'query_track',
  220. ValueError('order_number is required'),
  221. )
  222. system = self.presenter.present_exception(
  223. 'query_track',
  224. RuntimeError('SQL password leaked'),
  225. )
  226. self.assertEqual('订单号参数不正确', parameter['structured_content']['message'])
  227. self.assertEqual('工具调用失败,请稍后重试', system['structured_content']['message'])
  228. self.assertNotIn('password', json.dumps(system, ensure_ascii=False))
  229. def test_present_exception_handles_device_disabled_and_unknown_tool(self):
  230. device = self.presenter.present_exception(
  231. 'query_track',
  232. RuntimeError('这台设备的 Workbuddy 配置已失效,请重新生成配置。'),
  233. )
  234. disabled = self.presenter.present_exception(
  235. 'query_track',
  236. RuntimeError('tool disabled: query_track'),
  237. )
  238. unknown = self.presenter.present_exception(
  239. 'future_tool',
  240. RuntimeError('secret error'),
  241. )
  242. self.assertIn('Workbuddy', device['structured_content']['message'])
  243. self.assertEqual('MCP_1202', disabled['structured_content']['code'])
  244. self.assertEqual('工具返回格式异常', unknown['structured_content']['message'])
  245. def test_invalid_payload_types_and_generic_errors_fail_safely(self):
  246. cases = (
  247. self.presenter.present('query_track', 'invalid'),
  248. self.presenter.present('query_track', {'code': 'MCP_0000', 'data': []}),
  249. self.presenter.present('query_track', {'code': '', 'msg': 'secret'}),
  250. self.presenter.present(
  251. 'query_track',
  252. {'code': 'MCP_7777', 'msg': 'secret backend error'},
  253. ),
  254. self.presenter.present(
  255. 'query_track',
  256. {'code': 'MCP_1301', 'msg': 'secret permission detail'},
  257. ),
  258. )
  259. for result in cases:
  260. self.assertTrue(result['is_error'])
  261. self.assertNotIn('secret', json.dumps(result, ensure_ascii=False))
  262. def test_malformed_table_parts_fail_closed(self):
  263. base = {
  264. 'code': 'MCP_0000',
  265. 'data': {
  266. 'columns': [{'key': 'status', 'name': '状态'}],
  267. 'records': [],
  268. },
  269. }
  270. bad_data = (
  271. {'columns': [], 'records': []},
  272. {'columns': [{'key': 'status', 'name': '状态'}], 'records': None},
  273. {'columns': ['status'], 'records': []},
  274. {'columns': [{'key': None, 'name': '状态'}], 'records': []},
  275. {'columns': [{'key': ' ', 'name': '状态'}], 'records': []},
  276. {'columns': [{'key': 'status', 'name': None}], 'records': []},
  277. {'columns': [{'key': 'status', 'name': ' '}], 'records': []},
  278. {'columns': [{'key': 'status', 'name': '状态'}], 'records': ['bad']},
  279. )
  280. for data in bad_data:
  281. with self.subTest(data=data):
  282. payload = dict(base)
  283. payload['data'] = data
  284. self.assertTrue(self.presenter.present('query_track', payload)['is_error'])
  285. def test_malformed_options_and_exports_fail_closed(self):
  286. cases = (
  287. self.presenter.present(
  288. 'list_order_filter_options',
  289. {'code': 'MCP_0000', 'data': {'records': None}},
  290. ),
  291. self.presenter.present(
  292. 'list_order_filter_options',
  293. {'code': 'MCP_0000', 'data': {'records': ['bad']}},
  294. ),
  295. self.presenter.present(
  296. 'export_pending_outbound_orders',
  297. {'code': 'MCP_0000', 'data': {'file_url': None}},
  298. ),
  299. self.presenter.present(
  300. 'export_pending_outbound_orders',
  301. {'code': 'MCP_0000', 'data': {'file_url': ' '}},
  302. ),
  303. )
  304. self.assertTrue(all(result['is_error'] for result in cases))
  305. def test_optional_text_meta_and_nested_values_cover_safe_boundaries(self):
  306. result = self.presenter.present(
  307. 'query_track',
  308. {
  309. 'code': 'MCP_0000',
  310. 'data': {
  311. 'summary': None,
  312. 'columns': [
  313. {'key': 'status', 'name': '状态', 'description': ''},
  314. {'key': 'detail', 'name': '详情', 'description': 123},
  315. ],
  316. 'records': [{'status': {'name': '已发货'}, 'detail': ['A']}],
  317. 'tips': ['', None, '有效提示'],
  318. },
  319. 'meta': {'request_id': ' ', 'page': 1},
  320. },
  321. )
  322. self.assertFalse(result['is_error'])
  323. self.assertEqual({}, result['meta'])
  324. self.assertIn('{"name": "已发货"}', result['text'])
  325. self.assertIn('["A"]', result['text'])
  326. self.assertEqual(['有效提示'], result['structured_content']['tips'])
  327. no_meta = self.presenter.present(
  328. 'list_order_filter_options',
  329. {'code': 'MCP_0000', 'data': {'records': []}, 'meta': None},
  330. )
  331. self.assertNotIn('pagination', no_meta['structured_content'])
  332. def test_parameter_error_without_known_field_is_generic(self):
  333. result = self.presenter.present(
  334. 'query_track',
  335. {'code': 'MCP_1401', 'msg': 'invalid request'},
  336. )
  337. self.assertEqual(
  338. '工具参数不正确,请检查后重试',
  339. result['structured_content']['message'],
  340. )
  341. def test_query_order_is_explicitly_not_presented(self):
  342. self.assertFalse(self.presenter.handles('query_order'))
  343. self.assertTrue(self.presenter.handles('query_order_exact'))
  344. def test_unhashable_tool_names_fail_closed(self):
  345. for tool_name in ([], {}):
  346. with self.subTest(tool_name=tool_name):
  347. self.assertFalse(self.presenter.handles(tool_name))
  348. result = self.presenter.present_exception(
  349. tool_name,
  350. RuntimeError('secret backend error'),
  351. )
  352. self.assertTrue(result['is_error'])
  353. self.assertEqual(
  354. '工具返回格式异常',
  355. result['structured_content']['message'],
  356. )
  357. def test_safe_tool_descriptions_require_business_labels_only(self):
  358. safe_tools = (
  359. QueryOrderExactTool(),
  360. QueryTrackTool(),
  361. ListOrderFilterOptionsTool(),
  362. ListPendingOutboundExportFilterOptionsTool(),
  363. ExportPendingOutboundOrdersTool(),
  364. ExportOutOfProvincePortDataTool(),
  365. )
  366. for tool in safe_tools:
  367. with self.subTest(tool=tool.name):
  368. self.assertIn('不得展示内部参数名', tool.metadata()['description'])
  369. self.assertNotIn('不得展示内部参数名', QueryOrderTool().metadata()['description'])
  370. def test_presented_order_and_option_values_can_feed_follow_up_tools(self):
  371. class RecordingClient:
  372. def __init__(self):
  373. self.calls = []
  374. def call_tool(self, tool_code, route_path, payload, request_id):
  375. self.calls.append((tool_code, payload))
  376. return {'code': 'MCP_0000'}
  377. order_result = self.presenter.present(
  378. 'query_order_exact',
  379. {
  380. 'code': 'MCP_0000',
  381. 'data': {
  382. 'columns': [{'key': 'order_number', 'name': '订单号'}],
  383. 'records': [{'order_number': 'SO-FOLLOW-UP'}],
  384. },
  385. },
  386. )
  387. option_result = self.presenter.present(
  388. 'list_order_filter_options',
  389. {
  390. 'code': 'MCP_0000',
  391. 'data': {
  392. 'records': [{'value': 901, 'label': '客户甲', 'code': 'C901'}],
  393. },
  394. },
  395. )
  396. client = RecordingClient()
  397. QueryTrackTool(client).call(
  398. order_number=order_result['structured_content']['rows'][0][0],
  399. )
  400. QueryOrderExactTool(client).call(
  401. customer_ids=[option_result['structured_content']['rows'][0][0]],
  402. )
  403. self.assertEqual(
  404. ('query_track', {'page': 1, 'limit': 5, 'order_number': 'SO-FOLLOW-UP'}),
  405. client.calls[0],
  406. )
  407. self.assertEqual([901], client.calls[1][1]['customer_ids'])
  408. if __name__ == '__main__':
  409. unittest.main()