test_order_abnormal_tools.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. import importlib
  2. import io
  3. import unittest
  4. from app import GatewayApp
  5. from public_gateway import PublicGatewayApp
  6. from services.output_presenter import OutputPresenter
  7. COLUMNS = [
  8. ('order_number', '订单号'), ('customer_name', '客户'),
  9. ('product_name', '物流产品'), ('abnormal_status', '问题件状态'),
  10. ('abnormal_type', '问题件类型'), ('abnormal_desc', '问题件描述'),
  11. ('outbound_status', '排舱单状态'), ('inbound_date', '入库时间'),
  12. ('marker_name', '标记人'),
  13. ]
  14. STATUSES = [
  15. '待处理', '放行待审核', '包裹放行', '退件待审核', '退件驳回', '已退件',
  16. ]
  17. class RecordingApiClient:
  18. def __init__(self):
  19. self.calls = []
  20. def list_enabled_tools(self, request_id=''):
  21. return {
  22. 'code': 'MCP_0000',
  23. 'data': {
  24. 'tool_codes': [
  25. 'query_order_abnormal_list',
  26. 'list_order_abnormal_filter_options',
  27. ],
  28. },
  29. }
  30. def call_tool(self, tool_code, route_path, payload, request_id):
  31. self.calls.append((tool_code, route_path, payload, request_id))
  32. return {'code': 'MCP_0000', 'data': {}, 'meta': {}}
  33. class OrderAbnormalToolContractTest(unittest.TestCase):
  34. def tool_class(self, module_name, class_name):
  35. return getattr(importlib.import_module('tools.' + module_name), class_name)
  36. def test_query_schema_requires_status_and_forbids_identity(self):
  37. cls = self.tool_class(
  38. 'query_order_abnormal_list', 'QueryOrderAbnormalListTool'
  39. )
  40. metadata = cls().metadata()
  41. schema = metadata['input_schema']
  42. self.assertEqual('query_order_abnormal_list', metadata['name'])
  43. self.assertEqual('/mcp/tools/queryOrderAbnormalList', cls.route_path)
  44. self.assertFalse(schema['additionalProperties'])
  45. self.assertEqual(['abnormal_status'], schema['required'])
  46. self.assertEqual(STATUSES, schema['properties']['abnormal_status']['enum'])
  47. self.assertNotIn('default', schema['properties']['abnormal_status'])
  48. for forbidden in (
  49. 'company_id', 'admin_id', 'is_super', 'customer_id', 'product_id',
  50. ):
  51. self.assertNotIn(forbidden, schema['properties'])
  52. self.assertIn('使用场景:', metadata['description'])
  53. self.assertIn('禁止使用:', metadata['description'])
  54. self.assertIn('号码类型不明确时必须先询问用户', metadata['description'])
  55. self.assertIn('不得根据号码格式猜测', metadata['description'])
  56. self.assertIn('不得跨字段或跨工具试查', metadata['description'])
  57. self.assertIn('admin/Order/abnormal', metadata['description'])
  58. self.assertIn('订单号可选', metadata['description'])
  59. def test_query_forwards_optional_order_numbers(self):
  60. cls = self.tool_class(
  61. 'query_order_abnormal_list', 'QueryOrderAbnormalListTool'
  62. )
  63. client = RecordingApiClient()
  64. cls(client).call(
  65. abnormal_status='包裹放行',
  66. order_numbers=[' FMS-1 ', 'FMS-1'],
  67. page=2,
  68. limit=30,
  69. request_id='rq_abn',
  70. )
  71. self.assertEqual(
  72. (
  73. 'query_order_abnormal_list',
  74. '/mcp/tools/queryOrderAbnormalList',
  75. {
  76. 'abnormal_status': '包裹放行',
  77. 'order_numbers': ['FMS-1'],
  78. 'page': 2,
  79. 'limit': 30,
  80. },
  81. 'rq_abn',
  82. ),
  83. client.calls[-1],
  84. )
  85. cls(client).call(abnormal_status='待处理')
  86. self.assertEqual(
  87. {'abnormal_status': '待处理', 'page': 1, 'limit': 20},
  88. client.calls[-1][2],
  89. )
  90. with self.assertRaises(ValueError):
  91. cls(client).call(abnormal_status='结案')
  92. with self.assertRaises(RuntimeError):
  93. cls().call(abnormal_status='待处理')
  94. def test_filter_schema_and_call(self):
  95. cls = self.tool_class(
  96. 'list_order_abnormal_filter_options',
  97. 'ListOrderAbnormalFilterOptionsTool',
  98. )
  99. metadata = cls().metadata()
  100. schema = metadata['input_schema']
  101. self.assertEqual('list_order_abnormal_filter_options', metadata['name'])
  102. self.assertEqual(
  103. '/mcp/tools/listOrderAbnormalFilterOptions', cls.route_path
  104. )
  105. self.assertEqual(['filter_type'], schema['required'])
  106. self.assertEqual(['问题件状态'], schema['properties']['filter_type']['enum'])
  107. self.assertIn('query_order_abnormal_list', metadata['description'])
  108. self.assertIn('禁止猜测', metadata['description'])
  109. client = RecordingApiClient()
  110. cls(client).call('问题件状态', keyword='待', page=2, limit=10)
  111. self.assertEqual(
  112. {
  113. 'filter_type': '问题件状态',
  114. 'keyword': '待',
  115. 'page': 2,
  116. 'limit': 10,
  117. },
  118. client.calls[-1][2],
  119. )
  120. with self.assertRaises(ValueError):
  121. cls(client).call('问题件类型')
  122. with self.assertRaises(RuntimeError):
  123. cls().call('问题件状态')
  124. with self.assertRaises(ValueError):
  125. cls(client).call('问题件状态', keyword='x' * 101)
  126. with self.assertRaises(ValueError):
  127. cls(client).call('问题件状态', page=0)
  128. with self.assertRaises(ValueError):
  129. cls(client).call('问题件状态', limit=True)
  130. def test_query_covers_remaining_validation_branches(self):
  131. cls = self.tool_class(
  132. 'query_order_abnormal_list', 'QueryOrderAbnormalListTool'
  133. )
  134. client = RecordingApiClient()
  135. with self.assertRaises(ValueError):
  136. cls(client).call(
  137. abnormal_status='待处理',
  138. order_numbers=['X%s' % i for i in range(201)],
  139. )
  140. with self.assertRaises(ValueError):
  141. cls(client).call(abnormal_status='待处理', order_numbers='FMS-1')
  142. with self.assertRaises(ValueError):
  143. cls(client).call(abnormal_status='待处理', order_numbers=[1])
  144. with self.assertRaises(ValueError):
  145. cls(client).call(abnormal_status='待处理', order_numbers=[' '])
  146. with self.assertRaises(ValueError):
  147. cls(client).call(
  148. abnormal_status='待处理',
  149. order_numbers=['X' * 101],
  150. )
  151. with self.assertRaises(ValueError):
  152. cls(client).call(abnormal_status='待处理', page=0)
  153. with self.assertRaises(ValueError):
  154. cls(client).call(abnormal_status='待处理', limit=True)
  155. with self.assertRaises(ValueError):
  156. cls(client).call(abnormal_status='待处理', order_numbers=[])
  157. with self.assertRaises(ValueError):
  158. cls(client).call(abnormal_status='待处理', page=101)
  159. def test_cli_forwards_status_and_order_numbers(self):
  160. client = RecordingApiClient()
  161. app = GatewayApp(api_client=client)
  162. with self.assertRaises(ValueError):
  163. app.run_cli([
  164. 'call', '--tool', 'query_order_abnormal_list',
  165. ], stdout=io.StringIO())
  166. app.run_cli([
  167. 'call', '--tool', 'query_order_abnormal_list',
  168. '--abnormal-status', '退件待审核',
  169. '--order-numbers', ' FMS-1,FMS-2 ',
  170. ], stdout=io.StringIO())
  171. self.assertEqual(
  172. {
  173. 'abnormal_status': '退件待审核',
  174. 'order_numbers': ['FMS-1', 'FMS-2'],
  175. 'page': 1,
  176. 'limit': 20,
  177. },
  178. client.calls[-1][2],
  179. )
  180. app.run_cli([
  181. 'call', '--tool', 'query_order_abnormal_list',
  182. '--abnormal-status', '待处理',
  183. ], stdout=io.StringIO())
  184. self.assertEqual(
  185. {'abnormal_status': '待处理', 'page': 1, 'limit': 20},
  186. client.calls[-1][2],
  187. )
  188. app.run_cli([
  189. 'call', '--tool', 'list_order_abnormal_filter_options',
  190. '--filter-type', '问题件状态',
  191. ], stdout=io.StringIO())
  192. self.assertEqual('list_order_abnormal_filter_options', client.calls[-1][0])
  193. with self.assertRaises(ValueError):
  194. app.run_cli([
  195. 'call', '--tool', 'list_order_abnormal_filter_options',
  196. ], stdout=io.StringIO())
  197. def test_presenter_renders_nine_columns_and_fails_closed(self):
  198. presenter = OutputPresenter()
  199. meta = {
  200. 'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
  201. }
  202. columns = [{'key': key, 'name': name} for key, name in COLUMNS]
  203. record = {key: 'v' for key, _ in COLUMNS}
  204. record['abnormal_status'] = '待处理'
  205. ok = presenter.present('query_order_abnormal_list', {
  206. 'code': 'MCP_0000',
  207. 'data': {
  208. 'abnormal_status': '待处理',
  209. 'columns': columns,
  210. 'records': [record],
  211. },
  212. 'meta': meta,
  213. })
  214. self.assertFalse(ok['is_error'])
  215. self.assertEqual(
  216. ['订单号', '客户', '物流产品', '问题件状态', '问题件类型',
  217. '问题件描述', '排舱单状态', '入库时间', '标记人'],
  218. [item['label'] for item in ok['structured_content']['headers']],
  219. )
  220. self.assertTrue(presenter.present('query_order_abnormal_list', {
  221. 'code': 'MCP_0000',
  222. 'data': {
  223. 'abnormal_status': '待处理',
  224. 'columns': [],
  225. 'records': [],
  226. 'extra': 1,
  227. },
  228. 'meta': meta,
  229. })['is_error'])
  230. self.assertTrue(presenter.present('query_order_abnormal_list', {
  231. 'code': 'MCP_0000',
  232. 'data': {
  233. 'abnormal_status': '结案',
  234. 'columns': columns,
  235. 'records': [],
  236. },
  237. 'meta': meta,
  238. })['is_error'])
  239. bad_columns = list(columns)
  240. bad_columns[0] = {'key': 'order_number', 'name': '错'}
  241. self.assertTrue(presenter.present('query_order_abnormal_list', {
  242. 'code': 'MCP_0000',
  243. 'data': {
  244. 'abnormal_status': '待处理',
  245. 'columns': bad_columns,
  246. 'records': [],
  247. },
  248. 'meta': meta,
  249. })['is_error'])
  250. bad_record = dict(record)
  251. bad_record['order_number'] = 1
  252. self.assertTrue(presenter.present('query_order_abnormal_list', {
  253. 'code': 'MCP_0000',
  254. 'data': {
  255. 'abnormal_status': '待处理',
  256. 'columns': columns,
  257. 'records': [bad_record],
  258. },
  259. 'meta': meta,
  260. })['is_error'])
  261. self.assertTrue(presenter.present('query_order_abnormal_list', {
  262. 'code': 'MCP_0000',
  263. 'data': {
  264. 'abnormal_status': '待处理',
  265. 'columns': columns,
  266. 'records': 'bad',
  267. },
  268. 'meta': meta,
  269. })['is_error'])
  270. extra_record = dict(record)
  271. extra_record['deal_remark'] = 'x'
  272. self.assertTrue(presenter.present('query_order_abnormal_list', {
  273. 'code': 'MCP_0000',
  274. 'data': {
  275. 'abnormal_status': '待处理',
  276. 'columns': columns,
  277. 'records': [extra_record],
  278. },
  279. 'meta': meta,
  280. })['is_error'])
  281. self.assertTrue(presenter.present('query_order_abnormal_list', {
  282. 'code': 'MCP_0000',
  283. 'data': {
  284. 'abnormal_status': '待处理',
  285. 'columns': None,
  286. 'records': [],
  287. },
  288. 'meta': meta,
  289. })['is_error'])
  290. self.assertTrue(presenter.present('query_order_abnormal_list', {
  291. 'code': 'MCP_0000',
  292. 'data': {
  293. 'abnormal_status': '待处理',
  294. 'columns': columns,
  295. 'records': [None],
  296. },
  297. 'meta': meta,
  298. })['is_error'])
  299. self.assertTrue(presenter.present('query_order_abnormal_list', {
  300. 'code': 'MCP_0000',
  301. 'data': {
  302. 'abnormal_status': '待处理',
  303. 'columns': columns,
  304. 'records': [],
  305. },
  306. 'meta': {
  307. 'page': 1, 'limit': 20, 'has_more': False,
  308. 'request_id': 'rq_x', 'total': 1,
  309. },
  310. })['is_error'])
  311. options = presenter.present('list_order_abnormal_filter_options', {
  312. 'code': 'MCP_0000',
  313. 'data': {
  314. 'records': [
  315. {'value': '待处理', 'label': '待处理', 'code': ''},
  316. ],
  317. },
  318. 'meta': meta,
  319. })
  320. self.assertFalse(options['is_error'])
  321. def test_local_public_registry_includes_both_tools(self):
  322. local = GatewayApp().registered_tool_names()
  323. public = PublicGatewayApp(None, None).registered_tool_names()
  324. self.assertEqual(local, public)
  325. self.assertEqual(30, len(local))
  326. self.assertEqual(29, len(OutputPresenter.SAFE_TOOLS))
  327. self.assertIn('query_order_abnormal_list', local)
  328. self.assertIn('list_order_abnormal_filter_options', local)