# apps/api/tests/test_ai_governance_endpoints.py | 2026-05-02 @ Asia/Taipei """ Unit Tests — AI Governance Endpoints (PR 1) 覆蓋範圍: 1. events endpoint 分頁邏輯正確 2. events endpoint severity 映射正確(critical / warning / info) 3. queue endpoint graceful fallback(mock ProgrammingError) 4. summary endpoint compliance_rate 計算(含 total=0 邊界) 5. summary endpoint compliance_rate 計算(有 unresolved 的正常情況) 測試策略:mock service 層函式,不依賴 DB,確保 Router 邏輯正確。 """ from __future__ import annotations from datetime import datetime, timezone, timedelta from unittest.mock import AsyncMock, patch import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from src.api.v1.ai_governance import router from src.models.governance import ( DailyCount, DispatchItem, GovernanceEvent, GovernanceEventsResponse, GovernanceQueueResponse, GovernanceSummaryResponse, map_severity, ) TAIPEI = timezone(timedelta(hours=8)) NOW = datetime(2026, 5, 2, 12, 0, tzinfo=TAIPEI) # ============================================================================= # Fixture # ============================================================================= @pytest.fixture def client(): app = FastAPI() app.include_router(router, prefix="/api/v1") return TestClient(app) def _make_event( event_id: str = "evt-001", event_type: str = "slo_violation", resolved: bool = False, ) -> GovernanceEvent: return GovernanceEvent( id=event_id, event_type=event_type, severity=map_severity(event_type), triggered_at=NOW, resolved=resolved, resolved_at=None, impact="SLO violated", details={"message": "test"}, remediation=None, dispatch_ids=[], ) # ============================================================================= # 1. severity 映射單元測試 # ============================================================================= class TestSeverityMapping: def test_critical_types(self): for et in ("slo_violation", "conservative_mode", "governance_slo_data_gap"): assert map_severity(et) == "critical", f"{et} should be critical" def test_warning_types(self): for et in ("trust_drift", "kb_stale", "knowledge_degradation", "execution_blast_radius"): assert map_severity(et) == "warning", f"{et} should be warning" def test_info_types(self): for et in ("replay_degraded", "self_demotion", "llm_hallucination", "unknown_event"): assert map_severity(et) == "info", f"{et} should be info" # ============================================================================= # 2. events endpoint 分頁 # ============================================================================= class TestEventsEndpoint: def test_pagination_default(self, client): """page=1 size=20 預設分頁正確.""" fake_response = GovernanceEventsResponse( items=[_make_event(str(i)) for i in range(5)], total=5, page=1, size=20, ) with patch( "src.api.v1.ai_governance.query_governance_events", new_callable=lambda: lambda **kw: None, ): with patch( "src.api.v1.ai_governance.query_governance_events", new=AsyncMock(return_value=fake_response), ): r = client.get("/api/v1/ai/governance/events") assert r.status_code == 200 data = r.json() assert data["total"] == 5 assert data["page"] == 1 assert data["size"] == 20 assert len(data["items"]) == 5 def test_pagination_custom(self, client): """自訂分頁參數傳入 service.""" fake_response = GovernanceEventsResponse( items=[_make_event()], total=50, page=3, size=10, ) captured: dict = {} async def mock_query(**kwargs): captured.update(kwargs) return fake_response with patch("src.api.v1.ai_governance.query_governance_events", new=mock_query): r = client.get("/api/v1/ai/governance/events?page=3&size=10") assert r.status_code == 200 assert captured["page"] == 3 assert captured["size"] == 10 data = r.json() assert data["total"] == 50 def test_severity_filter_passed(self, client): """severity query param 正確傳入 service.""" fake_response = GovernanceEventsResponse(items=[], total=0, page=1, size=20) captured: dict = {} async def mock_query(**kwargs): captured.update(kwargs) return fake_response with patch("src.api.v1.ai_governance.query_governance_events", new=mock_query): r = client.get("/api/v1/ai/governance/events?severity=critical") assert r.status_code == 200 assert captured["severity"] == "critical" def test_invalid_severity_rejected(self, client): """非法 severity 值應被拒絕(422).""" r = client.get("/api/v1/ai/governance/events?severity=bad_value") assert r.status_code == 422 def test_invalid_status_rejected(self, client): """非法 status 值應被拒絕(422).""" r = client.get("/api/v1/ai/governance/events?status=invalid") assert r.status_code == 422 def test_severity_in_response(self, client): """回傳的事件 severity 欄位對應 event_type 映射.""" events = [ _make_event("e1", "slo_violation"), # critical _make_event("e2", "trust_drift"), # warning _make_event("e3", "self_demotion"), # info ] fake_response = GovernanceEventsResponse(items=events, total=3, page=1, size=20) with patch( "src.api.v1.ai_governance.query_governance_events", new=AsyncMock(return_value=fake_response), ): r = client.get("/api/v1/ai/governance/events") assert r.status_code == 200 items = r.json()["items"] assert items[0]["severity"] == "critical" assert items[1]["severity"] == "warning" assert items[2]["severity"] == "info" # ============================================================================= # 3. queue endpoint graceful fallback # ============================================================================= class TestQueueEndpoint: def test_graceful_fallback_on_programming_error(self, client): """dispatch 表不存在時回 table_pending=true,不拋 500.""" fallback = GovernanceQueueResponse( items=[], total=0, page=1, size=10, table_pending=True, ) with patch( "src.api.v1.ai_governance.query_governance_queue", new=AsyncMock(return_value=fallback), ): r = client.get("/api/v1/ai/governance/queue") assert r.status_code == 200 data = r.json() assert data["table_pending"] is True assert data["items"] == [] assert data["total"] == 0 def test_normal_response_when_table_ready(self, client): """表就緒時正常回傳 items.""" dispatch_item = DispatchItem( id="d-001", governance_event_id="evt-001", event_type="slo_violation", dispatch_status="pending", proposed_action="restart deployment", playbook_id=None, playbook_trust=None, created_at=NOW, dispatched_at=None, completed_at=None, operator_note=None, ) normal = GovernanceQueueResponse( items=[dispatch_item], total=1, page=1, size=10, table_pending=False, ) with patch( "src.api.v1.ai_governance.query_governance_queue", new=AsyncMock(return_value=normal), ): r = client.get("/api/v1/ai/governance/queue") assert r.status_code == 200 data = r.json() assert data["table_pending"] is False assert len(data["items"]) == 1 assert data["items"][0]["dispatch_status"] == "pending" def test_invalid_dispatch_status_rejected(self, client): """非法 dispatch_status 應被拒絕(422).""" r = client.get("/api/v1/ai/governance/queue?dispatch_status=unknown") assert r.status_code == 422 # ============================================================================= # 4. summary endpoint compliance_rate # ============================================================================= class TestSummaryEndpoint: def test_compliance_rate_normal(self, client): """有 unresolved 時計算 1 - unresolved/total.""" fake = GovernanceSummaryResponse( compliance_rate=0.8, total_events=10, unresolved_count=2, daily_counts=[], ) with patch( "src.api.v1.ai_governance.query_governance_summary", new=AsyncMock(return_value=fake), ): r = client.get("/api/v1/ai/governance/summary") assert r.status_code == 200 data = r.json() assert data["compliance_rate"] == pytest.approx(0.8) assert data["total_events"] == 10 assert data["unresolved_count"] == 2 def test_compliance_rate_all_resolved(self, client): """全部已解決時 compliance_rate = 1.0.""" fake = GovernanceSummaryResponse( compliance_rate=1.0, total_events=5, unresolved_count=0, daily_counts=[], ) with patch( "src.api.v1.ai_governance.query_governance_summary", new=AsyncMock(return_value=fake), ): r = client.get("/api/v1/ai/governance/summary?days=7") assert r.status_code == 200 assert r.json()["compliance_rate"] == pytest.approx(1.0) def test_compliance_rate_total_zero(self, client): """total_events=0 時 compliance_rate = 1.0(邊界測試).""" fake = GovernanceSummaryResponse( compliance_rate=1.0, total_events=0, unresolved_count=0, daily_counts=[], ) with patch( "src.api.v1.ai_governance.query_governance_summary", new=AsyncMock(return_value=fake), ): r = client.get("/api/v1/ai/governance/summary") assert r.status_code == 200 data = r.json() assert data["compliance_rate"] == pytest.approx(1.0) assert data["total_events"] == 0 def test_days_max_boundary(self, client): """days=90 邊界值應被接受.""" fake = GovernanceSummaryResponse( compliance_rate=1.0, total_events=0, unresolved_count=0, daily_counts=[], ) with patch( "src.api.v1.ai_governance.query_governance_summary", new=AsyncMock(return_value=fake), ): r = client.get("/api/v1/ai/governance/summary?days=90") assert r.status_code == 200 def test_days_over_max_rejected(self, client): """days=91 應被拒絕(422).""" r = client.get("/api/v1/ai/governance/summary?days=91") assert r.status_code == 422 def test_daily_counts_structure(self, client): """daily_counts 結構正確.""" fake = GovernanceSummaryResponse( compliance_rate=0.9, total_events=10, unresolved_count=1, daily_counts=[ DailyCount(date="2026-05-01", total=3, by_type={"slo_violation": 2, "trust_drift": 1}), DailyCount(date="2026-05-02", total=7, by_type={"slo_violation": 7}), ], ) with patch( "src.api.v1.ai_governance.query_governance_summary", new=AsyncMock(return_value=fake), ): r = client.get("/api/v1/ai/governance/summary") assert r.status_code == 200 counts = r.json()["daily_counts"] assert len(counts) == 2 assert counts[0]["date"] == "2026-05-01" assert counts[0]["by_type"]["slo_violation"] == 2 # ============================================================================= # 5. service 層 compliance_rate 純函式測試(不經 HTTP) # ============================================================================= class TestComplianceRateCalculation: """直接測試 service 邏輯,不經 Router。""" def test_formula_normal(self): """1 - 2/10 = 0.8""" rate = round(1.0 - 2 / 10, 4) assert rate == pytest.approx(0.8) def test_formula_zero_total(self): """total=0 → 1.0""" total = 0 rate = 1.0 if total == 0 else round(1.0 - 0 / total, 4) assert rate == pytest.approx(1.0) def test_formula_all_unresolved(self): """1 - 5/5 = 0.0""" rate = round(1.0 - 5 / 5, 4) assert rate == pytest.approx(0.0)