# apps/api/tests/test_model_version_probe.py # 2026-04-27 P3.2.1 by Claude # 2026-05-03 ogt: ADR-110 GCP 三層容災,更新 probe URL 為 GCP-A Primary """ model_version_probe 單元測試 ============================== 測試覆蓋: - probe_ollama_version: 成功 / model not found / HTTP 錯誤 / timeout - probe_gemini_version: 成功 / API key 未設定 / HTTP 錯誤 - probe_claude_version: 成功 / API key 未設定 - probe_openclaw_nemo_version: 成功(找到 model) / 成功(model not in tags,graceful fallback) - probe_all_providers: 並行 + return_exceptions(部分失敗不 crash) 測試分類:unit(mock httpx + settings,無 DB / Redis 依賴) """ from __future__ import annotations from datetime import datetime, timedelta, timezone from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest from src.services.model_version_probe import ( ProviderVersionInfo, probe_all_providers, probe_claude_version, probe_gemini_version, probe_ollama_version, probe_openclaw_nemo_version, ) TAIPEI_TZ = timezone(timedelta(hours=8)) # ============================================================================= # Helpers # ============================================================================= def _mock_response(status_code: int, body: dict) -> MagicMock: resp = MagicMock(spec=httpx.Response) resp.status_code = status_code resp.json.return_value = body resp.raise_for_status = MagicMock() if status_code >= 400: resp.raise_for_status.side_effect = httpx.HTTPStatusError( f"HTTP {status_code}", request=MagicMock(), response=resp, ) return resp def _tags_body(models: list[dict]) -> dict: return {"models": models} # ============================================================================= # probe_ollama_version # ============================================================================= class TestProbeOllamaVersion: @pytest.mark.asyncio async def test_success_gcp_a_provider(self): """GCP-A URL → provider='ollama', digest 和 version 正確解析(ADR-110)""" model_entry = { "name": "qwen2.5:7b-instruct", "modified_at": "2026-04-01T00:00:00Z", "digest": "sha256:abc123", } resp = _mock_response(200, _tags_body([model_entry])) async def _fake_get(url, **kwargs): return resp mock_client = AsyncMock() mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) mock_client.get = AsyncMock(side_effect=_fake_get) with patch("httpx.AsyncClient", return_value=mock_client): info = await probe_ollama_version( "http://34.143.170.20:11434", "qwen2.5:7b-instruct" ) assert info.provider == "ollama" assert info.model == "qwen2.5:7b-instruct" assert info.version == "2026-04-01T00:00:00Z" assert info.digest == "sha256:abc123" assert isinstance(info.captured_at, datetime) @pytest.mark.asyncio async def test_success_local_provider(self): """111 / local proxy URL → provider='ollama_local'""" model_entry = { "name": "deepseek-r1:14b", "modified_at": "2026-04-02T00:00:00Z", "digest": "sha256:def456", } resp = _mock_response(200, _tags_body([model_entry])) mock_client = AsyncMock() mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) mock_client.get = AsyncMock(return_value=resp) with patch("httpx.AsyncClient", return_value=mock_client): info = await probe_ollama_version( "http://192.168.0.111:11434", "deepseek-r1:14b" ) assert info.provider == "ollama_local" @pytest.mark.asyncio async def test_model_not_found_raises(self): """model 不在清單 → ValueError""" resp = _mock_response(200, _tags_body([{"name": "other-model:7b", "modified_at": "", "digest": ""}])) mock_client = AsyncMock() mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) mock_client.get = AsyncMock(return_value=resp) with patch("httpx.AsyncClient", return_value=mock_client): with pytest.raises(ValueError, match="not found"): await probe_ollama_version( "http://34.143.170.20:11434", "qwen2.5:7b-instruct" ) @pytest.mark.asyncio async def test_http_error_propagates(self): """HTTP 500 → HTTPStatusError 上拋""" resp = _mock_response(500, {}) mock_client = AsyncMock() mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) mock_client.get = AsyncMock(return_value=resp) with patch("httpx.AsyncClient", return_value=mock_client): with pytest.raises(httpx.HTTPStatusError): await probe_ollama_version( "http://34.143.170.20:11434", "qwen2.5:7b-instruct" ) @pytest.mark.asyncio async def test_timeout_propagates(self): """連線 timeout → TimeoutException 上拋""" mock_client = AsyncMock() mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) mock_client.get = AsyncMock(side_effect=httpx.TimeoutException("timeout")) with patch("httpx.AsyncClient", return_value=mock_client): with pytest.raises(httpx.TimeoutException): await probe_ollama_version( "http://34.143.170.20:11434", "qwen2.5:7b-instruct" ) # ============================================================================= # probe_gemini_version # ============================================================================= class TestProbeGeminiVersion: @pytest.mark.asyncio async def test_success(self): """GEMINI_API_KEY 存在 + API 回傳 models → 解析第一個 gemini model""" body = { "models": [ { "name": "models/gemini-1.5-flash", "supportedGenerationMethods": ["generateContent"], }, ] } resp = _mock_response(200, body) mock_client = AsyncMock() mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) mock_client.get = AsyncMock(return_value=resp) mock_settings = MagicMock() mock_settings.GEMINI_API_KEY = "fake-key" with patch("src.services.model_version_probe.settings", mock_settings), \ patch("httpx.AsyncClient", return_value=mock_client): info = await probe_gemini_version() assert info.provider == "gemini" assert "gemini" in info.model assert info.digest is None mock_client.get.assert_awaited_once() _, kwargs = mock_client.get.await_args assert kwargs["headers"] == {"x-goog-api-key": "fake-key"} assert kwargs["params"] == {"pageSize": 50} assert "fake-key" not in mock_client.get.await_args.args[0] assert "fake-key" not in str(kwargs["params"]) @pytest.mark.asyncio async def test_missing_api_key_raises(self): """GEMINI_API_KEY 未設定 → RuntimeError""" mock_settings = MagicMock() mock_settings.GEMINI_API_KEY = "" with patch("src.services.model_version_probe.settings", mock_settings): with pytest.raises(RuntimeError, match="GEMINI_API_KEY"): await probe_gemini_version() @pytest.mark.asyncio async def test_http_error_propagates(self): """Gemini API 回 403 → HTTPStatusError""" resp = _mock_response(403, {}) mock_client = AsyncMock() mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) mock_client.get = AsyncMock(return_value=resp) mock_settings = MagicMock() mock_settings.GEMINI_API_KEY = "fake-key" with patch("src.services.model_version_probe.settings", mock_settings), \ patch("httpx.AsyncClient", return_value=mock_client): with pytest.raises(httpx.HTTPStatusError): await probe_gemini_version() # ============================================================================= # probe_claude_version # ============================================================================= class TestProbeClaudeVersion: @pytest.mark.asyncio async def test_success(self): """CLAUDE_API_KEY 存在 → 回傳 claude provider info""" mock_settings = MagicMock() mock_settings.CLAUDE_API_KEY = "sk-fake" with patch("src.services.model_version_probe.settings", mock_settings): info = await probe_claude_version() assert info.provider == "claude" assert "claude" in info.model assert info.version == info.model assert info.digest is None @pytest.mark.asyncio async def test_missing_api_key_raises(self): """CLAUDE_API_KEY 未設定 → RuntimeError""" mock_settings = MagicMock() mock_settings.CLAUDE_API_KEY = "" with patch("src.services.model_version_probe.settings", mock_settings): with pytest.raises(RuntimeError, match="CLAUDE_API_KEY"): await probe_claude_version() # ============================================================================= # probe_openclaw_nemo_version # ============================================================================= class TestProbeOpenclawNemoVersion: @pytest.mark.asyncio async def test_success_model_found(self): """model 在 /api/tags 清單 → 正確解析""" model_entry = { "name": "deepseek-r1:14b", "modified_at": "2026-04-03T00:00:00Z", "digest": "sha256:nemo999", } resp = _mock_response(200, _tags_body([model_entry])) mock_client = AsyncMock() mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) mock_client.get = AsyncMock(return_value=resp) mock_settings = MagicMock() mock_settings.OPENCLAW_DEFAULT_MODEL = "deepseek-r1:14b" mock_settings.OLLAMA_FALLBACK_URL = "http://192.168.0.111:11434" with patch("src.services.model_version_probe.settings", mock_settings), \ patch("httpx.AsyncClient", return_value=mock_client): info = await probe_openclaw_nemo_version() assert info.provider == "openclaw_nemo" assert info.model == "deepseek-r1:14b" assert info.digest == "sha256:nemo999" @pytest.mark.asyncio async def test_model_not_in_tags_graceful(self): """model 不在清單 → graceful fallback(不 raise,version=model name)""" resp = _mock_response(200, _tags_body([{"name": "other:7b", "modified_at": "", "digest": ""}])) mock_client = AsyncMock() mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) mock_client.get = AsyncMock(return_value=resp) mock_settings = MagicMock() mock_settings.OPENCLAW_DEFAULT_MODEL = "deepseek-r1:14b" mock_settings.OLLAMA_FALLBACK_URL = "http://192.168.0.111:11434" with patch("src.services.model_version_probe.settings", mock_settings), \ patch("httpx.AsyncClient", return_value=mock_client): info = await probe_openclaw_nemo_version() # 不應 raise,graceful 回傳 assert info.provider == "openclaw_nemo" assert info.version == "deepseek-r1:14b" assert info.digest is None @pytest.mark.asyncio async def test_missing_model_config_raises(self): """OPENCLAW_DEFAULT_MODEL 未設定 → RuntimeError""" mock_settings = MagicMock() mock_settings.OPENCLAW_DEFAULT_MODEL = "" with patch("src.services.model_version_probe.settings", mock_settings): with pytest.raises(RuntimeError, match="OPENCLAW_DEFAULT_MODEL"): await probe_openclaw_nemo_version() # ============================================================================= # probe_all_providers # ============================================================================= class TestProbeAllProviders: @pytest.mark.asyncio async def test_all_success(self): """5 個 provider 全部成功 → 回傳 5 筆 ProviderVersionInfo""" fake_results = [ ProviderVersionInfo(provider="ollama", model="qwen2.5:7b-instruct", version="v1"), ProviderVersionInfo(provider="ollama_local", model="qwen2.5:7b-instruct", version="v1"), ProviderVersionInfo(provider="gemini", model="gemini-1.5-flash", version="gemini-1.5-flash"), ProviderVersionInfo(provider="claude", model="claude-haiku-4-5-20251001", version="claude-haiku-4-5-20251001"), ProviderVersionInfo(provider="openclaw_nemo", model="deepseek-r1:14b", version="v1"), ] with patch("src.services.model_version_probe.probe_ollama_version", side_effect=[ fake_results[0], fake_results[1] ]), patch("src.services.model_version_probe.probe_gemini_version", return_value=fake_results[2]), \ patch("src.services.model_version_probe.probe_claude_version", return_value=fake_results[3]), \ patch("src.services.model_version_probe.probe_openclaw_nemo_version", return_value=fake_results[4]): mock_settings = MagicMock() mock_settings.OLLAMA_URL = "http://34.143.170.20:11434" # GCP-A(ADR-110) mock_settings.OLLAMA_FALLBACK_URL = "http://192.168.0.111:11434" mock_settings.OLLAMA_HEALTH_CHECK_MODEL = "qwen2.5:7b-instruct" with patch("src.services.model_version_probe.settings", mock_settings): results = await probe_all_providers() assert len(results) == 5 @pytest.mark.asyncio async def test_partial_failure_no_crash(self): """2 個 provider 失敗 → 只回傳成功的 3 筆,不 crash""" good = ProviderVersionInfo(provider="ollama", model="qwen2.5:7b-instruct", version="v1") async def _fail(): raise RuntimeError("simulated failure") async def _fail_ollama(url, model): if "111" in url: raise RuntimeError("local offline") return good with patch("src.services.model_version_probe.probe_ollama_version", side_effect=_fail_ollama), \ patch("src.services.model_version_probe.probe_gemini_version", side_effect=_fail), \ patch("src.services.model_version_probe.probe_claude_version", return_value=ProviderVersionInfo( provider="claude", model="claude-haiku-4-5-20251001", version="claude-haiku-4-5-20251001" )), \ patch("src.services.model_version_probe.probe_openclaw_nemo_version", return_value=ProviderVersionInfo( provider="openclaw_nemo", model="deepseek-r1:14b", version="v1" )): mock_settings = MagicMock() mock_settings.OLLAMA_URL = "http://34.143.170.20:11434" # GCP-A(ADR-110) mock_settings.OLLAMA_FALLBACK_URL = "http://192.168.0.111:11434" mock_settings.OLLAMA_HEALTH_CHECK_MODEL = "qwen2.5:7b-instruct" with patch("src.services.model_version_probe.settings", mock_settings): results = await probe_all_providers() # ollama(ok) + ollama_local(fail) + gemini(fail) + claude(ok) + openclaw_nemo(ok) → 3 assert len(results) == 3 providers = {r.provider for r in results} assert "ollama" in providers assert "claude" in providers assert "openclaw_nemo" in providers