test_export_pallet_data_tool.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. import io
  2. import unittest
  3. from app import GatewayApp
  4. from public_gateway import PublicGatewayApp
  5. from services.output_presenter import OutputPresenter
  6. from tools.export_pallet_data import ExportPalletDataTool
  7. class RecordingApiClient:
  8. def __init__(self):
  9. self.calls = []
  10. def list_enabled_tools(self, request_id=''):
  11. return {
  12. 'code': 'MCP_0000',
  13. 'data': {'tool_codes': ['export_pallet_data']},
  14. }
  15. def call_tool(self, tool_code, route_path, payload, request_id):
  16. self.calls.append((tool_code, route_path, payload, request_id))
  17. return {
  18. 'code': 'MCP_0000',
  19. 'data': {
  20. 'task_ref': 'mexp_pallet',
  21. 'status': 'queued',
  22. 'retry_after_seconds': 10,
  23. },
  24. }
  25. class ExportPalletDataToolTest(unittest.TestCase):
  26. def _assert_number_array_schema(self, field_schema, business_label):
  27. self.assertEqual('array', field_schema['type'])
  28. self.assertEqual(1, field_schema['minItems'])
  29. self.assertEqual(200, field_schema['maxItems'])
  30. items = field_schema['items']
  31. self.assertEqual('string', items['type'])
  32. self.assertEqual(1, items['minLength'])
  33. self.assertEqual(100, items['maxLength'])
  34. self.assertIn('pattern', items)
  35. description = field_schema['description']
  36. self.assertIn(business_label, description)
  37. self.assertIn('仅当用户明确', description)
  38. self.assertIn('不得放入', description)
  39. def test_schema_is_closed_without_ids_or_pagination(self):
  40. metadata = ExportPalletDataTool().metadata()
  41. schema = metadata['input_schema']
  42. properties = schema['properties']
  43. self.assertEqual('export_pallet_data', metadata['name'])
  44. self.assertFalse(schema['additionalProperties'])
  45. self.assertEqual(
  46. {
  47. 'container_codes',
  48. 'bl_numbers',
  49. 'inbound_time_start',
  50. 'inbound_time_end',
  51. },
  52. set(properties),
  53. )
  54. self.assertNotIn('page', properties)
  55. self.assertNotIn('limit', properties)
  56. self.assertNotIn('ids', properties)
  57. self.assertNotIn('inbound_id', properties)
  58. self.assertNotIn('inbound_ids', properties)
  59. self.assertNotIn('inbound_numbers', properties)
  60. self._assert_number_array_schema(properties['container_codes'], '柜号')
  61. self._assert_number_array_schema(properties['bl_numbers'], '提单号')
  62. description = metadata['description']
  63. self.assertIn('明确要求导出打托数据', description)
  64. self.assertIn('三选一', description)
  65. self.assertIn('异步导出', description)
  66. self.assertIn('query_export_task', description)
  67. self.assertIn('不会在本次调用中等待', description)
  68. self.assertIn('禁止在一次调用内轮询', description)
  69. self.assertIn('柜号', description)
  70. self.assertIn('提单号', description)
  71. self.assertIn('海外仓入库时间', description)
  72. def test_call_rejects_legacy_inbound_numbers_argument(self):
  73. client = RecordingApiClient()
  74. tool = ExportPalletDataTool(client)
  75. with self.assertRaises(TypeError):
  76. tool.call(inbound_numbers=['OLD'])
  77. self.assertEqual([], client.calls)
  78. def test_call_forwards_container_codes(self):
  79. client = RecordingApiClient()
  80. result = ExportPalletDataTool(client).call(
  81. container_codes=[' CONT-1 ', 'CONT-1', 'CONT-2'],
  82. )
  83. self.assertEqual('MCP_0000', result['code'])
  84. self.assertEqual(
  85. (
  86. 'export_pallet_data',
  87. '/mcp/tools/exportPalletData',
  88. {'container_codes': ['CONT-1', 'CONT-2']},
  89. 'rq_export_pallet_data',
  90. ),
  91. client.calls[0],
  92. )
  93. def test_call_forwards_bl_numbers(self):
  94. client = RecordingApiClient()
  95. result = ExportPalletDataTool(client).call(
  96. bl_numbers=[' BL-1 ', 'BL-1'],
  97. )
  98. self.assertEqual('MCP_0000', result['code'])
  99. self.assertEqual(
  100. (
  101. 'export_pallet_data',
  102. '/mcp/tools/exportPalletData',
  103. {'bl_numbers': ['BL-1']},
  104. 'rq_export_pallet_data',
  105. ),
  106. client.calls[0],
  107. )
  108. def test_call_ignores_empty_container_codes_when_bl_numbers_provided(self):
  109. client = RecordingApiClient()
  110. ExportPalletDataTool(client).call(
  111. container_codes=[],
  112. bl_numbers=['BL-1'],
  113. )
  114. self.assertEqual(
  115. (
  116. 'export_pallet_data',
  117. '/mcp/tools/exportPalletData',
  118. {'bl_numbers': ['BL-1']},
  119. 'rq_export_pallet_data',
  120. ),
  121. client.calls[0],
  122. )
  123. def test_call_ignores_empty_container_codes_when_time_range_provided(self):
  124. client = RecordingApiClient()
  125. ExportPalletDataTool(client).call(
  126. container_codes=[],
  127. inbound_time_start='2026-09-01',
  128. inbound_time_end='2026-09-07',
  129. )
  130. self.assertEqual(
  131. {
  132. 'inbound_time_start': '2026-09-01',
  133. 'inbound_time_end': '2026-09-07',
  134. },
  135. client.calls[-1][2],
  136. )
  137. def test_call_ignores_empty_bl_numbers_when_container_codes_provided(self):
  138. client = RecordingApiClient()
  139. ExportPalletDataTool(client).call(
  140. container_codes=['CONT-1'],
  141. bl_numbers=[],
  142. )
  143. self.assertEqual(
  144. (
  145. 'export_pallet_data',
  146. '/mcp/tools/exportPalletData',
  147. {'container_codes': ['CONT-1']},
  148. 'rq_export_pallet_data',
  149. ),
  150. client.calls[0],
  151. )
  152. def test_call_forwards_inbound_time_range(self):
  153. client = RecordingApiClient()
  154. ExportPalletDataTool(client).call(
  155. inbound_time_start='2026-09-01',
  156. inbound_time_end='2026-09-07 18:30:00',
  157. )
  158. self.assertEqual(
  159. {
  160. 'inbound_time_start': '2026-09-01',
  161. 'inbound_time_end': '2026-09-07 18:30:00',
  162. },
  163. client.calls[-1][2],
  164. )
  165. def test_call_validation_boundaries(self):
  166. tool = ExportPalletDataTool()
  167. with self.assertRaisesRegex(RuntimeError, 'api client is required'):
  168. tool.call(container_codes=['CONT-1'])
  169. client = RecordingApiClient()
  170. tool = ExportPalletDataTool(client)
  171. with self.assertRaisesRegex(ValueError, 'non-empty list'):
  172. tool.call(container_codes='CONT-1')
  173. with self.assertRaisesRegex(ValueError, 'must be strings'):
  174. tool.call(container_codes=[1])
  175. with self.assertRaisesRegex(ValueError, '1 to 100 chars'):
  176. tool.call(container_codes=[''])
  177. with self.assertRaisesRegex(ValueError, '1 to 100 chars'):
  178. tool.call(container_codes=['x' * 101])
  179. with self.assertRaisesRegex(ValueError, 'at most 200'):
  180. tool.call(container_codes=['n{0}'.format(i) for i in range(201)])
  181. with self.assertRaisesRegex(ValueError, 'non-empty list'):
  182. tool.call(container_codes=[])
  183. with self.assertRaisesRegex(ValueError, 'non-empty list'):
  184. tool.call(bl_numbers=[])
  185. with self.assertRaisesRegex(ValueError, 'non-empty list'):
  186. tool.call(bl_numbers='BL-1')
  187. with self.assertRaisesRegex(ValueError, 'must be strings'):
  188. tool.call(bl_numbers=[1])
  189. with self.assertRaisesRegex(ValueError, '1 to 100 chars'):
  190. tool.call(bl_numbers=['x' * 101])
  191. with self.assertRaisesRegex(ValueError, 'at most 200'):
  192. tool.call(bl_numbers=['BL-{0}'.format(i) for i in range(201)])
  193. with self.assertRaisesRegex(ValueError, 'cannot mix'):
  194. tool.call(container_codes=['CONT-1'], bl_numbers=['BL-1'])
  195. with self.assertRaisesRegex(ValueError, 'cannot mix'):
  196. tool.call(
  197. container_codes=['CONT-1'],
  198. inbound_time_start='2026-09-01',
  199. inbound_time_end='2026-09-02',
  200. )
  201. with self.assertRaisesRegex(ValueError, 'cannot mix'):
  202. tool.call(
  203. bl_numbers=['BL-1'],
  204. inbound_time_start='2026-09-01',
  205. inbound_time_end='2026-09-02',
  206. )
  207. with self.assertRaisesRegex(ValueError, 'both start and end'):
  208. tool.call(inbound_time_start='2026-09-01')
  209. with self.assertRaisesRegex(ValueError, 'both start and end'):
  210. tool.call(inbound_time_end='2026-09-07')
  211. with self.assertRaisesRegex(ValueError, 'within 31 days'):
  212. tool.call(
  213. inbound_time_start='2026-09-10',
  214. inbound_time_end='2026-09-01',
  215. )
  216. with self.assertRaisesRegex(ValueError, 'within 31 days'):
  217. tool.call(
  218. inbound_time_start='2026-09-01',
  219. inbound_time_end='2026-10-02',
  220. )
  221. with self.assertRaisesRegex(ValueError, 'date or datetime'):
  222. tool.call(
  223. inbound_time_start='09/01/2026',
  224. inbound_time_end='2026-09-02',
  225. )
  226. with self.assertRaisesRegex(ValueError, 'provide'):
  227. tool.call()
  228. ExportPalletDataTool(client).call(
  229. inbound_time_start='2026-09-01 00:00:00',
  230. inbound_time_end='2026-10-01 23:59:59',
  231. )
  232. self.assertEqual(
  233. {
  234. 'inbound_time_start': '2026-09-01 00:00:00',
  235. 'inbound_time_end': '2026-10-01 23:59:59',
  236. },
  237. client.calls[-1][2],
  238. )
  239. def test_cli_forwards_export_filters(self):
  240. client = RecordingApiClient()
  241. app = GatewayApp(api_client=client)
  242. code = app.run_cli([
  243. 'call', '--tool', 'export_pallet_data',
  244. '--container-codes', 'CONT-1, CONT-2',
  245. ], stdout=io.StringIO())
  246. self.assertEqual(0, code)
  247. self.assertEqual(
  248. {'container_codes': ['CONT-1', 'CONT-2']},
  249. client.calls[-1][2],
  250. )
  251. code = app.run_cli([
  252. 'call', '--tool', 'export_pallet_data',
  253. '--bl-numbers', 'BL-1, BL-2',
  254. ], stdout=io.StringIO())
  255. self.assertEqual(0, code)
  256. self.assertEqual(
  257. {'bl_numbers': ['BL-1', 'BL-2']},
  258. client.calls[-1][2],
  259. )
  260. code = app.run_cli([
  261. 'call', '--tool', 'export_pallet_data',
  262. '--inbound-time-start', '2026-09-01',
  263. '--inbound-time-end', '2026-09-07 18:30:00',
  264. ], stdout=io.StringIO())
  265. self.assertEqual(0, code)
  266. self.assertEqual(
  267. {
  268. 'inbound_time_start': '2026-09-01',
  269. 'inbound_time_end': '2026-09-07 18:30:00',
  270. },
  271. client.calls[-1][2],
  272. )
  273. def test_local_and_public_registries_include_export_tool(self):
  274. local = GatewayApp().registered_tool_names()
  275. public = PublicGatewayApp(None, None).registered_tool_names()
  276. self.assertEqual(local, public)
  277. self.assertEqual(32, len(local))
  278. self.assertIn('export_pallet_data', local)
  279. self.assertEqual(31, len(OutputPresenter.SAFE_TOOLS))
  280. def test_presenter_reuses_queued_export_contract(self):
  281. presented = OutputPresenter().present(
  282. 'export_pallet_data',
  283. {
  284. 'code': 'MCP_0000',
  285. 'data': {
  286. 'task_ref': 'mexp_pallet',
  287. 'status': 'queued',
  288. 'retry_after_seconds': 10,
  289. },
  290. },
  291. )
  292. self.assertFalse(presented['is_error'])
  293. task = presented['structured_content']['task']
  294. self.assertEqual('queued', task['status'])
  295. self.assertEqual('mexp_pallet', task['task_ref'])
  296. self.assertEqual(10, task['retry_after_seconds'])
  297. def test_wrong_route_path_fails_and_restore_passes(self):
  298. tool = ExportPalletDataTool()
  299. original = tool.route_path
  300. tool.route_path = '/mcp/tools/exportPalletDataWrong'
  301. self.assertNotEqual('/mcp/tools/exportPalletData', tool.route_path)
  302. tool.route_path = original
  303. self.assertEqual('/mcp/tools/exportPalletData', tool.route_path)
  304. if __name__ == '__main__':
  305. unittest.main()