test_container_timeliness_tools.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  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.query_container_timeliness_list import (
  7. COLUMNS,
  8. QueryContainerTimelinessListTool,
  9. )
  10. from tools.export_container_timeliness_report import (
  11. ExportContainerTimelinessReportTool,
  12. )
  13. class RecordingApiClient:
  14. def __init__(self):
  15. self.calls = []
  16. def list_enabled_tools(self, request_id=''):
  17. return {
  18. 'code': 'MCP_0000',
  19. 'data': {
  20. 'tool_codes': [
  21. 'query_container_timeliness_list',
  22. 'export_container_timeliness_report',
  23. ],
  24. },
  25. }
  26. def call_tool(self, tool_code, route_path, payload, request_id):
  27. self.calls.append((tool_code, route_path, payload, request_id))
  28. return {'code': 'MCP_0000', 'data': {}, 'meta': {}}
  29. class ContainerTimelinessToolContractTest(unittest.TestCase):
  30. def test_query_schema_is_closed_without_identity_or_page_filters(self):
  31. metadata = QueryContainerTimelinessListTool().metadata()
  32. schema = metadata['input_schema']
  33. self.assertEqual('query_container_timeliness_list', metadata['name'])
  34. self.assertEqual(
  35. '/mcp/tools/queryContainerTimelinessList',
  36. QueryContainerTimelinessListTool.route_path,
  37. )
  38. self.assertFalse(schema['additionalProperties'])
  39. self.assertEqual([], schema['required'])
  40. self.assertEqual(
  41. {
  42. 'container_codes',
  43. 'bl_numbers',
  44. 'departure_time_start',
  45. 'departure_time_end',
  46. 'page',
  47. 'limit',
  48. },
  49. set(schema['properties']),
  50. )
  51. for forbidden in (
  52. 'company_id', 'admin_id', 'is_super', 'outbound_numbers',
  53. 'ship_company', 'loading_id',
  54. ):
  55. self.assertNotIn(forbidden, schema['properties'])
  56. description = metadata['description']
  57. self.assertIn('使用场景:', description)
  58. self.assertIn('禁止使用:', description)
  59. self.assertIn('admin/Report/outboundReport', description)
  60. self.assertIn('query_outbound_list', description)
  61. self.assertIn('干线实际出发时间', description)
  62. self.assertIn('54列', description)
  63. self.assertIn('号码类型不明确时必须先询问用户', description)
  64. self.assertIn('不得根据号码格式猜测', description)
  65. self.assertIn('不得跨字段或跨工具试查', description)
  66. def test_query_forwards_numbers_and_optional_departure_window(self):
  67. client = RecordingApiClient()
  68. QueryContainerTimelinessListTool(client).call(
  69. container_codes=['CONT-1', ' CONT-1 '],
  70. bl_numbers=['BL-1'],
  71. departure_time_start='2026-09-01',
  72. departure_time_end='2026-09-30',
  73. page=2,
  74. limit=10,
  75. )
  76. self.assertEqual(
  77. (
  78. 'query_container_timeliness_list',
  79. '/mcp/tools/queryContainerTimelinessList',
  80. {
  81. 'container_codes': ['CONT-1'],
  82. 'bl_numbers': ['BL-1'],
  83. 'departure_time_start': '2026-09-01',
  84. 'departure_time_end': '2026-09-30',
  85. 'page': 2,
  86. 'limit': 10,
  87. },
  88. 'rq_query_container_timeliness_list',
  89. ),
  90. client.calls[-1],
  91. )
  92. def test_query_rejects_empty_and_oversize_window(self):
  93. tool = QueryContainerTimelinessListTool(RecordingApiClient())
  94. with self.assertRaisesRegex(ValueError, 'provide'):
  95. tool.call()
  96. with self.assertRaisesRegex(ValueError, 'departure time range requires both'):
  97. tool.call(departure_time_start='2026-09-01')
  98. with self.assertRaisesRegex(ValueError, 'departure time window'):
  99. tool.call(
  100. departure_time_start='2026-08-01',
  101. departure_time_end='2026-09-01',
  102. )
  103. with self.assertRaisesRegex(ValueError, 'is invalid'):
  104. tool.call(departure_time_start='09/01/2026', departure_time_end='2026-09-02')
  105. with self.assertRaisesRegex(ValueError, 'at most 200'):
  106. tool.call(
  107. container_codes=['C1'],
  108. bl_numbers=['B{0}'.format(i) for i in range(200)],
  109. )
  110. with self.assertRaisesRegex(ValueError, 'non-empty'):
  111. tool.call(container_codes=[])
  112. with self.assertRaisesRegex(ValueError, 'strings'):
  113. tool.call(container_codes=[1])
  114. with self.assertRaisesRegex(ValueError, '1 to 100 chars'):
  115. tool.call(container_codes=[''])
  116. with self.assertRaisesRegex(ValueError, 'at most 200 container_codes'):
  117. tool.call(container_codes=['C{0}'.format(i) for i in range(201)])
  118. with self.assertRaisesRegex(ValueError, 'page is invalid'):
  119. tool.call(container_codes=['CONT-1'], page=True)
  120. with self.assertRaisesRegex(ValueError, 'page is invalid'):
  121. tool.call(container_codes=['CONT-1'], page=0)
  122. with self.assertRaisesRegex(ValueError, 'limit is invalid'):
  123. tool.call(container_codes=['CONT-1'], limit='20')
  124. with self.assertRaisesRegex(ValueError, '1 to 100 chars'):
  125. tool.call(container_codes=['x' * 101])
  126. with self.assertRaisesRegex(ValueError, 'departure time window'):
  127. tool.call(
  128. departure_time_start='2026-09-30',
  129. departure_time_end='2026-09-01',
  130. )
  131. with self.assertRaisesRegex(RuntimeError, 'api client is required'):
  132. QueryContainerTimelinessListTool().call(container_codes=['CONT-1'])
  133. with self.assertRaisesRegex(RuntimeError, 'api client is required'):
  134. ExportContainerTimelinessReportTool().call(container_codes=['CONT-1'])
  135. def test_export_schema_forbids_pagination_and_requires_async_follow_up(self):
  136. metadata = ExportContainerTimelinessReportTool().metadata()
  137. schema = metadata['input_schema']
  138. self.assertEqual('export_container_timeliness_report', metadata['name'])
  139. self.assertFalse(schema['additionalProperties'])
  140. self.assertEqual(
  141. {
  142. 'container_codes',
  143. 'bl_numbers',
  144. 'departure_time_start',
  145. 'departure_time_end',
  146. },
  147. set(schema['properties']),
  148. )
  149. self.assertNotIn('page', schema['properties'])
  150. description = metadata['description']
  151. self.assertIn('明确要求导出', description)
  152. self.assertIn('柜子时效', description)
  153. self.assertIn('异步导出任务', description)
  154. self.assertIn('query_export_task', description)
  155. self.assertIn('不会在本次调用中等待文件生成', description)
  156. self.assertIn('query_container_timeliness_list', description)
  157. def test_export_forwards_locator_without_page(self):
  158. client = RecordingApiClient()
  159. ExportContainerTimelinessReportTool(client).call(
  160. bl_numbers=['BL-1'],
  161. )
  162. self.assertEqual(
  163. {
  164. 'bl_numbers': ['BL-1'],
  165. },
  166. client.calls[-1][2],
  167. )
  168. self.assertNotIn('page', client.calls[-1][2])
  169. def test_cli_forwards_query_and_export_filters(self):
  170. client = RecordingApiClient()
  171. app = GatewayApp(api_client=client)
  172. code = app.run_cli([
  173. 'call', '--tool', 'query_container_timeliness_list',
  174. '--container-codes', 'CONT-1',
  175. '--departure-time-start', '2026-09-01',
  176. '--departure-time-end', '2026-09-14',
  177. '--page', '2',
  178. '--limit', '10',
  179. ], stdout=io.StringIO())
  180. self.assertEqual(0, code)
  181. self.assertEqual(
  182. {
  183. 'container_codes': ['CONT-1'],
  184. 'departure_time_start': '2026-09-01',
  185. 'departure_time_end': '2026-09-14',
  186. 'page': 2,
  187. 'limit': 10,
  188. },
  189. client.calls[-1][2],
  190. )
  191. code = app.run_cli([
  192. 'call', '--tool', 'export_container_timeliness_report',
  193. '--bl-numbers', 'BL-1, BL-2',
  194. ], stdout=io.StringIO())
  195. self.assertEqual(0, code)
  196. self.assertEqual(
  197. {'bl_numbers': ['BL-1', 'BL-2']},
  198. client.calls[-1][2],
  199. )
  200. self.assertNotIn('page', client.calls[-1][2])
  201. def test_presenter_accepts_fifty_four_string_columns(self):
  202. presenter = OutputPresenter()
  203. columns = [{'key': key, 'name': name} for key, name in COLUMNS]
  204. record = {key: 'v_{0}'.format(key) for key, _ in COLUMNS}
  205. ok = presenter.present('query_container_timeliness_list', {
  206. 'code': 'MCP_0000',
  207. 'data': {'columns': columns, 'records': [record]},
  208. 'meta': {
  209. 'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
  210. },
  211. })
  212. self.assertFalse(ok['is_error'])
  213. self.assertEqual(54, len(ok['structured_content']['headers']))
  214. self.assertEqual('v_container_code', ok['structured_content']['rows'][0][2])
  215. numeric = dict(record)
  216. numeric['total_volume'] = 1.2
  217. self.assertTrue(presenter.present('query_container_timeliness_list', {
  218. 'code': 'MCP_0000',
  219. 'data': {'columns': columns, 'records': [numeric]},
  220. 'meta': {
  221. 'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
  222. },
  223. })['is_error'])
  224. self.assertTrue(presenter.present('query_container_timeliness_list', {
  225. 'code': 'MCP_0000',
  226. 'data': {'columns': columns, 'records': [], 'extra': 1},
  227. 'meta': {
  228. 'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
  229. },
  230. })['is_error'])
  231. self.assertTrue(presenter.present('query_container_timeliness_list', {
  232. 'code': 'MCP_0000',
  233. 'data': {'columns': columns, 'records': []},
  234. 'meta': {
  235. 'page': 1, 'limit': 20, 'has_more': False,
  236. 'request_id': 'rq_x', 'total': 1,
  237. },
  238. })['is_error'])
  239. bad_columns = list(columns)
  240. bad_columns[0] = {'key': 'ship_company', 'name': '错'}
  241. self.assertTrue(presenter.present('query_container_timeliness_list', {
  242. 'code': 'MCP_0000',
  243. 'data': {'columns': bad_columns, 'records': []},
  244. 'meta': {
  245. 'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
  246. },
  247. })['is_error'])
  248. extra = dict(record)
  249. extra['seq'] = '1'
  250. self.assertTrue(presenter.present('query_container_timeliness_list', {
  251. 'code': 'MCP_0000',
  252. 'data': {'columns': columns, 'records': [extra]},
  253. 'meta': {
  254. 'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
  255. },
  256. })['is_error'])
  257. self.assertTrue(presenter.present('query_container_timeliness_list', {
  258. 'code': 'MCP_0000',
  259. 'data': {'columns': columns, 'records': 'bad'},
  260. 'meta': {
  261. 'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
  262. },
  263. })['is_error'])
  264. short_columns = columns[:-1]
  265. self.assertTrue(presenter.present('query_container_timeliness_list', {
  266. 'code': 'MCP_0000',
  267. 'data': {'columns': short_columns, 'records': []},
  268. 'meta': {
  269. 'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
  270. },
  271. })['is_error'])
  272. self.assertTrue(presenter.present('query_container_timeliness_list', {
  273. 'code': 'MCP_0000',
  274. 'data': {'columns': columns, 'records': ['bad']},
  275. 'meta': {
  276. 'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
  277. },
  278. })['is_error'])
  279. def test_local_and_public_registries_include_both_tools(self):
  280. local = GatewayApp().registered_tool_names()
  281. public = PublicGatewayApp(None, None).registered_tool_names()
  282. self.assertEqual(local, public)
  283. self.assertEqual(34, len(local))
  284. self.assertEqual(33, len(OutputPresenter.SAFE_TOOLS))
  285. self.assertIn('query_container_timeliness_list', local)
  286. self.assertIn('export_container_timeliness_report', local)
  287. if __name__ == '__main__':
  288. unittest.main()