# apps/api/tests/test_failover_e2e_dispatch.py | 2026-05-06 @ Asia/Taipei # 2026-05-06 Codex — 188 不再作為 Ollama Provider;驗證 ollama_local dispatch。 """ E2E:executor 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 # ============================================================================= # B1:registry 健全性 # ============================================================================= 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" # ============================================================================= # B1:is_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 == False(local 節點未設定)""" 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 method,mock 需要接受 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.post(class-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_providers.interfaces import AIResult from src.services.ai_providers.ollama import OllamaLocalProvider from src.services.ai_router import ( AIProviderRegistry, AIRouterExecutor, reset_ai_router, ) 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 # 建立 OllamaLocalProvider,mock 其 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 fallback,captured: {captured_urls}" ) # ============================================================================= # B3:Gemini selection preflight is read-only; generation owns atomic quota # ============================================================================= @pytest.mark.asyncio async def test_gemini_quota_concurrent_no_overshoot(): """Concurrent route checks read unified stats and never reserve paid quota.""" from src.services.ollama_failover_manager import OllamaFailoverManager from src.services.ollama_health_monitor import OllamaHealthMonitor mock_monitor = MagicMock(spec=OllamaHealthMonitor) manager = OllamaFailoverManager(health_monitor=mock_monitor) limiter = MagicMock() limiter.get_usage_stats = AsyncMock(return_value={ "daily_requests": {"current": 4, "limit": 5}, "daily_tokens": {"current": 100, "reserved": 0, "limit": 1000}, "daily_cost_usd": {"current": 0.01, "reserved": 0.0, "limit": 1.0}, "cost_exceeded": False, }) with patch("src.services.ai_rate_limiter.get_ai_rate_limiter", return_value=limiter): results = await asyncio.gather(*[manager._check_gemini_quota() for _ in range(5)]) assert all(results) assert limiter.get_usage_stats.await_count == 5 assert not hasattr(limiter, "reserve_generation") or limiter.reserve_generation.call_count == 0 def test_gemini_reservation_receipt_and_identity_are_durable(): """The atomic reservation must not reopen a paid call through TTL expiry.""" from src.services.ai_rate_limiter import _RESERVE_GENERATION_LUA assert "redis.call('HSET', KEYS[9]" in _RESERVE_GENERATION_LUA assert "redis.call('EXPIRE', KEYS[9]" not in _RESERVE_GENERATION_LUA assert "redis.call('EXPIRE', KEYS[10]" not in _RESERVE_GENERATION_LUA assert "redis.call('INCR', KEYS[1])" in _RESERVE_GENERATION_LUA assert "redis.call('INCR', KEYS[2])" in _RESERVE_GENERATION_LUA