Files
awoooi/apps/api/tests/test_failover_e2e_dispatch.py
Your Name 4111ea4f9f
All checks were successful
Code Review / ai-code-review (push) Successful in 12s
CD Pipeline / tests (push) Successful in 1m13s
CD Pipeline / build-and-deploy (push) Successful in 3m36s
CD Pipeline / post-deploy-checks (push) Successful in 1m20s
fix(ai): remove 188 ollama provider
2026-05-06 14:34:48 +08:00

363 lines
13 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.
# apps/api/tests/test_failover_e2e_dispatch.py | 2026-05-06 @ Asia/Taipei
# 2026-05-06 Codex — 188 不再作為 Ollama Provider驗證 ollama_local dispatch。
"""
E2Eexecutor dispatch 層驗證
===============================
測試覆蓋(補全 B4 — 整合測試只驗決策層,未驗執行層):
1. registry 確實有 ollama_local provider且沒有 ollama_188 provider
2. OllamaLocalProvider.is_enabled 在有 OLLAMA_FALLBACK_URL 時為 True
3. OllamaLocalProvider.is_enabled 在 OLLAMA_FALLBACK_URL 空字串時為 False
4. OllamaLocalProvider.analyze() 真的把 HTTP 打到 OLLAMA_FALLBACK_URL攔截 httpx
5. executor.execute(provider_order=["ollama_local"]) 真的路由到 local URL
6. Gemini quota pipeline 並行 5 次不超發B3 atomic 驗證)
7. Gemini quota TTL 第一次呼叫即設定
"""
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# =============================================================================
# B1registry 健全性
# =============================================================================
def test_registry_has_ollama_local_provider_without_ollama_188():
"""_init_registry() 後 registry 必須有 ollama_local且不得有 ollama_188"""
from src.services.ai_router import _init_registry
registry = _init_registry()
# registry.get() 只返回 is_enabled=True 的 provider
# 用 _providers dict 直接檢查(不管 is_enabled
assert "ollama_local" in registry._providers
assert "ollama_188" not in registry._providers
def test_ollama_local_provider_name():
"""OllamaLocalProvider.name == 'ollama_local'"""
from src.services.ai_providers.ollama import OllamaLocalProvider
p = OllamaLocalProvider()
assert p.name == "ollama_local"
def test_ollama_local_provider_privacy_level():
"""OllamaLocalProvider.privacy_level == 'local'(本地推理,可接機密資料)"""
from src.services.ai_providers.ollama import OllamaLocalProvider
p = OllamaLocalProvider()
assert p.privacy_level == "local"
# =============================================================================
# B1is_enabled 邏輯
# =============================================================================
def test_ollama_local_is_enabled_with_fallback_url(monkeypatch):
"""OLLAMA_FALLBACK_URL 有值 + ENABLE_OLLAMA_LOCAL 未設 → is_enabled == True"""
from src.services.ai_providers.ollama import OllamaLocalProvider
monkeypatch.setenv("ENABLE_OLLAMA_LOCAL", "true")
# patch settings 的 OLLAMA_FALLBACK_URL
mock_settings = MagicMock()
mock_settings.OLLAMA_FALLBACK_URL = "http://192.168.0.111:11434"
mock_settings.OPENCLAW_TIMEOUT = "60"
p = OllamaLocalProvider()
# 直接 patch module-level settings 物件
with patch("src.services.ai_providers.ollama.settings", mock_settings):
assert p.is_enabled is True
def test_ollama_local_is_disabled_without_fallback_url(monkeypatch):
"""OLLAMA_FALLBACK_URL 空字串 → is_enabled == Falselocal 節點未設定)"""
from src.services.ai_providers.ollama import OllamaLocalProvider
monkeypatch.setenv("ENABLE_OLLAMA_LOCAL", "true")
mock_settings = MagicMock()
mock_settings.OLLAMA_FALLBACK_URL = ""
p = OllamaLocalProvider()
with patch("src.services.ai_providers.ollama.settings", mock_settings):
assert p.is_enabled is False
def test_ollama_local_is_disabled_by_env_flag(monkeypatch):
"""ENABLE_OLLAMA_LOCAL=false → is_enabled == False即使有 URL"""
from src.services.ai_providers.ollama import OllamaLocalProvider
monkeypatch.setenv("ENABLE_OLLAMA_LOCAL", "false")
mock_settings = MagicMock()
mock_settings.OLLAMA_FALLBACK_URL = "http://192.168.0.111:11434"
p = OllamaLocalProvider()
with patch("src.services.ai_providers.ollama.settings", mock_settings):
assert p.is_enabled is False
# =============================================================================
# B4 核心HTTP dispatch 驗證
# =============================================================================
@pytest.mark.asyncio
async def test_ollama_local_analyze_dispatches_to_fallback_url():
"""
B4 核心OllamaLocalProvider.analyze() 必須把 HTTP 打到 OLLAMA_FALLBACK_URL。
攔截 httpx.AsyncClient.post記錄實際呼叫 URL斷言包含本地 fallback IP。
"""
from src.services.ai_providers.ollama import OllamaLocalProvider
FALLBACK_URL = "http://192.168.0.111:11434"
captured_urls: list[str] = []
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.raise_for_status = MagicMock()
mock_response.json = MagicMock(return_value={
"response": '{"action_title": "test", "confidence": 0.9}',
"eval_count": 10,
"prompt_eval_count": 5,
})
# httpx.AsyncClient.post 是 instance methodmock 需要接受 self
async def mock_post(self_client, url, **kwargs):
captured_urls.append(url)
return mock_response
mock_settings = MagicMock()
mock_settings.OLLAMA_FALLBACK_URL = FALLBACK_URL
mock_settings.OLLAMA_HEALTH_CHECK_MODEL = "qwen2.5:7b-instruct"
mock_settings.OPENCLAW_TIMEOUT = "60"
mock_settings.OLLAMA_DIAGNOSE_TIMEOUT_SECONDS = 200
# mock model_registry
mock_registry = MagicMock()
mock_registry.get_model = MagicMock(return_value="qwen2.5:7b-instruct")
mock_registry.get_provider_options = MagicMock(return_value={
"num_predict": 1024,
"temperature": 0.1,
"top_p": 0.9,
})
provider = OllamaLocalProvider()
with patch("src.services.ai_providers.ollama.settings", mock_settings):
with patch("src.services.ai_providers.ollama.get_model_registry", return_value=mock_registry):
import httpx
# patch httpx.AsyncClient.postclass-level適用所有 instance
with patch.object(httpx.AsyncClient, "post", new=mock_post):
result = await provider.analyze("test prompt", context={})
assert len(captured_urls) > 0, "analyze() 未發出任何 HTTP 請求"
assert any("192.168.0.111" in url for url in captured_urls), (
f"HTTP 請求未打到 local fallback實際 URL: {captured_urls}"
)
assert result.provider == "ollama_local"
@pytest.mark.asyncio
async def test_ollama_local_analyze_returns_error_when_no_fallback_url():
"""OLLAMA_FALLBACK_URL 未設定 → analyze() 應返回 success=False不發 HTTP"""
from src.services.ai_providers.ollama import OllamaLocalProvider
mock_settings = MagicMock()
mock_settings.OLLAMA_FALLBACK_URL = ""
provider = OllamaLocalProvider()
with patch("src.services.ai_providers.ollama.settings", mock_settings):
result = await provider.analyze("test prompt")
assert result.success is False
assert result.provider == "ollama_local"
assert "OLLAMA_FALLBACK_URL" in (result.error or "")
@pytest.mark.asyncio
async def test_executor_dispatches_ollama_local_to_fallback_url():
"""
B4 執行層AIRouterExecutor.execute(provider_order=["ollama_local"])
應路由到 OllamaLocalProvider且 HTTP 打到 OLLAMA_FALLBACK_URL。
"""
from src.services.ai_router import AIProviderRegistry, AIRouterExecutor, reset_ai_router
from src.services.ai_providers.ollama import OllamaLocalProvider
from src.services.ai_providers.interfaces import AIResult
reset_ai_router()
FALLBACK_URL = "http://192.168.0.111:11434"
captured_urls: list[str] = []
# 建立真實 registry只登錄 ollama_local
registry = AIProviderRegistry()
# mock analyze 讓它回傳成功,但驗 URL 路徑
async def fake_analyze(prompt, context=None):
captured_urls.append(f"{FALLBACK_URL}/api/generate")
return AIResult(
raw_response='{"action_title":"ok","confidence":0.9}',
success=True,
provider="ollama_local",
tokens=10,
)
mock_settings_global = MagicMock()
mock_settings_global.OLLAMA_FALLBACK_URL = FALLBACK_URL
# 建立 OllamaLocalProvidermock 其 analyze + is_enabled
provider = OllamaLocalProvider()
provider.analyze = fake_analyze # type: ignore[method-assign]
# 強制 is_enabled = True繞過 settings patch 的複雜度)
type(provider).is_enabled = property(lambda self: True)
registry.register(provider)
executor = AIRouterExecutor(registry)
# mock Redis不依賴真實 Redis
mock_redis = AsyncMock()
mock_redis.get = AsyncMock(return_value=None)
mock_redis.set = AsyncMock(return_value=True)
with patch("src.core.redis_client.get_redis", return_value=mock_redis):
with patch("src.services.ai_router._settings") as mock_settings:
mock_settings.MOCK_MODE = False
result = await executor.execute(
prompt="test alert",
provider_order=["ollama_local"],
context={},
)
assert result.success is True, f"execute 失敗: {result.error}"
assert result.provider == "ollama_local", f"provider 不是 ollama_local: {result.provider}"
assert any("192.168.0.111" in u for u in captured_urls), (
f"HTTP 未打到 local fallbackcaptured: {captured_urls}"
)
# =============================================================================
# B3Gemini quota atomic pipeline 驗證
# =============================================================================
@pytest.mark.asyncio
async def test_gemini_quota_concurrent_no_overshoot():
"""
B3 atomic 驗證5 個並行呼叫 _check_gemini_quota()quota=5。
pipeline 原子遞增 → counter 嚴格等於 5不超發
第 6 次呼叫應返回 False。
"""
from src.services.ollama_failover_manager import OllamaFailoverManager
from src.services.ollama_health_monitor import OllamaHealthMonitor
# 用真正的 in-memory counter 模擬 Redis pipeline
_store: dict[str, int] = {}
def make_mock_redis():
redis = MagicMock()
class FakePipeline:
def __init__(self):
self._key = None
self._nx_val = 0
self._ex = None
def set(self, key, val, ex=None, nx=False):
self._key = key
self._nx_val = val
self._ex = ex
return self
def incr(self, key):
self._key = key
return self
async def execute(self):
key = self._key
# NX set: only if not exists
if key not in _store:
_store[key] = self._nx_val
# INCR
_store[key] = _store.get(key, 0) + 1
new_val = _store[key]
return [True, new_val]
redis.pipeline = MagicMock(return_value=FakePipeline())
return redis
mock_settings = MagicMock()
mock_settings.GEMINI_DAILY_QUOTA = 5
mock_monitor = MagicMock(spec=OllamaHealthMonitor)
manager = OllamaFailoverManager(health_monitor=mock_monitor)
manager._settings = mock_settings
call_count = 0
async def patched_check():
nonlocal call_count
mock_redis = make_mock_redis()
with patch("src.core.redis_client.get_redis", return_value=mock_redis):
return await manager._check_gemini_quota()
# 5 個並行呼叫quota=5每個都應返回 True
results = await asyncio.gather(*[patched_check() for _ in range(5)])
assert all(results), f"5 個並行呼叫中有失敗: {results}"
# 第 6 次(超出 quota應返回 False
# 重置 store 到 quota 值,模擬已滿
_store.clear()
for _ in range(5):
await patched_check()
result_6 = await patched_check()
assert result_6 is False, f"第 6 次超出 quota 應返回 False實際: {result_6}"
@pytest.mark.asyncio
async def test_gemini_quota_ttl_set_atomically():
"""
B3 TTL 驗證:第一次呼叫 _check_gemini_quota() 後,
pipeline 的 SET NX 應已設定 TTL不依賴分開的 EXPIRE
"""
from src.services.ollama_failover_manager import OllamaFailoverManager
from src.services.ollama_health_monitor import OllamaHealthMonitor
set_calls: list[dict] = []
class CapturingPipeline:
def set(self, key, val, ex=None, nx=False):
set_calls.append({"key": key, "val": val, "ex": ex, "nx": nx})
return self
def incr(self, key):
return self
async def execute(self):
return [True, 1]
mock_redis = MagicMock()
mock_redis.pipeline = MagicMock(return_value=CapturingPipeline())
mock_settings = MagicMock()
mock_settings.GEMINI_DAILY_QUOTA = 1000
mock_monitor = MagicMock(spec=OllamaHealthMonitor)
manager = OllamaFailoverManager(health_monitor=mock_monitor)
manager._settings = mock_settings
with patch("src.core.redis_client.get_redis", return_value=mock_redis):
await manager._check_gemini_quota()
assert len(set_calls) == 1, f"pipeline.set() 應被呼叫一次,實際: {len(set_calls)}"
call = set_calls[0]
assert call["nx"] is True, "SET 必須帶 NX=True只首次設定"
assert call["ex"] == 86400, f"TTL 必須 86400s實際: {call['ex']}"
assert call["ex"] is not None, "TTL 必須在 SET 時設定,不能分開 EXPIREB3 修復驗證)"