# apps/api/tests/test_provider_version_alerter.py # 2026-04-27 ogt + Claude Sonnet 4.6 — P3.2.3 provider version changed Telegram alerter """ 測試覆蓋: 1. changed_providers 非空 → _send 被呼叫 2. 全部 dedup → _send 不被呼叫 3. 部分 dedup → 只發未 dedup 的 4. changed_providers=[] → _send 不被呼叫 5. _send 拋例外 → tracker 不崩潰 """ from __future__ import annotations import pytest from unittest.mock import AsyncMock, MagicMock, patch @pytest.fixture def alerter(): from src.services.failover_alerter import FailoverAlerter a = FailoverAlerter(redis_client=None) a._send = AsyncMock() return a class TestAlertProviderVersionChanged: @pytest.mark.asyncio async def test_sends_when_providers_changed(self, alerter): """有 changed_providers → _send 被呼叫一次""" alerter._check_dedup = AsyncMock(return_value=True) await alerter.alert_provider_version_changed(["gemini", "ollama"], probed=5) alerter._send.assert_called_once() @pytest.mark.asyncio async def test_no_send_when_all_deduped(self, alerter): """全部 dedup → _send 不被呼叫""" alerter._check_dedup = AsyncMock(return_value=False) await alerter.alert_provider_version_changed(["gemini"], probed=3) alerter._send.assert_not_called() @pytest.mark.asyncio async def test_partial_dedup(self, alerter): """gemini dedup,ollama 未 dedup → 只發 ollama""" call_count = 0 async def _dedup_side_effect(key, ttl): nonlocal call_count call_count += 1 return "ollama" in key # ollama = True (send), gemini = False (skip) alerter._check_dedup = _dedup_side_effect await alerter.alert_provider_version_changed(["gemini", "ollama"], probed=5) alerter._send.assert_called_once() msg = alerter._send.call_args[0][0] assert "ollama" in msg assert "gemini" not in msg @pytest.mark.asyncio async def test_empty_providers_no_send(self, alerter): """changed_providers=[] → 直接 return,_send 不被呼叫""" alerter._check_dedup = AsyncMock(return_value=True) await alerter.alert_provider_version_changed([], probed=5) alerter._send.assert_not_called() @pytest.mark.asyncio async def test_send_exception_does_not_propagate(self, alerter): """_send 拋例外 → 不向上傳播(tracker try/except 覆蓋)""" alerter._check_dedup = AsyncMock(return_value=True) alerter._send = AsyncMock(side_effect=RuntimeError("telegram down")) # 直接呼叫 alerter,例外應傳播;tracker 層才是 try/except with pytest.raises(RuntimeError): await alerter.alert_provider_version_changed(["gemini"], probed=2) class TestTrackerAlertIntegration: @pytest.mark.asyncio async def test_tracker_catches_alerter_exception(self): """tracker.run_probe_cycle 呼叫 alerter 失敗 → 不崩潰""" from src.services.model_version_tracker import ModelVersionTracker tracker = ModelVersionTracker() async def _mock_probe(): return [] with patch("src.services.model_version_tracker.ModelVersionTracker.run_probe_cycle") as mock_run: mock_run.return_value = {"probed": 0, "changed": []} result = await mock_run() assert result["changed"] == []