# apps/api/tests/test_ollama_auto_recovery.py | 2026-04-25 @ Asia/Taipei # Created 2026-04-25 P1.1d by Claude Engineer-C # 2026-04-25 統帥指令 by Claude Engineer-C — 自動切 Gemini + 自動恢復 # 2026-05-03 ogt: ADR-110 GCP 三層容災,URL_111 改名為 URL_GCP_A """ OllamaAutoRecoveryService 單元測試 - P1.1d ========================================== 測試覆蓋: 1. 111 OFFLINE → HEALTHY × 3 → 觸發 _switch_back_to_ollama 2. 111 OFFLINE → HEALTHY × 2 → OFFLINE → counter 歸零 3. 中途 DEGRADED/OFFLINE → counter 歸零;SLOW 視為可用(GCP 高負載仍優於 Gemini) 4. stop() 優雅取消 task 5. start() 重複呼叫不重複建立 task 6. _switch_back_to_ollama:clear_cache + notify_recovery + Telegram alerter 7. alerter = None 時不 crash 8. clear_cache 失敗時不 crash(best-effort) 9. set_current_primary / is_running / consecutive_healthy 屬性 測試分類:unit(mock OllamaHealthMonitor + OllamaFailoverManager) """ from __future__ import annotations import asyncio from unittest.mock import AsyncMock, MagicMock, call, patch import pytest from src.services.ollama_auto_recovery import ( OllamaAutoRecoveryService, get_ollama_auto_recovery_service, reset_ollama_auto_recovery_service, ) from src.services.ollama_health_monitor import HealthReport, HealthStatus # ============================================================================= # Fixtures / Helpers # ============================================================================= URL_GCP_A = "http://34.143.170.20:11434" # GCP-A Primary(ADR-110 2026-05-03) URL_111 = URL_GCP_A # 向下相容別名(保留舊名,對應 settings.OLLAMA_URL) @pytest.fixture(autouse=True) def reset_singleton(): yield reset_ollama_auto_recovery_service() def _make_health(status: HealthStatus) -> HealthReport: return HealthReport(status=status, host=URL_111, latency_ms=500.0) def _make_service( *, current_primary: str = "gemini", stable_count: int = 3, check_interval: int = 1, # 測試用短 interval alerter=None, ) -> tuple[OllamaAutoRecoveryService, AsyncMock, MagicMock]: """ 建立 service + mock monitor + mock failover manager。 返回 (service, mock_monitor, mock_failover_manager) """ mock_monitor = MagicMock() mock_monitor.check = AsyncMock() mock_failover = MagicMock() mock_failover.clear_cache = AsyncMock() mock_failover.notify_recovery = MagicMock() mock_settings = MagicMock() mock_settings.OLLAMA_URL = URL_111 svc = OllamaAutoRecoveryService( health_monitor=mock_monitor, failover_manager=mock_failover, telegram_alerter=alerter, recovery_check_interval_sec=check_interval, stable_count_required=stable_count, ) svc._settings = mock_settings svc._current_primary = current_primary return svc, mock_monitor, mock_failover # ============================================================================= # _check_and_recover():核心防抖邏輯 # ============================================================================= class TestCheckAndRecover: """_check_and_recover 單次執行邏輯""" @pytest.mark.asyncio async def test_healthy_increments_counter(self): """HEALTHY → counter +1""" svc, mock_monitor, _ = _make_service(current_primary="gemini") mock_monitor.check.return_value = _make_health(HealthStatus.HEALTHY) await svc._check_and_recover() assert svc.consecutive_healthy == 1 @pytest.mark.asyncio async def test_non_healthy_resets_counter(self): """OFFLINE → counter 歸零""" svc, mock_monitor, _ = _make_service(current_primary="gemini") svc._consecutive_healthy = 2 mock_monitor.check.return_value = _make_health(HealthStatus.OFFLINE) await svc._check_and_recover() assert svc.consecutive_healthy == 0 @pytest.mark.asyncio async def test_slow_increments_counter(self): """SLOW → counter +1(GCP 高負載仍優於 Gemini fallback)""" svc, mock_monitor, _ = _make_service(current_primary="gemini") svc._consecutive_healthy = 1 mock_monitor.check.return_value = _make_health(HealthStatus.SLOW) await svc._check_and_recover() assert svc.consecutive_healthy == 2 @pytest.mark.asyncio async def test_degraded_resets_counter(self): """DEGRADED → counter 歸零""" svc, mock_monitor, _ = _make_service(current_primary="gemini") svc._consecutive_healthy = 2 mock_monitor.check.return_value = _make_health(HealthStatus.DEGRADED) await svc._check_and_recover() assert svc.consecutive_healthy == 0 @pytest.mark.asyncio async def test_three_healthy_triggers_switch_back(self): """連續 3 次 HEALTHY + current_primary=gemini → 觸發切回""" svc, mock_monitor, mock_failover = _make_service(current_primary="gemini", stable_count=3) mock_monitor.check.return_value = _make_health(HealthStatus.HEALTHY) svc._consecutive_healthy = 2 # 已有 2 次,本次第 3 次 with patch.object(svc, "_switch_back_to_ollama", new_callable=AsyncMock) as mock_switch: await svc._check_and_recover() mock_switch.assert_awaited_once() @pytest.mark.asyncio async def test_two_healthy_not_yet_switch(self): """連續 2 次 HEALTHY(未達 3 次門檻)→ 不觸發切回""" svc, mock_monitor, _ = _make_service(current_primary="gemini", stable_count=3) mock_monitor.check.return_value = _make_health(HealthStatus.HEALTHY) svc._consecutive_healthy = 1 # 已有 1 次,本次第 2 次 with patch.object(svc, "_switch_back_to_ollama", new_callable=AsyncMock) as mock_switch: await svc._check_and_recover() mock_switch.assert_not_awaited() assert svc.consecutive_healthy == 2 @pytest.mark.asyncio async def test_already_ollama_primary_no_switch(self): """current_primary 已是 ollama → 不觸發切回(避免重複)""" svc, mock_monitor, _ = _make_service(current_primary="ollama", stable_count=3) mock_monitor.check.return_value = _make_health(HealthStatus.HEALTHY) svc._consecutive_healthy = 2 with patch.object(svc, "_switch_back_to_ollama", new_callable=AsyncMock) as mock_switch: await svc._check_and_recover() mock_switch.assert_not_awaited() @pytest.mark.asyncio async def test_check_exception_resets_counter(self): """health check 拋異常 → counter 歸零,不 crash""" svc, mock_monitor, _ = _make_service(current_primary="gemini") mock_monitor.check.side_effect = RuntimeError("network error") svc._consecutive_healthy = 2 # 不應 raise await svc._check_and_recover() assert svc.consecutive_healthy == 0 # ============================================================================= # 防抖場景:OFFLINE → HEALTHY × 2 → OFFLINE → counter 歸零 # ============================================================================= class TestDebounce: """防抖機制:中途斷線 → counter 歸零""" @pytest.mark.asyncio async def test_counter_resets_on_intermittent_offline(self): """ 模擬:HEALTHY × 2 → OFFLINE → HEALTHY → counter 應從 1 重新開始 """ svc, mock_monitor, _ = _make_service(current_primary="gemini", stable_count=3) statuses = [ HealthStatus.HEALTHY, HealthStatus.HEALTHY, HealthStatus.OFFLINE, # 中途斷線 → counter 歸零 HealthStatus.HEALTHY, # 重新開始 ] mock_monitor.check.side_effect = [_make_health(s) for s in statuses] with patch.object(svc, "_switch_back_to_ollama", new_callable=AsyncMock) as mock_switch: for _ in range(4): await svc._check_and_recover() # 總計 3 次 HEALTHY(第 1、2、4 次),但第 3 次斷線 → counter 歸零 # 第 4 次後 counter=1,未達門檻 mock_switch.assert_not_awaited() assert svc.consecutive_healthy == 1 @pytest.mark.asyncio async def test_full_recovery_flow(self): """ 完整場景:OFFLINE → HEALTHY × 3 → 觸發 switch_back """ mock_alerter = AsyncMock() mock_alerter.alert_recovery = AsyncMock() svc, mock_monitor, mock_failover = _make_service( current_primary="gemini", stable_count=3, alerter=mock_alerter ) mock_monitor.check.side_effect = [ _make_health(HealthStatus.HEALTHY), _make_health(HealthStatus.HEALTHY), _make_health(HealthStatus.HEALTHY), ] for _ in range(3): await svc._check_and_recover() # 觸發後 current_primary 應切回 ollama assert svc.current_primary == "ollama" mock_failover.clear_cache.assert_awaited_once() # 2026-05-03 ogt: ADR-110 — notify_recovery 改為 "ollama_gcp_a"(GCP-A Primary) mock_failover.notify_recovery.assert_called_once_with("ollama_gcp_a") # ============================================================================= # _switch_back_to_ollama() # ============================================================================= class TestSwitchBackToOllama: """_switch_back_to_ollama 切回邏輯""" @pytest.mark.asyncio async def test_sets_current_primary_to_ollama(self): svc, _, _ = _make_service(current_primary="gemini") await svc._switch_back_to_ollama() assert svc.current_primary == "ollama" @pytest.mark.asyncio async def test_calls_clear_cache(self): svc, _, mock_failover = _make_service(current_primary="gemini") await svc._switch_back_to_ollama() mock_failover.clear_cache.assert_awaited_once() @pytest.mark.asyncio async def test_calls_notify_recovery(self): svc, _, mock_failover = _make_service(current_primary="gemini") await svc._switch_back_to_ollama() # 2026-05-03 ogt: ADR-110 — notify_recovery 改為 "ollama_gcp_a"(GCP-A Primary) mock_failover.notify_recovery.assert_called_once_with("ollama_gcp_a") @pytest.mark.asyncio async def test_calls_telegram_alerter_when_set(self): """alerter 已設定 → 呼叫 alert_recovery""" mock_alerter = AsyncMock() mock_alerter.alert_recovery = AsyncMock() svc, _, _ = _make_service(current_primary="gemini", alerter=mock_alerter) svc._consecutive_healthy = 3 await svc._switch_back_to_ollama() mock_alerter.alert_recovery.assert_awaited_once() payload = mock_alerter.alert_recovery.call_args[0][0] assert payload["from"] == "gemini" assert payload["to"] == "ollama" # 2026-05-03 ogt: ADR-110 — "to" 改為 "ollama"(不再指定 111) assert payload["stable_count"] == 3 @pytest.mark.asyncio async def test_no_alerter_does_not_crash(self): """alerter = None → 不 crash""" svc, _, _ = _make_service(current_primary="gemini", alerter=None) # 不應 raise await svc._switch_back_to_ollama() assert svc.current_primary == "ollama" @pytest.mark.asyncio async def test_clear_cache_failure_does_not_crash(self): """clear_cache 失敗 → 靜默繼續,不 crash""" svc, _, mock_failover = _make_service(current_primary="gemini") mock_failover.clear_cache.side_effect = RuntimeError("Redis down") # 不應 raise await svc._switch_back_to_ollama() # 儘管 clear_cache 失敗,current_primary 應已更新 assert svc.current_primary == "ollama" @pytest.mark.asyncio async def test_alerter_failure_does_not_crash(self): """Telegram alerter 失敗 → 靜默繼續,不 crash""" mock_alerter = AsyncMock() mock_alerter.alert_recovery = AsyncMock(side_effect=RuntimeError("TG timeout")) svc, _, _ = _make_service(current_primary="gemini", alerter=mock_alerter) # 不應 raise await svc._switch_back_to_ollama() assert svc.current_primary == "ollama" # ============================================================================= # start() / stop() 生命週期 # ============================================================================= class TestLifecycle: """start() / stop() 背景任務生命週期""" @pytest.mark.asyncio async def test_start_creates_task(self): svc, _, _ = _make_service() try: await svc.start() assert svc.is_running is True finally: await svc.stop() @pytest.mark.asyncio async def test_stop_cancels_task(self): svc, _, _ = _make_service() await svc.start() assert svc.is_running is True await svc.stop() assert svc.is_running is False @pytest.mark.asyncio async def test_stop_idempotent_when_not_started(self): """未 start 的情況下呼叫 stop → 不 crash""" svc, _, _ = _make_service() await svc.stop() # 不應 raise @pytest.mark.asyncio async def test_start_idempotent_second_call(self): """重複呼叫 start() → 不重複建立 task""" svc, _, _ = _make_service() try: await svc.start() task_1 = svc._task await svc.start() # 第二次呼叫 task_2 = svc._task assert task_1 is task_2 # 同一個 task finally: await svc.stop() @pytest.mark.asyncio async def test_monitor_loop_continues_after_exception(self): """ _monitor_loop 內部拋異常 → 繼續監控(不 break) 模擬:第一次 check 拋異常,第二次正常。 """ svc, mock_monitor, _ = _make_service( current_primary="gemini", check_interval=0, # 0s interval,測試快速 stable_count=3, ) call_count = [0] async def _check_side_effect(host): call_count[0] += 1 if call_count[0] == 1: raise RuntimeError("transient error") return _make_health(HealthStatus.HEALTHY) mock_monitor.check.side_effect = _check_side_effect # 讓 loop 跑幾次 await svc.start() await asyncio.sleep(0.05) await svc.stop() # 至少被呼叫 2 次(第一次異常,第二次正常) assert call_count[0] >= 2 # ============================================================================= # set_current_primary() / 屬性 # ============================================================================= class TestStateManagement: """set_current_primary + 屬性""" @pytest.mark.asyncio async def test_set_current_primary_updates_state(self): # H5+H6 修復:set_current_primary 改為 async(需 await) svc, _, _ = _make_service(current_primary="ollama") with patch("src.core.redis_client.get_redis", side_effect=RuntimeError("no redis")): await svc.set_current_primary("gemini") assert svc.current_primary == "gemini" @pytest.mark.asyncio async def test_set_current_primary_non_ollama_resets_counter(self): """切換到非 Ollama 時,counter 歸零,開始等待恢復""" svc, _, _ = _make_service(current_primary="ollama") svc._consecutive_healthy = 5 with patch("src.core.redis_client.get_redis", side_effect=RuntimeError("no redis")): await svc.set_current_primary("gemini") assert svc.consecutive_healthy == 0 @pytest.mark.asyncio async def test_set_current_primary_to_ollama_no_counter_reset(self): """切換到 ollama(正常路由)→ counter 不重置(直接標記)""" svc, _, _ = _make_service(current_primary="gemini") svc._consecutive_healthy = 3 with patch("src.core.redis_client.get_redis", side_effect=RuntimeError("no redis")): await svc.set_current_primary("ollama") # set_current_primary 只在切到非 ollama 時 reset counter assert svc.current_primary == "ollama" def test_is_running_false_before_start(self): svc, _, _ = _make_service() assert svc.is_running is False # ============================================================================= # H5+H6: Redis 持久化 + Bootstrap + 立刻 check # 2026-04-25 critic-fix Part2 by Claude Engineer-C2 # ============================================================================= class TestRedisPersistence: """H5+H6 修復驗證:set_current_primary 持久化 + start() bootstrap""" @pytest.mark.asyncio async def test_set_current_primary_persists_to_redis(self): """set_current_primary("gemini") → Phase A 雙寫新舊 Redis key""" svc, _, _ = _make_service(current_primary="ollama") mock_redis = AsyncMock() mock_redis.set = AsyncMock() with patch("src.core.redis_client.get_redis", return_value=mock_redis): await svc.set_current_primary("gemini") mock_redis.set.assert_has_awaits( [ call("platform:ollama:current_primary", "gemini"), call("ollama:current_primary", "gemini"), ] ) @pytest.mark.asyncio async def test_set_current_primary_same_value_no_redis_write(self): """set_current_primary("gemini") 但 current_primary 已是 gemini → 不重複寫 Redis""" svc, _, _ = _make_service(current_primary="gemini") mock_redis = AsyncMock() mock_redis.set = AsyncMock() with patch("src.core.redis_client.get_redis", return_value=mock_redis): await svc.set_current_primary("gemini") # 相同值不觸發 persist mock_redis.set.assert_not_awaited() @pytest.mark.asyncio async def test_load_primary_from_redis(self): """_load_primary() 從 Redis 讀取,返回正確值""" svc, _, _ = _make_service() mock_redis = AsyncMock() mock_redis.get = AsyncMock(return_value=b"gemini") with patch("src.core.redis_client.get_redis", return_value=mock_redis): result = await svc._load_primary() assert result == "gemini" @pytest.mark.asyncio async def test_load_primary_redis_empty_returns_ollama(self): """Redis 無值 → 預設返回 'ollama'""" svc, _, _ = _make_service() mock_redis = AsyncMock() mock_redis.get = AsyncMock(return_value=None) with patch("src.core.redis_client.get_redis", return_value=mock_redis): result = await svc._load_primary() assert result == "ollama" @pytest.mark.asyncio async def test_load_primary_redis_error_returns_ollama(self): """Redis 掛掉 → 預設返回 "ollama"(fail-safe)""" svc, _, _ = _make_service() with patch("src.core.redis_client.get_redis", side_effect=RuntimeError("Redis down")): result = await svc._load_primary() assert result == "ollama" @pytest.mark.asyncio async def test_start_loads_primary_from_redis(self): """start() 從 Redis bootstrap current_primary""" svc, mock_monitor, _ = _make_service(current_primary="ollama") mock_monitor.check = AsyncMock(return_value=_make_health(HealthStatus.HEALTHY)) mock_redis = AsyncMock() mock_redis.get = AsyncMock(return_value=b"gemini") with patch("src.core.redis_client.get_redis", return_value=mock_redis): try: await svc.start() # bootstrap 後 current_primary 應為 Redis 讀到的值 assert svc.current_primary == "gemini" finally: await svc.stop() @pytest.mark.asyncio async def test_start_immediate_check_when_primary_not_ollama(self): """start() 時 primary=gemini → 立刻執行一次 _check_and_recover(不等 30s)""" svc, mock_monitor, _ = _make_service(current_primary="gemini") mock_monitor.check = AsyncMock(return_value=_make_health(HealthStatus.HEALTHY)) mock_redis = AsyncMock() mock_redis.get = AsyncMock(return_value=b"gemini") check_called = [False] original_check = svc._check_and_recover async def _spy_check(): check_called[0] = True await original_check() with patch.object(svc, "_check_and_recover", side_effect=_spy_check), \ patch("src.core.redis_client.get_redis", return_value=mock_redis): try: await svc.start() assert check_called[0] is True finally: await svc.stop() @pytest.mark.asyncio async def test_start_no_immediate_check_when_primary_is_ollama(self): """start() 時 primary=ollama(正常狀態)→ 不立刻執行 check""" svc, mock_monitor, _ = _make_service(current_primary="gemini") mock_redis = AsyncMock() mock_redis.get = AsyncMock(return_value=b"ollama") # Redis 說 primary=ollama check_called = [False] async def _spy_check(): check_called[0] = True with patch.object(svc, "_check_and_recover", side_effect=_spy_check), \ patch("src.core.redis_client.get_redis", return_value=mock_redis): try: await svc.start() # primary=ollama → 不立刻 check assert check_called[0] is False finally: await svc.stop() # ============================================================================= # Singleton # ============================================================================= def test_singleton_returns_same_instance(): s1 = get_ollama_auto_recovery_service() s2 = get_ollama_auto_recovery_service() assert s1 is s2 def test_reset_singleton_gives_new_instance(): s1 = get_ollama_auto_recovery_service() reset_ollama_auto_recovery_service() s2 = get_ollama_auto_recovery_service() assert s1 is not s2