test_app_coverage.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  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 unittest.mock import MagicMock, patch
  15. from app import GatewayApp, main
  16. from services.token_store import FileTokenStore
  17. from tools.query_order import QueryOrderTool
  18. # ---------------------------------------------------------------------------
  19. # GatewayApp.from_config — 不支持的 store type
  20. # ---------------------------------------------------------------------------
  21. class FromConfigUnsupportedStoreTest(unittest.TestCase):
  22. def test_raises_value_error_for_unknown_store_type(self):
  23. config = MagicMock()
  24. config.token_store_type = 'memcached'
  25. with self.assertRaises(ValueError) as ctx:
  26. GatewayApp.from_config(config)
  27. self.assertIn('unsupported token store type', str(ctx.exception))
  28. self.assertIn('memcached', str(ctx.exception))
  29. def test_from_config_file_store(self):
  30. """file store 分支:正常返回 GatewayApp 实例。"""
  31. with tempfile.TemporaryDirectory() as tmp_dir:
  32. config = MagicMock()
  33. config.token_store_type = 'file'
  34. config.token_store_path = os.path.join(tmp_dir, 'tok.json')
  35. config.refresh_skew_seconds = 120
  36. config.auth_base_url = 'http://auth.test'
  37. config.client_type = 'workbuddy'
  38. config.timeout_seconds = 5
  39. config.session_key = 'test_session'
  40. config.tools_base_url = 'http://api.test'
  41. app = GatewayApp.from_config(config)
  42. self.assertIsInstance(app, GatewayApp)
  43. def test_from_config_redis_store_uses_provided_redis_client(self):
  44. """redis store 分支:传入 redis_client 时跳过 RedisSocketClient 创建。"""
  45. mock_redis = MagicMock()
  46. config = MagicMock()
  47. config.token_store_type = 'redis'
  48. config.redis_prefix = 'test:'
  49. config.session_key = 'sess_1'
  50. config.refresh_skew_seconds = 120
  51. config.auth_base_url = 'http://auth.test'
  52. config.client_type = 'workbuddy'
  53. config.timeout_seconds = 5
  54. config.tools_base_url = 'http://api.test'
  55. # get() 返回 None,避免 RedisTokenStore 在初始化时崩溃
  56. mock_redis.get.return_value = None
  57. app = GatewayApp.from_config(config, redis_client=mock_redis)
  58. self.assertIsInstance(app, GatewayApp)
  59. # ---------------------------------------------------------------------------
  60. # GatewayApp.run_cli call — else 分支
  61. # ---------------------------------------------------------------------------
  62. class DummyApiClient:
  63. def list_enabled_tools(self):
  64. return {
  65. 'code': 'MCP_0000',
  66. 'data': {
  67. 'tool_codes': [
  68. 'query_order',
  69. 'query_track',
  70. 'query_order_exact',
  71. 'list_order_filter_options',
  72. 'fake_tool',
  73. 'no_kw_tool',
  74. ],
  75. },
  76. }
  77. def call_tool(self, tool_code, route_path, payload, request_id):
  78. return {
  79. 'code': 'MCP_0000',
  80. 'data': {},
  81. 'meta': {'request_id': request_id},
  82. }
  83. class DummyAuthClient:
  84. def __init__(self, token_store):
  85. self.token_store = token_store
  86. def refresh(self, mcp_token):
  87. pass
  88. def _make_app_with_token():
  89. """创建有有效 token 的 GatewayApp,附带一个自定义 fake_tool 工具。"""
  90. with tempfile.TemporaryDirectory() as tmp_dir:
  91. path = os.path.join(tmp_dir, 'token.json')
  92. token_store = FileTokenStore(path)
  93. token_store.save('MT_test', '2099-01-01T00:00:00')
  94. api_client = DummyApiClient()
  95. app = GatewayApp(
  96. auth_client=DummyAuthClient(token_store),
  97. api_client=api_client,
  98. token_store=token_store,
  99. )
  100. # 注册一个假工具,使 else 分支可以正常 call_tool
  101. app._tools['fake_tool'] = QueryOrderTool(api_client=api_client)
  102. return app, tmp_dir
  103. class RunCliCallElseBranchTest(unittest.TestCase):
  104. def test_call_else_branch_with_keyword_passes_keyword_to_tool(self):
  105. """
  106. --tool fake_tool(非 query_order / query_track)且 --keyword 有值 →
  107. 走 else 分支,tool_args 中加入 keyword。
  108. """
  109. with tempfile.TemporaryDirectory() as tmp_dir:
  110. path = os.path.join(tmp_dir, 'token.json')
  111. token_store = FileTokenStore(path)
  112. token_store.save('MT_test', '2099-01-01T00:00:00')
  113. api_client = DummyApiClient()
  114. app = GatewayApp(
  115. auth_client=DummyAuthClient(token_store),
  116. api_client=api_client,
  117. token_store=token_store,
  118. )
  119. app._tools['fake_tool'] = QueryOrderTool(api_client=api_client)
  120. stdout = io.StringIO()
  121. code = app.run_cli(
  122. ['call', '--tool', 'fake_tool', '--keyword', 'hello'],
  123. stdout=stdout,
  124. )
  125. self.assertEqual(0, code)
  126. payload = json.loads(stdout.getvalue())
  127. self.assertEqual('MCP_0000', payload['code'])
  128. def test_call_else_branch_without_keyword_still_calls_tool(self):
  129. """
  130. --tool fake_tool(非 query_order / query_track)且 --keyword 为空 →
  131. 走 else 分支,tool_args 中不含 keyword(由工具自己决定是否报错)。
  132. 注:fake_tool 是 QueryOrderTool,无 keyword 会抛 ValueError →
  133. GatewayApp.run_cli 不 catch,直接抛出。
  134. """
  135. with tempfile.TemporaryDirectory() as tmp_dir:
  136. path = os.path.join(tmp_dir, 'token.json')
  137. token_store = FileTokenStore(path)
  138. token_store.save('MT_test', '2099-01-01T00:00:00')
  139. api_client = DummyApiClient()
  140. app = GatewayApp(
  141. auth_client=DummyAuthClient(token_store),
  142. api_client=api_client,
  143. token_store=token_store,
  144. )
  145. # 注册一个不需要 keyword 的假工具
  146. class NoKeywordTool:
  147. name = 'no_kw_tool'
  148. route_path = '/fake'
  149. requires_session = False
  150. def metadata(self):
  151. return {'name': self.name, 'description': '', 'input_schema': {'type': 'object', 'properties': {}}}
  152. def call(self, request_id='', **_kwargs):
  153. return {'code': 'MCP_0000', 'data': {}}
  154. app._tools['no_kw_tool'] = NoKeywordTool()
  155. stdout = io.StringIO()
  156. # --keyword 不传,else 分支 keyword 为空 → 不加入 tool_args
  157. code = app.run_cli(
  158. ['call', '--tool', 'no_kw_tool'],
  159. stdout=stdout,
  160. )
  161. self.assertEqual(0, code)
  162. # ---------------------------------------------------------------------------
  163. # main() — 正常调用
  164. # ---------------------------------------------------------------------------
  165. class MainFunctionTest(unittest.TestCase):
  166. def test_main_list_tools_returns_zero(self):
  167. """
  168. main() 读取 env、构建 app、调用 run_cli(['list-tools'])。
  169. 直接 mock GatewayConfig.from_env 返回 file store 配置,避免依赖 .env 文件。
  170. """
  171. import config as config_module
  172. with tempfile.TemporaryDirectory() as tmp_dir:
  173. token_path = os.path.join(tmp_dir, 'token.json')
  174. fake_config = config_module.GatewayConfig(
  175. auth_base_url='http://auth.example.com',
  176. tools_base_url='http://api.example.com',
  177. token_store_type='file',
  178. token_store_path=token_path,
  179. session_key='test_machine:test_user',
  180. )
  181. stdout = io.StringIO()
  182. enabled = {
  183. 'code': 'MCP_0000',
  184. 'data': {
  185. 'tool_codes': [
  186. 'query_order',
  187. 'query_track',
  188. 'query_order_exact',
  189. 'list_order_filter_options',
  190. ],
  191. },
  192. }
  193. with patch.object(config_module.GatewayConfig, 'from_env', return_value=fake_config):
  194. with patch('services.api_client.ApiClient.list_enabled_tools', return_value=enabled):
  195. with patch('sys.stdout', stdout):
  196. code = main(['list-tools'])
  197. self.assertEqual(0, code)
  198. tools = json.loads(stdout.getvalue())
  199. names = [t['name'] for t in tools]
  200. self.assertIn('query_order', names)
  201. self.assertIn('query_track', names)
  202. self.assertIn('query_order_exact', names)
  203. self.assertIn('list_order_filter_options', names)
  204. def test_main_propagates_value_error_for_bad_store(self):
  205. """
  206. 当 token_store_type 为不支持的值时,main() 应抛出 ValueError。
  207. """
  208. import config as config_module
  209. bad_config = config_module.GatewayConfig(
  210. auth_base_url='http://auth.example.com',
  211. tools_base_url='http://api.example.com',
  212. token_store_type='bad_store_type',
  213. session_key='host:user',
  214. )
  215. with self.assertRaises(ValueError):
  216. with patch.object(config_module.GatewayConfig, 'from_env', return_value=bad_config):
  217. main(['list-tools'])
  218. if __name__ == '__main__':
  219. unittest.main()