test_app_coverage.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  1. """
  2. Coverage补充测试:app.py
  3. 目标路径:
  4. - GatewayApp.from_config — token_store_type 不支持 → ValueError
  5. - GatewayApp.run_cli call — else 分支(tool 既非 query_order 也非 query_track,有 keyword)
  6. - GatewayApp.run_cli call — else 分支(tool 既非 query_order 也非 query_track,无 keyword)
  7. - main() — 正常调用 list-tools
  8. """
  9. import io
  10. import json
  11. import os
  12. import tempfile
  13. import unittest
  14. from types import SimpleNamespace
  15. from unittest.mock import MagicMock, patch
  16. from app import GatewayApp, main
  17. from services.token_store import FileTokenStore
  18. from tools.query_order import QueryOrderTool
  19. # ---------------------------------------------------------------------------
  20. # GatewayApp.from_config — 不支持的 store type
  21. # ---------------------------------------------------------------------------
  22. class FromConfigUnsupportedStoreTest(unittest.TestCase):
  23. def test_raises_value_error_for_unknown_store_type(self):
  24. config = MagicMock()
  25. config.token_store_type = 'memcached'
  26. with self.assertRaises(ValueError) as ctx:
  27. GatewayApp.from_config(config)
  28. self.assertIn('unsupported token store type', str(ctx.exception))
  29. self.assertIn('memcached', str(ctx.exception))
  30. def test_from_config_file_store(self):
  31. """file store 分支:正常返回 GatewayApp 实例。"""
  32. with tempfile.TemporaryDirectory() as tmp_dir:
  33. config = MagicMock()
  34. config.token_store_type = 'file'
  35. config.token_store_path = os.path.join(tmp_dir, 'tok.json')
  36. config.refresh_skew_seconds = 120
  37. config.auth_base_url = 'http://auth.test'
  38. config.client_type = 'workbuddy'
  39. config.timeout_seconds = 5
  40. config.session_key = 'test_session'
  41. config.tools_base_url = 'http://api.test'
  42. app = GatewayApp.from_config(config)
  43. self.assertIsInstance(app, GatewayApp)
  44. def test_from_config_redis_store_uses_provided_redis_client(self):
  45. """redis store 分支:传入 redis_client 时跳过 RedisSocketClient 创建。"""
  46. mock_redis = MagicMock()
  47. config = MagicMock()
  48. config.token_store_type = 'redis'
  49. config.redis_prefix = 'test:'
  50. config.session_key = 'sess_1'
  51. config.refresh_skew_seconds = 120
  52. config.auth_base_url = 'http://auth.test'
  53. config.client_type = 'workbuddy'
  54. config.timeout_seconds = 5
  55. config.tools_base_url = 'http://api.test'
  56. # get() 返回 None,避免 RedisTokenStore 在初始化时崩溃
  57. mock_redis.get.return_value = None
  58. app = GatewayApp.from_config(config, redis_client=mock_redis)
  59. self.assertIsInstance(app, GatewayApp)
  60. # ---------------------------------------------------------------------------
  61. # GatewayApp.run_cli call — else 分支
  62. # ---------------------------------------------------------------------------
  63. class DummyApiClient:
  64. def list_enabled_tools(self, request_id=''):
  65. return {
  66. 'code': 'MCP_0000',
  67. 'data': {
  68. 'tool_codes': [
  69. 'query_order',
  70. 'query_track',
  71. 'query_order_exact',
  72. 'list_order_filter_options',
  73. 'fake_tool',
  74. 'no_kw_tool',
  75. ],
  76. },
  77. }
  78. def call_tool(self, tool_code, route_path, payload, request_id):
  79. return {
  80. 'code': 'MCP_0000',
  81. 'data': {},
  82. 'meta': {'request_id': request_id},
  83. }
  84. class DummyAuthClient:
  85. def __init__(self, token_store):
  86. self.token_store = token_store
  87. def refresh(self, mcp_token):
  88. pass
  89. class GatewayAppBoundaryTest(unittest.TestCase):
  90. def test_registered_tool_names_returns_local_registry(self):
  91. app = GatewayApp(api_client=DummyApiClient())
  92. self.assertEqual(tuple(app._tools.keys()), app.registered_tool_names())
  93. def test_enabled_tool_names_rejects_non_dict_response(self):
  94. with self.assertRaisesRegex(RuntimeError, 'invalid enabled tool response'):
  95. GatewayApp()._enabled_tool_names(None)
  96. def test_enabled_tool_names_rejects_missing_tool_code_list(self):
  97. response = {'code': 'MCP_0000', 'data': {}}
  98. with self.assertRaisesRegex(RuntimeError, 'invalid enabled tool response'):
  99. GatewayApp()._enabled_tool_names(response)
  100. def test_load_enabled_tools_requires_capable_client(self):
  101. for api_client in (None, object()):
  102. with self.subTest(api_client=api_client):
  103. with self.assertRaisesRegex(RuntimeError, 'client unavailable'):
  104. GatewayApp(api_client=api_client)._load_enabled_tool_names()
  105. def test_build_request_id_preserves_provided_value(self):
  106. self.assertEqual(
  107. 'rq_given',
  108. GatewayApp().build_request_id(' rq_given '),
  109. )
  110. def test_ensure_session_requires_auth_client_for_expiring_token(self):
  111. token_store = MagicMock()
  112. token_store.get.return_value = {'token': 'MT_test'}
  113. token_store.is_expiring.return_value = True
  114. with self.assertRaisesRegex(RuntimeError, 'auth client missing'):
  115. GatewayApp(token_store=token_store).ensure_session()
  116. def _make_app_with_token():
  117. """创建有有效 token 的 GatewayApp,附带一个自定义 fake_tool 工具。"""
  118. with tempfile.TemporaryDirectory() as tmp_dir:
  119. path = os.path.join(tmp_dir, 'token.json')
  120. token_store = FileTokenStore(path)
  121. token_store.save('MT_test', '2099-01-01T00:00:00')
  122. api_client = DummyApiClient()
  123. app = GatewayApp(
  124. auth_client=DummyAuthClient(token_store),
  125. api_client=api_client,
  126. token_store=token_store,
  127. )
  128. # 注册一个假工具,使 else 分支可以正常 call_tool
  129. app._tools['fake_tool'] = QueryOrderTool(api_client=api_client)
  130. return app, tmp_dir
  131. class RunCliCallElseBranchTest(unittest.TestCase):
  132. def test_call_else_branch_with_keyword_passes_keyword_to_tool(self):
  133. """
  134. --tool fake_tool(非 query_order / query_track)且 --keyword 有值 →
  135. 走 else 分支,tool_args 中加入 keyword。
  136. """
  137. with tempfile.TemporaryDirectory() as tmp_dir:
  138. path = os.path.join(tmp_dir, 'token.json')
  139. token_store = FileTokenStore(path)
  140. token_store.save('MT_test', '2099-01-01T00:00:00')
  141. api_client = DummyApiClient()
  142. app = GatewayApp(
  143. auth_client=DummyAuthClient(token_store),
  144. api_client=api_client,
  145. token_store=token_store,
  146. )
  147. app._tools['fake_tool'] = QueryOrderTool(api_client=api_client)
  148. stdout = io.StringIO()
  149. code = app.run_cli(
  150. ['call', '--tool', 'fake_tool', '--keyword', 'hello'],
  151. stdout=stdout,
  152. )
  153. self.assertEqual(0, code)
  154. payload = json.loads(stdout.getvalue())
  155. self.assertEqual('MCP_0000', payload['code'])
  156. class RunCliBranchCoverageTest(unittest.TestCase):
  157. def _run_with_mocked_call(self, argv):
  158. app = GatewayApp(api_client=DummyApiClient())
  159. stdout = io.StringIO()
  160. with patch.object(
  161. app,
  162. 'call_tool',
  163. return_value={'code': 'MCP_0000'},
  164. ) as call_tool:
  165. result = app.run_cli(argv, stdout=stdout)
  166. return result, call_tool
  167. def test_query_order_requires_keyword(self):
  168. with self.assertRaisesRegex(ValueError, 'keyword is required'):
  169. GatewayApp().run_cli([
  170. 'call', '--tool', 'query_order',
  171. ], stdout=io.StringIO())
  172. def test_query_track_forwards_each_supported_identifier(self):
  173. cases = (
  174. (['--order-id', '5'], {'order_id': 5}),
  175. (['--order-number', 'ORDER-1'], {'order_number': 'ORDER-1'}),
  176. (['--tracking-number', 'TRACK-1'], {'tracking_number': 'TRACK-1'}),
  177. )
  178. for cli_args, expected in cases:
  179. with self.subTest(cli_args=cli_args):
  180. result, call_tool = self._run_with_mocked_call([
  181. 'call', '--tool', 'query_track', *cli_args,
  182. ])
  183. self.assertEqual(0, result)
  184. payload = call_tool.call_args.args[1]
  185. for key, value in expected.items():
  186. self.assertEqual(value, payload[key])
  187. def test_query_track_requires_one_identifier(self):
  188. with self.assertRaisesRegex(ValueError, 'order-id'):
  189. GatewayApp().run_cli([
  190. 'call', '--tool', 'query_track',
  191. ], stdout=io.StringIO())
  192. def test_query_order_exact_forwards_scalar_ids(self):
  193. result, call_tool = self._run_with_mocked_call([
  194. 'call', '--tool', 'query_order_exact',
  195. '--order-number', 'ORDER-1',
  196. '--sales-id', '7',
  197. '--department-id', '8',
  198. ])
  199. self.assertEqual(0, result)
  200. payload = call_tool.call_args.args[1]
  201. self.assertEqual(7, payload['sales_id'])
  202. self.assertEqual(8, payload['department_id'])
  203. def test_filter_options_requires_filter_type(self):
  204. with self.assertRaisesRegex(ValueError, 'filter-type is required'):
  205. GatewayApp().run_cli([
  206. 'call', '--tool', 'list_order_filter_options',
  207. ], stdout=io.StringIO())
  208. def test_serve_public_builds_scoped_dependencies(self):
  209. config = SimpleNamespace(
  210. redis_host='redis.test',
  211. redis_port=6380,
  212. redis_db=2,
  213. redis_password='secret',
  214. timeout_seconds=9,
  215. redis_prefix='gateway:',
  216. gateway_session_ttl_seconds=600,
  217. tools_base_url='https://tools.test',
  218. rate_limit_enabled=True,
  219. rate_limit_max_requests=12,
  220. rate_limit_window_seconds=34,
  221. )
  222. with patch('app.GatewayConfig.from_env', return_value=config), \
  223. patch('app.RedisSocketClient') as redis_cls, \
  224. patch('app.GatewaySessionStore') as store_cls, \
  225. patch('app.ScopedApiClient') as api_cls, \
  226. patch('app.PublicGatewayApp') as public_app_cls, \
  227. patch('app.serve_public', return_value=17) as serve:
  228. result = GatewayApp().run_cli([
  229. 'serve-public', '--host', '127.0.0.1', '--port', '9000',
  230. ])
  231. self.assertEqual(17, result)
  232. redis_cls.assert_called_once_with(
  233. host='redis.test',
  234. port=6380,
  235. db=2,
  236. password='secret',
  237. timeout=9,
  238. )
  239. store_cls.assert_called_once_with(
  240. redis_cls.return_value,
  241. prefix='gateway:',
  242. ttl_seconds=600,
  243. )
  244. api_cls.assert_called_once_with('https://tools.test', timeout=9)
  245. public_app_cls.assert_called_once_with(
  246. session_store=store_cls.return_value,
  247. api_client=api_cls.return_value,
  248. )
  249. serve.assert_called_once_with(
  250. public_app_cls.return_value,
  251. host='127.0.0.1',
  252. port=9000,
  253. enable_rate_limit=True,
  254. rate_limit_max_requests=12,
  255. rate_limit_window_seconds=34,
  256. )
  257. def test_unsupported_command_raises_runtime_error(self):
  258. with patch(
  259. 'app.argparse.ArgumentParser.parse_args',
  260. return_value=SimpleNamespace(command='unknown'),
  261. ):
  262. with self.assertRaisesRegex(RuntimeError, 'unsupported command'):
  263. GatewayApp().run_cli([], stdout=io.StringIO())
  264. def test_call_else_branch_without_keyword_still_calls_tool(self):
  265. """
  266. --tool fake_tool(非 query_order / query_track)且 --keyword 为空 →
  267. 走 else 分支,tool_args 中不含 keyword(由工具自己决定是否报错)。
  268. 注:fake_tool 是 QueryOrderTool,无 keyword 会抛 ValueError →
  269. GatewayApp.run_cli 不 catch,直接抛出。
  270. """
  271. with tempfile.TemporaryDirectory() as tmp_dir:
  272. path = os.path.join(tmp_dir, 'token.json')
  273. token_store = FileTokenStore(path)
  274. token_store.save('MT_test', '2099-01-01T00:00:00')
  275. api_client = DummyApiClient()
  276. app = GatewayApp(
  277. auth_client=DummyAuthClient(token_store),
  278. api_client=api_client,
  279. token_store=token_store,
  280. )
  281. # 注册一个不需要 keyword 的假工具
  282. class NoKeywordTool:
  283. name = 'no_kw_tool'
  284. route_path = '/fake'
  285. requires_session = False
  286. def metadata(self):
  287. return {'name': self.name, 'description': '', 'input_schema': {'type': 'object', 'properties': {}}}
  288. def call(self, request_id='', **_kwargs):
  289. return {'code': 'MCP_0000', 'data': {}}
  290. app._tools['no_kw_tool'] = NoKeywordTool()
  291. stdout = io.StringIO()
  292. # --keyword 不传,else 分支 keyword 为空 → 不加入 tool_args
  293. code = app.run_cli(
  294. ['call', '--tool', 'no_kw_tool'],
  295. stdout=stdout,
  296. )
  297. self.assertEqual(0, code)
  298. # ---------------------------------------------------------------------------
  299. # main() — 正常调用
  300. # ---------------------------------------------------------------------------
  301. class MainFunctionTest(unittest.TestCase):
  302. def test_main_list_tools_returns_zero(self):
  303. """
  304. main() 读取 env、构建 app、调用 run_cli(['list-tools'])。
  305. 直接 mock GatewayConfig.from_env 返回 file store 配置,避免依赖 .env 文件。
  306. """
  307. import config as config_module
  308. with tempfile.TemporaryDirectory() as tmp_dir:
  309. token_path = os.path.join(tmp_dir, 'token.json')
  310. fake_config = config_module.GatewayConfig(
  311. auth_base_url='http://auth.example.com',
  312. tools_base_url='http://api.example.com',
  313. token_store_type='file',
  314. token_store_path=token_path,
  315. session_key='test_machine:test_user',
  316. )
  317. stdout = io.StringIO()
  318. enabled = {
  319. 'code': 'MCP_0000',
  320. 'data': {
  321. 'tool_codes': [
  322. 'query_order',
  323. 'query_track',
  324. 'query_order_exact',
  325. 'list_order_filter_options',
  326. ],
  327. },
  328. }
  329. with patch.object(config_module.GatewayConfig, 'from_env', return_value=fake_config):
  330. with patch('services.api_client.ApiClient.list_enabled_tools', return_value=enabled):
  331. with patch('sys.stdout', stdout):
  332. code = main(['list-tools'])
  333. self.assertEqual(0, code)
  334. tools = json.loads(stdout.getvalue())
  335. names = [t['name'] for t in tools]
  336. self.assertIn('query_order', names)
  337. self.assertIn('query_track', names)
  338. self.assertIn('query_order_exact', names)
  339. self.assertIn('list_order_filter_options', names)
  340. def test_main_propagates_value_error_for_bad_store(self):
  341. """
  342. 当 token_store_type 为不支持的值时,main() 应抛出 ValueError。
  343. """
  344. import config as config_module
  345. bad_config = config_module.GatewayConfig(
  346. auth_base_url='http://auth.example.com',
  347. tools_base_url='http://api.example.com',
  348. token_store_type='bad_store_type',
  349. session_key='host:user',
  350. )
  351. with self.assertRaises(ValueError):
  352. with patch.object(config_module.GatewayConfig, 'from_env', return_value=bad_config):
  353. main(['list-tools'])
  354. if __name__ == '__main__':
  355. unittest.main()