Files
ewoooc/tests/test_mcp_router.py
OoO ba8510eac7
All checks were successful
CD Pipeline / deploy (push) Successful in 57s
補齊 MCP 早退狀態
2026-05-13 09:40:55 +08:00

251 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
tests/test_mcp_router.py
─────────────────────────────────────────────────────────────────
Operation Ollama-First v5.0 / Phase 10.5 — MCP Router unit tests
驗證面:
T1. flag OFF → 直接回 success=False不打 HTTP
T2. flag ON + tool 不在白名單 → 回 success=False
T3. flag ON + 正常 → HTTP call + cache 命中第二次
T4. flag ON + HTTP timeout → success=False + error 含 timeout
T5. flag ON + HTTP 500 → success=False + error 含 HTTP 500
T6. cache key 排序穩定(相同 args 不同 dict 順序 → 同 key
T7. health_check 全失敗時所有 server 回 False
紀律:
- 不打真實 MCP server全 mock requests.post / requests.get
- 不寫 mcp_calls 表fire-and-forget thread 不影響 test
"""
import json
from unittest.mock import patch, MagicMock
import pytest
@pytest.fixture(autouse=True)
def _reset_state(monkeypatch):
"""每 test 清 env + memory cache"""
monkeypatch.delenv('MCP_ROUTER_ENABLED', raising=False)
import services.mcp_router as mr
mr._memory_cache.clear()
yield
mr._memory_cache.clear()
# ═══════════════════════════════════════════════════════════════════════════
# T1: flag OFF
# ═══════════════════════════════════════════════════════════════════════════
def test_flag_off_returns_failure_without_http(monkeypatch):
monkeypatch.setenv('MCP_ROUTER_ENABLED', 'false')
from services.mcp_router import mcp_router
with patch('services.mcp_router.requests.post') as mock_post:
result = mcp_router.call(
server='omnisearch', tool='tavily_search',
args={'query': 'test'}, caller='mcp_collector',
)
assert result.success is False
assert result.status == 'error'
assert 'MCP_ROUTER_ENABLED=false' in (result.error or '')
mock_post.assert_not_called()
# ═══════════════════════════════════════════════════════════════════════════
# T2: flag ON + tool 不在白名單
# ═══════════════════════════════════════════════════════════════════════════
def test_unauthorized_caller_tool_combo_rejected(monkeypatch):
monkeypatch.setenv('MCP_ROUTER_ENABLED', 'true')
from services.mcp_router import mcp_router
with patch('services.mcp_router.requests.post') as mock_post:
# mcp_collector 不允許 postgres
result = mcp_router.call(
server='postgres', tool='query',
args={'sql': 'SELECT 1'}, caller='mcp_collector',
)
assert result.success is False
assert result.status == 'error'
assert 'tool not in registry' in (result.error or '')
mock_post.assert_not_called()
def test_filesystem_registry_is_read_only(monkeypatch):
monkeypatch.setenv('MCP_ROUTER_ENABLED', 'true')
from services.mcp_router import mcp_router
fake_resp = MagicMock(status_code=200)
fake_resp.json.return_value = {'content': 'ok'}
fake_resp.text = '{"content": "ok"}'
with patch('services.mcp_router.requests.post', return_value=fake_resp) as mock_post:
allowed = mcp_router.call(
server='filesystem', tool='read_file',
args={'path': '/logs/system.log'}, caller='ops_diagnostics',
)
denied = mcp_router.call(
server='filesystem', tool='write_file',
args={'path': '/data/x', 'content': 'nope'}, caller='ops_diagnostics',
)
assert allowed.success is True
assert denied.success is False
assert 'tool not in registry' in (denied.error or '')
mock_post.assert_called_once()
assert mock_post.call_args.args[0].endswith('/tools/read_file')
# ═══════════════════════════════════════════════════════════════════════════
# T3: flag ON + 正常 + cache
# ═══════════════════════════════════════════════════════════════════════════
def test_successful_call_and_cache_hit(monkeypatch):
monkeypatch.setenv('MCP_ROUTER_ENABLED', 'true')
import services.mcp_router as mr
from services.mcp_router import mcp_router
written_statuses = []
monkeypatch.setattr(
mr,
'_async_write_mcp_call',
lambda caller, server, tool, args, result, request_id=None: written_statuses.append(result.status),
)
fake_resp = MagicMock(status_code=200)
fake_resp.json.return_value = {'results': [{'title': '商品 A', 'content': '熱銷'}]}
fake_resp.text = '{"ok": true}'
with patch('services.mcp_router.requests.post', return_value=fake_resp) as mock_post:
# 第一次HTTP call
r1 = mcp_router.call(
server='omnisearch', tool='tavily_search',
args={'query': '母親節'}, caller='mcp_collector',
)
assert r1.success is True
assert r1.cache_hit is False
assert r1.status == 'ok'
assert r1.data['results'][0]['title'] == '商品 A'
assert mock_post.call_count == 1
# 第二次cache 命中
r2 = mcp_router.call(
server='omnisearch', tool='tavily_search',
args={'query': '母親節'}, caller='mcp_collector',
)
assert r2.success is True
assert r2.cache_hit is True
assert r2.status == 'cache_only'
assert mock_post.call_count == 1 # 沒再打 HTTP
assert written_statuses == ['ok', 'cache_only']
# ═══════════════════════════════════════════════════════════════════════════
# T4: HTTP timeout
# ═══════════════════════════════════════════════════════════════════════════
def test_http_timeout_returns_failure(monkeypatch):
monkeypatch.setenv('MCP_ROUTER_ENABLED', 'true')
import requests as _r
from services.mcp_router import mcp_router
with patch('services.mcp_router.requests.post', side_effect=_r.Timeout('30s')):
result = mcp_router.call(
server='omnisearch', tool='tavily_search',
args={'query': 'x'}, caller='mcp_collector',
)
assert result.success is False
assert result.status == 'timeout'
assert 'timeout' in (result.error or '').lower()
# ═══════════════════════════════════════════════════════════════════════════
# T5: HTTP 500
# ═══════════════════════════════════════════════════════════════════════════
def test_http_500_returns_failure(monkeypatch):
monkeypatch.setenv('MCP_ROUTER_ENABLED', 'true')
from services.mcp_router import mcp_router
fake_resp = MagicMock(status_code=500)
fake_resp.text = 'Internal Server Error'
with patch('services.mcp_router.requests.post', return_value=fake_resp):
result = mcp_router.call(
server='omnisearch', tool='tavily_search',
args={'query': 'x'}, caller='mcp_collector',
)
assert result.success is False
assert result.status == 'error'
assert 'HTTP 500' in (result.error or '')
def test_http_429_records_rate_limited_status(monkeypatch):
monkeypatch.setenv('MCP_ROUTER_ENABLED', 'true')
from services.mcp_router import mcp_router
fake_resp = MagicMock(status_code=429)
fake_resp.text = 'Too Many Requests'
with patch('services.mcp_router.requests.post', return_value=fake_resp):
result = mcp_router.call(
server='omnisearch', tool='tavily_search',
args={'query': 'x'}, caller='mcp_collector',
)
assert result.success is False
assert result.status == 'rate_limited'
assert 'HTTP 429' in (result.error or '')
# ═══════════════════════════════════════════════════════════════════════════
# T6: cache key 穩定性
# ═══════════════════════════════════════════════════════════════════════════
def test_cache_key_sort_stable():
from services.mcp_router import _cache_key
k1 = _cache_key('omnisearch', 'tavily_search', {'a': 1, 'b': 2, 'c': 3})
k2 = _cache_key('omnisearch', 'tavily_search', {'c': 3, 'a': 1, 'b': 2})
assert k1 == k2 # dict 順序不影響 cache key
# ═══════════════════════════════════════════════════════════════════════════
# T7: health_check 全失敗
# ═══════════════════════════════════════════════════════════════════════════
def test_health_check_all_fail_returns_all_false(monkeypatch):
monkeypatch.setenv('MCP_ROUTER_ENABLED', 'true')
from services.mcp_router import mcp_router
with patch('services.mcp_router.requests.get', side_effect=Exception('connection refused')):
results = mcp_router.health_check()
assert results == {'postgres': False, 'firecrawl': False,
'omnisearch': False, 'filesystem': False}
# ═══════════════════════════════════════════════════════════════════════════
# T8: 未知 server 直接拒絕
# ═══════════════════════════════════════════════════════════════════════════
def test_unknown_server_rejected(monkeypatch):
monkeypatch.setenv('MCP_ROUTER_ENABLED', 'true')
from services.mcp_router import mcp_router
with patch('services.mcp_router.requests.post') as mock_post:
# 統帥的 caller 在 registry但 server 名拼錯
result = mcp_router.call(
server='omnisearch_wrong', tool='tavily_search',
args={'query': 'x'}, caller='mcp_collector',
)
# 注意:白名單檢查在前,會先回 'tool not in registry'(因為 omnisearch_wrong 不在 registry
assert result.success is False
assert result.status == 'error'
mock_post.assert_not_called()