Files
awoooi/apps/api/tests/test_model_version_tracker.py
Your Name ed205489c1
Some checks failed
CD Pipeline / build-and-deploy (push) Failing after 1m20s
feat(p3.2-tests+ci-schema): model_version 測試 + CI test_schema 對齊 + Grafana SLO Dashboard
P3.2 配套測試 + CI 環境同步 + ADR-100 Grafana 視覺化:

CI test_schema 補齊(解 1162-1172 阻塞之延伸):
- setup_test_schema.sql 加 ai_provider_version_history 表
- 對齊 production p3_2_provider_version_history.sql(已 K8s exec 上線)

新增測試 (636 行):
- test_model_version_probe.py (387) — Provider 探測單元測試
- test_model_version_tracker.py (249) — Tracker 整合測試
  · 4 個 DB-dependent tests 標 @pytest.mark.integration
  · 15 unit + 4 integration(unit step 跳過 integration class)

新增配套:
- ai-slo-dashboard.json (496 行) — Grafana 儀表板
  · 對應 ADR-100 SLO 規則的 4 大面板:
    自主修復成功率 / 飛輪閉環延遲 / 治理事件 / Provider 健康度

修改:
- governance_agent.py +122 行 — SLO 指標暴露 + retrieve metric 整合

Tests: 15 passed (probe + tracker unit), 4 deselected (integration class)

Production 部署狀態:
- p2_decision_fusion_columns.sql  K8s exec 完成(commit c58bdd0c)
- p3_2_provider_version_history.sql  K8s exec 完成(this commit)
- 兩個 production migration 都已上線,CI test_schema 同步補齊

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:57:16 +08:00

250 lines
8.2 KiB
Python
Raw 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_model_version_tracker.py
# 2026-04-27 P3.2.2 by Claude
"""
ModelVersionTracker 單元測試
==============================
測試覆蓋:
- 第一次寫入5 row全部 changed=Trueprev_version=None
- 同樣資料重入5 row全部 changed=False
- digest 變更:該 provider changed=True其餘 changed=False
- run_probe_cycle 回傳 dict 格式正確
- probe_all_providers 拋例外 → tracker 不 crash
測試分類unitmock DB session + probe_all_providers無實際 DB 依賴)
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.services.model_version_probe import ProviderVersionInfo
from src.services.model_version_tracker import ModelVersionTracker
TAIPEI_TZ = timezone(timedelta(hours=8))
# =============================================================================
# Helpers
# =============================================================================
def _make_info(provider: str, version: str = "v1", digest: str | None = "sha256:abc") -> ProviderVersionInfo:
return ProviderVersionInfo(
provider=provider,
model=f"model-{provider}",
version=version,
digest=digest,
captured_at=datetime.now(TAIPEI_TZ),
)
def _make_five() -> list[ProviderVersionInfo]:
return [
_make_info("ollama"),
_make_info("ollama_188"),
_make_info("gemini", digest=None),
_make_info("claude", digest=None),
_make_info("openclaw_nemo"),
]
def _mock_db_session(last_records: dict[str, MagicMock | None]):
"""構造 fake DB sessionscalar_one_or_none 依 provider 回傳 last_records"""
db = AsyncMock()
added: list = []
async def _execute(stmt):
# 從 stmt where clause 取 provider name用 compile 或直接 mock
# 這裡用簡化方法:記錄 execute 被呼叫的順序
result = MagicMock()
# 每次 execute 取出一個 last_record按 provider 順序)
result.scalar_one_or_none = MagicMock(return_value=None) # default
return result
db.execute = AsyncMock(side_effect=_execute)
db.add = MagicMock(side_effect=lambda obj: added.append(obj))
db.commit = AsyncMock()
db._added = added
return db
# =============================================================================
# Test Cases
# =============================================================================
@pytest.mark.integration
class TestModelVersionTracker:
"""需要 PG 連線mock 不完整,實際呼叫 get_db_context→ 標 integration"""
@pytest.mark.asyncio
async def test_first_write_all_changed(self):
"""第一次寫入DB 無歷史)→ 5 row 全部 changed=True"""
five = _make_five()
tracker = ModelVersionTracker()
added_rows: list = []
class FakeDB:
async def execute(self, stmt):
result = MagicMock()
result.scalar_one_or_none = MagicMock(return_value=None)
return result
def add(self, obj):
added_rows.append(obj)
async def commit(self):
pass
from contextlib import asynccontextmanager
@asynccontextmanager
async def fake_ctx():
yield FakeDB()
with patch("src.services.model_version_tracker.probe_all_providers", return_value=five), \
patch("src.services.model_version_tracker.get_db_context", fake_ctx):
result = await tracker.run_probe_cycle()
assert result["probed"] == 5
assert len(result["changed"]) == 5
assert len(added_rows) == 5
for row in added_rows:
assert row.changed is True
assert row.prev_version is None
@pytest.mark.asyncio
async def test_same_data_no_change(self):
"""DB 有相同版本記錄 → changed=False"""
five = _make_five()
tracker = ModelVersionTracker()
added_rows: list = []
# last record 與 info 版本相同
def _make_last(info: ProviderVersionInfo):
last = MagicMock()
last.version = info.version
last.digest = info.digest
return last
lasts = {info.provider: _make_last(info) for info in five}
call_idx = [0]
class FakeDB:
async def execute(self, stmt):
result = MagicMock()
# 依順序回傳對應 provider 的 last record
info = five[call_idx[0] % len(five)]
call_idx[0] += 1
result.scalar_one_or_none = MagicMock(return_value=lasts[info.provider])
return result
def add(self, obj):
added_rows.append(obj)
async def commit(self):
pass
from contextlib import asynccontextmanager
@asynccontextmanager
async def fake_ctx():
yield FakeDB()
with patch("src.services.model_version_tracker.probe_all_providers", return_value=five), \
patch("src.services.model_version_tracker.get_db_context", fake_ctx):
result = await tracker.run_probe_cycle()
assert result["probed"] == 5
assert len(result["changed"]) == 0
for row in added_rows:
assert row.changed is False
@pytest.mark.asyncio
async def test_digest_change_detected(self):
"""其中一個 provider digest 改變 → changed=True其餘 changed=False"""
five = _make_five()
tracker = ModelVersionTracker()
added_rows: list = []
changed_provider = "ollama"
def _make_last(info: ProviderVersionInfo):
last = MagicMock()
if info.provider == changed_provider:
# 舊 digest 不同
last.version = info.version
last.digest = "sha256:OLD_DIGEST"
else:
last.version = info.version
last.digest = info.digest
return last
lasts = {info.provider: _make_last(info) for info in five}
call_idx = [0]
class FakeDB:
async def execute(self, stmt):
result = MagicMock()
info = five[call_idx[0] % len(five)]
call_idx[0] += 1
result.scalar_one_or_none = MagicMock(return_value=lasts[info.provider])
return result
def add(self, obj):
added_rows.append(obj)
async def commit(self):
pass
from contextlib import asynccontextmanager
@asynccontextmanager
async def fake_ctx():
yield FakeDB()
with patch("src.services.model_version_tracker.probe_all_providers", return_value=five), \
patch("src.services.model_version_tracker.get_db_context", fake_ctx):
result = await tracker.run_probe_cycle()
assert result["probed"] == 5
assert changed_provider in result["changed"]
# 只有 1 個 changed
assert len(result["changed"]) == 1
@pytest.mark.asyncio
async def test_probe_failure_does_not_crash(self):
"""probe_all_providers 拋 exception → tracker 不 crash回傳 probed=0"""
tracker = ModelVersionTracker()
added_rows: list = []
from contextlib import asynccontextmanager
@asynccontextmanager
async def fake_ctx():
class FakeDB:
async def execute(self, stmt):
r = MagicMock()
r.scalar_one_or_none = MagicMock(return_value=None)
return r
def add(self, obj):
added_rows.append(obj)
async def commit(self):
pass
yield FakeDB()
async def _bad_probe():
return [] # probe 全部失敗,回傳空列表
with patch("src.services.model_version_tracker.probe_all_providers", side_effect=_bad_probe), \
patch("src.services.model_version_tracker.get_db_context", fake_ctx):
result = await tracker.run_probe_cycle()
assert result["probed"] == 0
assert result["changed"] == []
assert len(added_rows) == 0