|
|
@@ -0,0 +1,347 @@
|
|
|
+import importlib
|
|
|
+import io
|
|
|
+import unittest
|
|
|
+
|
|
|
+from app import GatewayApp
|
|
|
+from public_gateway import PublicGatewayApp
|
|
|
+from services.output_presenter import OutputPresenter
|
|
|
+
|
|
|
+
|
|
|
+COLUMNS = [
|
|
|
+ ('order_number', '订单号'), ('customer_name', '客户'),
|
|
|
+ ('product_name', '物流产品'), ('abnormal_status', '问题件状态'),
|
|
|
+ ('abnormal_type', '问题件类型'), ('abnormal_desc', '问题件描述'),
|
|
|
+ ('outbound_status', '排舱单状态'), ('inbound_date', '入库时间'),
|
|
|
+ ('marker_name', '标记人'),
|
|
|
+]
|
|
|
+STATUSES = [
|
|
|
+ '待处理', '放行待审核', '包裹放行', '退件待审核', '退件驳回', '已退件',
|
|
|
+]
|
|
|
+
|
|
|
+
|
|
|
+class RecordingApiClient:
|
|
|
+ def __init__(self):
|
|
|
+ self.calls = []
|
|
|
+
|
|
|
+ def list_enabled_tools(self, request_id=''):
|
|
|
+ return {
|
|
|
+ 'code': 'MCP_0000',
|
|
|
+ 'data': {
|
|
|
+ 'tool_codes': [
|
|
|
+ 'query_order_abnormal_list',
|
|
|
+ 'list_order_abnormal_filter_options',
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ def call_tool(self, tool_code, route_path, payload, request_id):
|
|
|
+ self.calls.append((tool_code, route_path, payload, request_id))
|
|
|
+ return {'code': 'MCP_0000', 'data': {}, 'meta': {}}
|
|
|
+
|
|
|
+
|
|
|
+class OrderAbnormalToolContractTest(unittest.TestCase):
|
|
|
+ def tool_class(self, module_name, class_name):
|
|
|
+ return getattr(importlib.import_module('tools.' + module_name), class_name)
|
|
|
+
|
|
|
+ def test_query_schema_requires_status_and_forbids_identity(self):
|
|
|
+ cls = self.tool_class(
|
|
|
+ 'query_order_abnormal_list', 'QueryOrderAbnormalListTool'
|
|
|
+ )
|
|
|
+ metadata = cls().metadata()
|
|
|
+ schema = metadata['input_schema']
|
|
|
+ self.assertEqual('query_order_abnormal_list', metadata['name'])
|
|
|
+ self.assertEqual('/mcp/tools/queryOrderAbnormalList', cls.route_path)
|
|
|
+ self.assertFalse(schema['additionalProperties'])
|
|
|
+ self.assertEqual(['abnormal_status'], schema['required'])
|
|
|
+ self.assertEqual(STATUSES, schema['properties']['abnormal_status']['enum'])
|
|
|
+ self.assertNotIn('default', schema['properties']['abnormal_status'])
|
|
|
+ for forbidden in (
|
|
|
+ 'company_id', 'admin_id', 'is_super', 'customer_id', 'product_id',
|
|
|
+ ):
|
|
|
+ self.assertNotIn(forbidden, schema['properties'])
|
|
|
+ self.assertIn('使用场景:', metadata['description'])
|
|
|
+ self.assertIn('禁止使用:', metadata['description'])
|
|
|
+ self.assertIn('号码类型不明确时必须先询问用户', metadata['description'])
|
|
|
+ self.assertIn('不得根据号码格式猜测', metadata['description'])
|
|
|
+ self.assertIn('不得跨字段或跨工具试查', metadata['description'])
|
|
|
+ self.assertIn('admin/Order/abnormal', metadata['description'])
|
|
|
+ self.assertIn('订单号可选', metadata['description'])
|
|
|
+
|
|
|
+ def test_query_forwards_optional_order_numbers(self):
|
|
|
+ cls = self.tool_class(
|
|
|
+ 'query_order_abnormal_list', 'QueryOrderAbnormalListTool'
|
|
|
+ )
|
|
|
+ client = RecordingApiClient()
|
|
|
+ cls(client).call(
|
|
|
+ abnormal_status='包裹放行',
|
|
|
+ order_numbers=[' FMS-1 ', 'FMS-1'],
|
|
|
+ page=2,
|
|
|
+ limit=30,
|
|
|
+ request_id='rq_abn',
|
|
|
+ )
|
|
|
+ self.assertEqual(
|
|
|
+ (
|
|
|
+ 'query_order_abnormal_list',
|
|
|
+ '/mcp/tools/queryOrderAbnormalList',
|
|
|
+ {
|
|
|
+ 'abnormal_status': '包裹放行',
|
|
|
+ 'order_numbers': ['FMS-1'],
|
|
|
+ 'page': 2,
|
|
|
+ 'limit': 30,
|
|
|
+ },
|
|
|
+ 'rq_abn',
|
|
|
+ ),
|
|
|
+ client.calls[-1],
|
|
|
+ )
|
|
|
+ cls(client).call(abnormal_status='待处理')
|
|
|
+ self.assertEqual(
|
|
|
+ {'abnormal_status': '待处理', 'page': 1, 'limit': 20},
|
|
|
+ client.calls[-1][2],
|
|
|
+ )
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
+ cls(client).call(abnormal_status='结案')
|
|
|
+ with self.assertRaises(RuntimeError):
|
|
|
+ cls().call(abnormal_status='待处理')
|
|
|
+
|
|
|
+ def test_filter_schema_and_call(self):
|
|
|
+ cls = self.tool_class(
|
|
|
+ 'list_order_abnormal_filter_options',
|
|
|
+ 'ListOrderAbnormalFilterOptionsTool',
|
|
|
+ )
|
|
|
+ metadata = cls().metadata()
|
|
|
+ schema = metadata['input_schema']
|
|
|
+ self.assertEqual('list_order_abnormal_filter_options', metadata['name'])
|
|
|
+ self.assertEqual(
|
|
|
+ '/mcp/tools/listOrderAbnormalFilterOptions', cls.route_path
|
|
|
+ )
|
|
|
+ self.assertEqual(['filter_type'], schema['required'])
|
|
|
+ self.assertEqual(['问题件状态'], schema['properties']['filter_type']['enum'])
|
|
|
+ self.assertIn('query_order_abnormal_list', metadata['description'])
|
|
|
+ self.assertIn('禁止猜测', metadata['description'])
|
|
|
+
|
|
|
+ client = RecordingApiClient()
|
|
|
+ cls(client).call('问题件状态', keyword='待', page=2, limit=10)
|
|
|
+ self.assertEqual(
|
|
|
+ {
|
|
|
+ 'filter_type': '问题件状态',
|
|
|
+ 'keyword': '待',
|
|
|
+ 'page': 2,
|
|
|
+ 'limit': 10,
|
|
|
+ },
|
|
|
+ client.calls[-1][2],
|
|
|
+ )
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
+ cls(client).call('问题件类型')
|
|
|
+ with self.assertRaises(RuntimeError):
|
|
|
+ cls().call('问题件状态')
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
+ cls(client).call('问题件状态', keyword='x' * 101)
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
+ cls(client).call('问题件状态', page=0)
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
+ cls(client).call('问题件状态', limit=True)
|
|
|
+
|
|
|
+ def test_query_covers_remaining_validation_branches(self):
|
|
|
+ cls = self.tool_class(
|
|
|
+ 'query_order_abnormal_list', 'QueryOrderAbnormalListTool'
|
|
|
+ )
|
|
|
+ client = RecordingApiClient()
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
+ cls(client).call(
|
|
|
+ abnormal_status='待处理',
|
|
|
+ order_numbers=['X%s' % i for i in range(201)],
|
|
|
+ )
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
+ cls(client).call(abnormal_status='待处理', order_numbers='FMS-1')
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
+ cls(client).call(abnormal_status='待处理', order_numbers=[1])
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
+ cls(client).call(abnormal_status='待处理', order_numbers=[' '])
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
+ cls(client).call(
|
|
|
+ abnormal_status='待处理',
|
|
|
+ order_numbers=['X' * 101],
|
|
|
+ )
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
+ cls(client).call(abnormal_status='待处理', page=0)
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
+ cls(client).call(abnormal_status='待处理', limit=True)
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
+ cls(client).call(abnormal_status='待处理', order_numbers=[])
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
+ cls(client).call(abnormal_status='待处理', page=101)
|
|
|
+
|
|
|
+ def test_cli_forwards_status_and_order_numbers(self):
|
|
|
+ client = RecordingApiClient()
|
|
|
+ app = GatewayApp(api_client=client)
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
+ app.run_cli([
|
|
|
+ 'call', '--tool', 'query_order_abnormal_list',
|
|
|
+ ], stdout=io.StringIO())
|
|
|
+ app.run_cli([
|
|
|
+ 'call', '--tool', 'query_order_abnormal_list',
|
|
|
+ '--abnormal-status', '退件待审核',
|
|
|
+ '--order-numbers', ' FMS-1,FMS-2 ',
|
|
|
+ ], stdout=io.StringIO())
|
|
|
+ self.assertEqual(
|
|
|
+ {
|
|
|
+ 'abnormal_status': '退件待审核',
|
|
|
+ 'order_numbers': ['FMS-1', 'FMS-2'],
|
|
|
+ 'page': 1,
|
|
|
+ 'limit': 20,
|
|
|
+ },
|
|
|
+ client.calls[-1][2],
|
|
|
+ )
|
|
|
+ app.run_cli([
|
|
|
+ 'call', '--tool', 'query_order_abnormal_list',
|
|
|
+ '--abnormal-status', '待处理',
|
|
|
+ ], stdout=io.StringIO())
|
|
|
+ self.assertEqual(
|
|
|
+ {'abnormal_status': '待处理', 'page': 1, 'limit': 20},
|
|
|
+ client.calls[-1][2],
|
|
|
+ )
|
|
|
+ app.run_cli([
|
|
|
+ 'call', '--tool', 'list_order_abnormal_filter_options',
|
|
|
+ '--filter-type', '问题件状态',
|
|
|
+ ], stdout=io.StringIO())
|
|
|
+ self.assertEqual('list_order_abnormal_filter_options', client.calls[-1][0])
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
+ app.run_cli([
|
|
|
+ 'call', '--tool', 'list_order_abnormal_filter_options',
|
|
|
+ ], stdout=io.StringIO())
|
|
|
+
|
|
|
+ def test_presenter_renders_nine_columns_and_fails_closed(self):
|
|
|
+ presenter = OutputPresenter()
|
|
|
+ meta = {
|
|
|
+ 'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
|
|
|
+ }
|
|
|
+ columns = [{'key': key, 'name': name} for key, name in COLUMNS]
|
|
|
+ record = {key: 'v' for key, _ in COLUMNS}
|
|
|
+ record['abnormal_status'] = '待处理'
|
|
|
+ ok = presenter.present('query_order_abnormal_list', {
|
|
|
+ 'code': 'MCP_0000',
|
|
|
+ 'data': {
|
|
|
+ 'abnormal_status': '待处理',
|
|
|
+ 'columns': columns,
|
|
|
+ 'records': [record],
|
|
|
+ },
|
|
|
+ 'meta': meta,
|
|
|
+ })
|
|
|
+ self.assertFalse(ok['is_error'])
|
|
|
+ self.assertEqual(
|
|
|
+ ['订单号', '客户', '物流产品', '问题件状态', '问题件类型',
|
|
|
+ '问题件描述', '排舱单状态', '入库时间', '标记人'],
|
|
|
+ [item['label'] for item in ok['structured_content']['headers']],
|
|
|
+ )
|
|
|
+
|
|
|
+ self.assertTrue(presenter.present('query_order_abnormal_list', {
|
|
|
+ 'code': 'MCP_0000',
|
|
|
+ 'data': {
|
|
|
+ 'abnormal_status': '待处理',
|
|
|
+ 'columns': [],
|
|
|
+ 'records': [],
|
|
|
+ 'extra': 1,
|
|
|
+ },
|
|
|
+ 'meta': meta,
|
|
|
+ })['is_error'])
|
|
|
+ self.assertTrue(presenter.present('query_order_abnormal_list', {
|
|
|
+ 'code': 'MCP_0000',
|
|
|
+ 'data': {
|
|
|
+ 'abnormal_status': '结案',
|
|
|
+ 'columns': columns,
|
|
|
+ 'records': [],
|
|
|
+ },
|
|
|
+ 'meta': meta,
|
|
|
+ })['is_error'])
|
|
|
+ bad_columns = list(columns)
|
|
|
+ bad_columns[0] = {'key': 'order_number', 'name': '错'}
|
|
|
+ self.assertTrue(presenter.present('query_order_abnormal_list', {
|
|
|
+ 'code': 'MCP_0000',
|
|
|
+ 'data': {
|
|
|
+ 'abnormal_status': '待处理',
|
|
|
+ 'columns': bad_columns,
|
|
|
+ 'records': [],
|
|
|
+ },
|
|
|
+ 'meta': meta,
|
|
|
+ })['is_error'])
|
|
|
+ bad_record = dict(record)
|
|
|
+ bad_record['order_number'] = 1
|
|
|
+ self.assertTrue(presenter.present('query_order_abnormal_list', {
|
|
|
+ 'code': 'MCP_0000',
|
|
|
+ 'data': {
|
|
|
+ 'abnormal_status': '待处理',
|
|
|
+ 'columns': columns,
|
|
|
+ 'records': [bad_record],
|
|
|
+ },
|
|
|
+ 'meta': meta,
|
|
|
+ })['is_error'])
|
|
|
+ self.assertTrue(presenter.present('query_order_abnormal_list', {
|
|
|
+ 'code': 'MCP_0000',
|
|
|
+ 'data': {
|
|
|
+ 'abnormal_status': '待处理',
|
|
|
+ 'columns': columns,
|
|
|
+ 'records': 'bad',
|
|
|
+ },
|
|
|
+ 'meta': meta,
|
|
|
+ })['is_error'])
|
|
|
+ extra_record = dict(record)
|
|
|
+ extra_record['deal_remark'] = 'x'
|
|
|
+ self.assertTrue(presenter.present('query_order_abnormal_list', {
|
|
|
+ 'code': 'MCP_0000',
|
|
|
+ 'data': {
|
|
|
+ 'abnormal_status': '待处理',
|
|
|
+ 'columns': columns,
|
|
|
+ 'records': [extra_record],
|
|
|
+ },
|
|
|
+ 'meta': meta,
|
|
|
+ })['is_error'])
|
|
|
+ self.assertTrue(presenter.present('query_order_abnormal_list', {
|
|
|
+ 'code': 'MCP_0000',
|
|
|
+ 'data': {
|
|
|
+ 'abnormal_status': '待处理',
|
|
|
+ 'columns': None,
|
|
|
+ 'records': [],
|
|
|
+ },
|
|
|
+ 'meta': meta,
|
|
|
+ })['is_error'])
|
|
|
+ self.assertTrue(presenter.present('query_order_abnormal_list', {
|
|
|
+ 'code': 'MCP_0000',
|
|
|
+ 'data': {
|
|
|
+ 'abnormal_status': '待处理',
|
|
|
+ 'columns': columns,
|
|
|
+ 'records': [None],
|
|
|
+ },
|
|
|
+ 'meta': meta,
|
|
|
+ })['is_error'])
|
|
|
+ self.assertTrue(presenter.present('query_order_abnormal_list', {
|
|
|
+ 'code': 'MCP_0000',
|
|
|
+ 'data': {
|
|
|
+ 'abnormal_status': '待处理',
|
|
|
+ 'columns': columns,
|
|
|
+ 'records': [],
|
|
|
+ },
|
|
|
+ 'meta': {
|
|
|
+ 'page': 1, 'limit': 20, 'has_more': False,
|
|
|
+ 'request_id': 'rq_x', 'total': 1,
|
|
|
+ },
|
|
|
+ })['is_error'])
|
|
|
+
|
|
|
+ options = presenter.present('list_order_abnormal_filter_options', {
|
|
|
+ 'code': 'MCP_0000',
|
|
|
+ 'data': {
|
|
|
+ 'records': [
|
|
|
+ {'value': '待处理', 'label': '待处理', 'code': ''},
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ 'meta': meta,
|
|
|
+ })
|
|
|
+ self.assertFalse(options['is_error'])
|
|
|
+
|
|
|
+ def test_local_public_registry_includes_both_tools(self):
|
|
|
+ local = GatewayApp().registered_tool_names()
|
|
|
+ public = PublicGatewayApp(None, None).registered_tool_names()
|
|
|
+ self.assertEqual(local, public)
|
|
|
+ self.assertEqual(30, len(local))
|
|
|
+ self.assertEqual(29, len(OutputPresenter.SAFE_TOOLS))
|
|
|
+ self.assertIn('query_order_abnormal_list', local)
|
|
|
+ self.assertIn('list_order_abnormal_filter_options', local)
|