# 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, timedelta, timezone from types import SimpleNamespace 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.db.models import GovernanceRemediationDispatch, KnowledgeEntryRecord from src.models.governance import ( DailyCount, DispatchItem, GovernanceEvent, GovernanceEventsResponse, GovernanceQueueResponse, GovernanceSummaryResponse, KnowledgeReviewDraftArchiveRequest, KnowledgeReviewDraftArchiveResponse, KnowledgeReviewDraftDedupeGroup, KnowledgeReviewDraftDedupeResponse, KnowledgeReviewDraftStaleRatioSnapshot, KnowledgeStaleCandidate, KnowledgeStaleCandidatesResponse, KnowledgeStaleOwnerReviewBatchItem, KnowledgeStaleOwnerReviewBatchQueueRequest, KnowledgeStaleOwnerReviewBatchQueueResponse, KnowledgeStaleOwnerReviewBurnDownItem, KnowledgeStaleOwnerReviewBurnDownResponse, KnowledgeStaleOwnerReviewCompleteRequest, KnowledgeStaleOwnerReviewCompleteResponse, KnowledgeStaleOwnerReviewCompletionBatchPreviewRequest, KnowledgeStaleOwnerReviewCompletionBatchPreviewResponse, KnowledgeStaleOwnerReviewCompletionQueueItem, KnowledgeStaleOwnerReviewCompletionQueueResponse, KnowledgeStaleOwnerReviewInboxItem, KnowledgeStaleOwnerReviewInboxResponse, KnowledgeStaleOwnerReviewRequest, KnowledgeStaleOwnerReviewResponse, map_severity, ) from src.models.knowledge import EntrySource, EntryStatus, EntryType from src.services.governance_km_review_service import ( KmReviewDraftArchiveError, _build_dry_run_plan_fingerprint, _build_stale_ratio_recheck_context, _validate_archive_request_against_plan, _validate_dry_run_plan_fingerprint, ) from src.services.governance_km_stale_review_service import ( KmStaleOwnerReviewError, _build_batch_owner_review_decision_context, _build_batch_queue_plan_fingerprint, _build_completion_batch_preview_fingerprint, _build_completion_plan_fingerprint, _build_completion_queue_item, _build_owner_review_burndown_items, _build_owner_review_completion_audit_context, _build_owner_review_inbox_item, _build_stale_owner_review_decision_context, _completion_stage_for_outcome, _entries_to_stale_threshold, ) from src.services.governance_km_stale_review_service import ( _build_stale_ratio_recheck_context as _build_stale_owner_review_recheck_context, ) from src.services.governance_query_service import ( _build_km_review_draft_dedupe_groups, _build_km_stale_candidate, _extract_archived_count, _extract_dry_run_plan_fingerprint, _extract_governance_event_id_from_tags, _extract_kb_draft_entry_id, _extract_remediation, _extract_stale_ratio_snapshot, _extract_worker_status, _merge_dispatch_ids, _query_dispatch_table, _to_governance_event, ) 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_event_id_filter_passed(self, client): """event_id query param 供 Telegram 詳情 / 歷史精準回看.""" 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" "?event_id=evt-001&event_id=evt-002&status=unresolved" ) assert r.status_code == 200 assert captured["event_ids"] == ["evt-001", "evt-002"] assert captured["status"] == "unresolved" 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" class TestEventsReadSideNormalization: def test_remediation_dict_is_normalized_to_string(self): """production details.remediation 可能是 dict,response schema 必須仍回字串.""" remediation = _extract_remediation({ "remediation": { "items": [ "補齊 ADR-100 SLO emitter", "設置 PROMETHEUS_MULTIPROC_DIR", ] } }) assert remediation == "補齊 ADR-100 SLO emitter;設置 PROMETHEUS_MULTIPROC_DIR" def test_governance_event_accepts_dict_remediation(self): """dict remediation 不應讓 GovernanceEvent Pydantic validation 變成 500.""" row = type("Row", (), { "id": "evt-001", "event_type": "governance_slo_data_gap", "triggered_at": NOW, "resolved": False, "resolved_at": None, "details": { "message": "SLO metrics missing", "remediation": {"items": ["補齊 SLO emitter"]}, }, })() event = _to_governance_event(row) assert event.remediation == "補齊 SLO emitter" assert event.impact == "SLO metrics missing" def test_governance_event_uses_db_dispatch_ids_first(self): """events read model 應以 dispatch table ids 補齊 detail/history 鏈路。""" row = type("Row", (), { "id": "evt-001", "event_type": "knowledge_degradation", "triggered_at": NOW, "resolved": False, "resolved_at": None, "details": { "message": "KM stale", "dispatch_ids": ["legacy-dispatch"], }, })() event = _to_governance_event(row, dispatch_ids=["db-dispatch", "legacy-dispatch"]) assert event.dispatch_ids == ["db-dispatch", "legacy-dispatch"] def test_merge_dispatch_ids_deduplicates_and_preserves_db_priority(self): """DB truth-first,legacy payload 只作 fallback。""" assert _merge_dispatch_ids( ["db-2", "db-1"], ["db-1", "legacy-1", None], ) == ["db-2", "db-1", "legacy-1"] def test_events_query_joins_dispatch_table_for_history_buttons(self): """events endpoint 不可只讀 details.dispatch_ids,必須查 dispatch table。""" import inspect from src.services import governance_query_service source = inspect.getsource(governance_query_service._load_dispatch_ids_for_events) assert "FROM governance_remediation_dispatch d" in source assert "d.governance_event_id IN :event_ids" in source assert "ORDER BY d.dispatched_at DESC" in source # ============================================================================= # 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 def test_all_status_and_event_type_filter_passed(self, client): """Work Items 需要 all + event_type 查 KM healthcheck 全狀態鏈。""" normal = GovernanceQueueResponse( items=[], total=0, page=1, size=20, table_pending=False, ) captured: dict = {} async def mock_query(**kwargs): captured.update(kwargs) return normal with patch("src.api.v1.ai_governance.query_governance_queue", new=mock_query): r = client.get( "/api/v1/ai/governance/queue?dispatch_status=all" "&event_type=knowledge_degradation&size=20" ) assert r.status_code == 200 assert captured["dispatch_status"] == "all" assert captured["event_types"] == ["knowledge_degradation"] assert captured["size"] == 20 def test_queue_item_exposes_kb_draft_review_context(self, client): """Work Items 需要看到 Hermes 草稿 ID 與 worker 狀態,而不只 dispatch succeeded。""" item = DispatchItem( id="dispatch-001", governance_event_id="event-001", event_type="knowledge_degradation", dispatch_status="succeeded", executor_type="hermes_kb_growth_healthcheck", proposed_action="Hermes 已建立 KM 更新草稿,等待 owner 審核", created_at=NOW, workflow_stage="waiting_owner_review", next_action="owner_review_km_draft", lead_agent="Hermes", human_owner="KM owner / SRE owner", kb_draft_entry_id="km-001", worker_status="draft_created", ) normal = GovernanceQueueResponse( items=[item], total=1, page=1, size=20, 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?dispatch_status=all" "&event_type=knowledge_degradation&size=20" ) assert r.status_code == 200 data = r.json() assert data["items"][0]["kb_draft_entry_id"] == "km-001" assert data["items"][0]["worker_status"] == "draft_created" def test_extract_kb_draft_review_context_from_decision_context(self): """queue read model 從 workflow / worker_result 補出 KM 草稿追蹤欄位。""" context = { "workflow": {"kb_draft_entry_id": "km-workflow"}, "worker_result": { "km_draft_entry_id": "km-worker", "status": "draft_created", }, } assert _extract_kb_draft_entry_id(context) == "km-workflow" assert _extract_worker_status(context) == "draft_created" def test_extract_km_archive_audit_context_from_decision_context(self): """queue read model 應把 archive audit / stale ratio recheck 證據交給 Work Items。""" context = { "archived_count": 5, "dry_run_plan_fingerprint": "sha256:" + "a" * 64, "stale_ratio_snapshot": { "stale_count": 120, "total_count": 200, "stale_ratio": 0.6, "threshold": 0.2, "stale_days": 7, "extra": "ignored", }, "workflow": { "archived_entry_ids": ["fallback"], "dry_run_plan_fingerprint": "sha256:" + "b" * 64, }, } assert _extract_dry_run_plan_fingerprint(context) == "sha256:" + "a" * 64 assert _extract_archived_count(context) == 5 snapshot = _extract_stale_ratio_snapshot(context) assert snapshot == { "stale_count": 120, "total_count": 200, "stale_ratio": 0.6, "threshold": 0.2, "stale_days": 7, } def test_queue_query_uses_production_dispatch_schema(self): """queue 查詢必須對齊 migration schema:使用 dispatched_at,不讀不存在的 created_at/operator_note.""" import inspect source = inspect.getsource(_query_dispatch_table) assert "d.dispatched_at AS created_at" in source assert "ORDER BY d.dispatched_at DESC" in source assert "NULL::text AS operator_note" in source assert "CAST(:dispatch_status AS governance_dispatch_status)" in source assert "e.event_type::text IN :event_types" in source assert "d.created_at" not in source assert "d.operator_note" not in source class TestKmReviewDraftDedupe: def test_endpoint_passes_limit_and_returns_owner_review_plan(self, client): """Work Items 需要 read-only canonical/duplicate plan,不直接封存 KM。""" fake = KnowledgeReviewDraftDedupeResponse( total_review_drafts=3, event_group_total=1, duplicate_draft_total=2, generated_at=NOW, groups=[ KnowledgeReviewDraftDedupeGroup( governance_event_id="event-001", canonical_entry_id="km-canonical", canonical_title="KM healthcheck review draft - event", preferred_source="dispatch_context", duplicate_entry_ids=["km-dup-1", "km-dup-2"], duplicate_count=2, total_entries=3, suggested_action="owner_review_canonical_then_archive_duplicates", owner_action="review_canonical_and_archive_duplicate_drafts", writes_on_read=False, can_archive_without_owner_approval=False, archive_history=[ DispatchItem( id="dispatch-archive-001", governance_event_id="event-001", event_type="knowledge_degradation", dispatch_status="succeeded", executor_type="hermes_km_review_dedupe_owner_archive", proposed_action="Owner archived duplicate KM drafts", created_at=NOW, workflow_stage="km_duplicate_archive_after_owner_approval", dry_run_plan_fingerprint="sha256:" + "a" * 64, archived_count=2, ) ], ) ], ) captured: dict = {} async def mock_query(**kwargs): captured.update(kwargs) return fake with patch("src.api.v1.ai_governance.query_km_review_draft_dedupe", new=mock_query): r = client.get("/api/v1/ai/governance/km-review-drafts/dedupe?limit=50") assert r.status_code == 200 assert captured["limit"] == 50 data = r.json() assert data["duplicate_draft_total"] == 2 assert data["groups"][0]["canonical_entry_id"] == "km-canonical" assert data["groups"][0]["writes_on_read"] is False assert data["groups"][0]["can_archive_without_owner_approval"] is False assert data["groups"][0]["archive_history"][0]["executor_type"] == ( "hermes_km_review_dedupe_owner_archive" ) assert data["groups"][0]["archive_history"][0]["archived_count"] == 2 def test_governance_event_tag_extraction(self): assert _extract_governance_event_id_from_tags([ "agent:Hermes", "governance_event:event-001", ]) == "event-001" assert _extract_governance_event_id_from_tags(["agent:Hermes"]) is None def test_build_dedupe_groups_prefers_dispatch_context_draft(self): """若 dispatch context 指出 canonical draft,dedupe plan 應以它為準。""" rows = [ { "id": "km-latest", "title": "latest draft", "tags": ["governance_event:event-001"], "updated_at": datetime(2026, 5, 2, 14, 0, tzinfo=TAIPEI), }, { "id": "km-canonical", "title": "canonical draft", "tags": ["governance_event:event-001"], "updated_at": datetime(2026, 5, 2, 13, 0, tzinfo=TAIPEI), }, { "id": "km-other", "title": "other event draft", "tags": ["governance_event:event-002"], "updated_at": datetime(2026, 5, 2, 12, 0, tzinfo=TAIPEI), }, ] groups = _build_km_review_draft_dedupe_groups( rows, {"event-001": "km-canonical"}, { "event-001": [ DispatchItem( id="dispatch-recheck-001", governance_event_id="event-001", event_type="knowledge_degradation", dispatch_status="succeeded", executor_type="hermes_km_stale_ratio_recheck", proposed_action="Rechecked stale ratio", created_at=NOW, workflow_stage="stale_ratio_recheck", stale_ratio_snapshot={ "stale_count": 10, "total_count": 100, "stale_ratio": 0.1, "threshold": 0.2, "stale_days": 7, }, ) ] }, ) first = groups[0] assert first.governance_event_id == "event-001" assert first.canonical_entry_id == "km-canonical" assert first.preferred_source == "dispatch_context" assert first.duplicate_entry_ids == ["km-latest"] assert first.writes_on_read is False assert first.archive_history[0].executor_type == "hermes_km_stale_ratio_recheck" assert first.archive_history[0].stale_ratio_snapshot["stale_ratio"] == pytest.approx(0.1) def test_km_stale_candidates_endpoint_returns_read_only_priority_queue(self, client): """stale KM endpoint 應回傳 owner 可排序處理的 read-only 清單。""" fake = KnowledgeStaleCandidatesResponse( project_id="awoooi", total_stale=1490, returned=1, threshold_days=7, items=[ KnowledgeStaleCandidate( entry_id="km-001", project_id="awoooi", title="Sentry / SigNoz checkout repair runbook", entry_type="auto_runbook", category="AI系統", status="review", source="ai_extracted", updated_at=NOW - timedelta(days=21), stale_days=21, view_count=9, priority_score=265, priority_tier="P0", recommended_action="refresh_with_evidence", reasons=[ "linked_incident", "linked_playbook", "sentry_context", "signoz_context", ], correlation_sources=["incident", "playbook", "sentry", "signoz"], related_incident_id="INC-20260513-79ED5E", related_playbook_id="pb:auto-repair-canary", tags=["sentry", "signoz"], ) ], generated_at=NOW, ) captured: dict = {} async def mock_query(**kwargs): captured.update(kwargs) return fake with patch("src.api.v1.ai_governance.query_km_stale_candidates", new=mock_query): r = client.get( "/api/v1/ai/governance/km-stale-candidates" "?project_id=awoooi&limit=25" ) assert r.status_code == 200 assert captured == {"project_id": "awoooi", "limit": 25} data = r.json() assert data["writes_on_read"] is False assert data["manual_review_required"] is True assert data["total_stale"] == 1490 assert data["items"][0]["priority_tier"] == "P0" assert data["items"][0]["correlation_sources"] == [ "incident", "playbook", "sentry", "signoz", ] def test_build_km_stale_candidate_prioritizes_linked_evidence(self): """有 Incident / PlayBook / Sentry / SigNoz 脈絡的 stale KM 應排前面。""" record = KnowledgeEntryRecord( id="km-001", project_id="awoooi", title="Sentry checkout failure repair", content="Use SigNoz trace and PlayBook verification before KM writeback.", entry_type=EntryType.AUTO_RUNBOOK, category="AI系統", tags=["sentry", "signoz", "workflow:kb_growth_healthcheck"], source=EntrySource.AI_EXTRACTED, status=EntryStatus.REVIEW, related_incident_id="INC-20260513-79ED5E", related_playbook_id="pb:auto-repair-canary", view_count=7, updated_at=NOW - timedelta(days=35), ) candidate = _build_km_stale_candidate(record, now=NOW, threshold_days=7) assert candidate.priority_tier == "P0" assert candidate.recommended_action == "refresh_with_evidence" assert candidate.stale_days == 35 assert candidate.correlation_sources == ["incident", "playbook", "sentry", "signoz"] assert "linked_incident" in candidate.reasons assert "linked_playbook" in candidate.reasons assert "sentry_context" in candidate.reasons assert "signoz_context" in candidate.reasons def test_queue_stale_candidate_endpoint_returns_owner_review_dispatch(self, client): """單筆 stale KM 可以排入 owner review,但不直接改寫 KM。""" fake = KnowledgeStaleOwnerReviewResponse( entry_id="km-001", project_id="awoooi", status="queued", governance_event_id="event-001", dispatch_id="dispatch-001", workflow_stage="waiting_owner_review", recommended_action="refresh_with_evidence", owner="operator_console", owner_note="prioritize P0", writes_km=False, writes_governance_audit=True, generated_at=NOW, ) captured: dict = {} async def mock_queue(**kwargs): captured.update(kwargs) return fake with patch( "src.api.v1.ai_governance.queue_km_stale_owner_review", new=mock_queue, ): r = client.post( "/api/v1/ai/governance/km-stale-candidates/km-001/queue-review", json={ "owner": "operator_console", "owner_note": "prioritize P0", "dry_run": False, }, ) assert r.status_code == 200 assert captured["entry_id"] == "km-001" assert isinstance(captured["request"], KnowledgeStaleOwnerReviewRequest) data = r.json() assert data["schema_version"] == "km_stale_owner_review_v1" assert data["status"] == "queued" assert data["dispatch_id"] == "dispatch-001" assert data["workflow_stage"] == "waiting_owner_review" assert data["writes_km"] is False assert data["writes_governance_audit"] is True def test_queue_stale_candidate_endpoint_maps_validation_error(self, client): async def mock_queue(**kwargs): raise KmStaleOwnerReviewError(409, "KM entry is no longer past the stale threshold") with patch( "src.api.v1.ai_governance.queue_km_stale_owner_review", new=mock_queue, ): r = client.post( "/api/v1/ai/governance/km-stale-candidates/km-001/queue-review", json={"owner": "operator_console"}, ) assert r.status_code == 409 assert r.json()["detail"] == "KM entry is no longer past the stale threshold" def test_batch_queue_stale_candidate_endpoint_returns_batch_dispatch(self, client): """P0/P1 stale KM batch queue 應只寫治理 dispatch,不改寫 KM。""" fake = KnowledgeStaleOwnerReviewBatchQueueResponse( project_id="awoooi", status="queued", owner="operator_console", dry_run=False, priority_tiers=["P0", "P1"], requested_limit=10, candidate_count=2, queued_count=1, already_queued_count=1, skipped_count=0, batch_governance_event_id="event-batch-001", batch_dispatch_id="dispatch-batch-001", workflow_stage="batch_owner_review_queued", writes_km=False, writes_governance_audit=True, stale_ratio_snapshot=KnowledgeReviewDraftStaleRatioSnapshot( stale_count=119, total_count=200, stale_ratio=0.595, threshold=0.2, stale_days=7, ), dry_run_plan_fingerprint="sha256:" + "b" * 64, items=[ KnowledgeStaleOwnerReviewBatchItem( entry_id="km-001", title="Sentry checkout failure repair", priority_tier="P0", recommended_action="refresh_with_evidence", status="queued", governance_event_id="event-001", dispatch_id="dispatch-001", workflow_stage="waiting_owner_review", ), KnowledgeStaleOwnerReviewBatchItem( entry_id="km-002", title="Already queued", priority_tier="P1", recommended_action="owner_review", status="already_queued", reason="active_owner_review_exists", dispatch_id="dispatch-002", workflow_stage="waiting_owner_review", ), ], generated_at=NOW, ) captured: dict = {} async def mock_batch_queue(**kwargs): captured.update(kwargs) return fake with patch( "src.api.v1.ai_governance.batch_queue_km_stale_owner_reviews", new=mock_batch_queue, ): r = client.post( "/api/v1/ai/governance/km-stale-candidates/batch-queue-review", json={ "project_id": "awoooi", "priority_tiers": ["P0", "P1"], "limit": 10, "owner": "operator_console", "dry_run": False, "dry_run_plan_fingerprint": "sha256:" + "b" * 64, }, ) assert r.status_code == 200 assert isinstance(captured["request"], KnowledgeStaleOwnerReviewBatchQueueRequest) data = r.json() assert data["schema_version"] == "km_stale_owner_review_batch_v1" assert data["status"] == "queued" assert data["workflow_stage"] == "batch_owner_review_queued" assert data["writes_km"] is False assert data["writes_governance_audit"] is True assert data["batch_dispatch_id"] == "dispatch-batch-001" assert data["items"][0]["status"] == "queued" assert data["items"][1]["status"] == "already_queued" def test_batch_queue_stale_candidate_endpoint_maps_validation_error(self, client): async def mock_batch_queue(**kwargs): raise KmStaleOwnerReviewError( 409, "dry_run_plan_fingerprint does not match the latest stale KM batch queue plan", ) with patch( "src.api.v1.ai_governance.batch_queue_km_stale_owner_reviews", new=mock_batch_queue, ): r = client.post( "/api/v1/ai/governance/km-stale-candidates/batch-queue-review", json={ "project_id": "awoooi", "limit": 10, "dry_run": False, "dry_run_plan_fingerprint": "sha256:" + "c" * 64, }, ) assert r.status_code == 409 assert "batch queue plan" in r.json()["detail"] def test_owner_review_inbox_endpoint_returns_sorted_work_items(self, client): """Owner-review inbox 應回傳 pending dispatch 與 KM priority context。""" fake = KnowledgeStaleOwnerReviewInboxResponse( project_id="awoooi", dispatch_status="pending", total=1, returned=1, items=[ KnowledgeStaleOwnerReviewInboxItem( dispatch_id="dispatch-001", governance_event_id="event-001", entry_id="km-001", project_id="awoooi", title="Sentry checkout failure repair", dispatch_status="pending", workflow_stage="waiting_owner_review", next_action="owner_review_stale_km_candidate", owner="operator_console", owner_note="p0_p1_stale_km_batch", batch_governance_event_id="event-batch-001", batch_dispatch_id="dispatch-batch-001", priority_tier="P0", priority_score=265, recommended_action="refresh_with_evidence", stale_days=35, view_count=7, correlation_sources=["incident", "playbook", "sentry"], reasons=["linked_incident", "linked_playbook"], related_incident_id="INC-20260513-79ED5E", related_playbook_id="pb:auto-repair-canary", queued_at=NOW, ) ], generated_at=NOW, ) captured: dict = {} async def mock_inbox(**kwargs): captured.update(kwargs) return fake with patch( "src.api.v1.ai_governance.query_km_stale_owner_review_inbox", new=mock_inbox, ): r = client.get( "/api/v1/ai/governance/km-stale-owner-reviews" "?project_id=awoooi&dispatch_status=pending&limit=20" ) assert r.status_code == 200 assert captured == { "project_id": "awoooi", "dispatch_status": "pending", "limit": 20, } data = r.json() assert data["schema_version"] == "km_stale_owner_review_inbox_v1" assert data["total"] == 1 assert data["writes_on_read"] is False assert data["manual_review_required"] is True assert data["items"][0]["dispatch_id"] == "dispatch-001" assert data["items"][0]["workflow_stage"] == "waiting_owner_review" assert data["items"][0]["batch_dispatch_id"] == "dispatch-batch-001" def test_owner_review_inbox_context_keeps_batch_and_priority_visible(self): record = KnowledgeEntryRecord( id="km-001", project_id="awoooi", title="Sentry checkout failure repair", content="Use Sentry and SigNoz evidence before writeback.", entry_type=EntryType.AUTO_RUNBOOK, category="AI系統", tags=["sentry", "signoz"], source=EntrySource.AI_EXTRACTED, status=EntryStatus.REVIEW, related_incident_id="INC-20260513-79ED5E", related_playbook_id="pb:auto-repair-canary", view_count=7, updated_at=NOW - timedelta(days=35), ) candidate = _build_km_stale_candidate(record, now=NOW, threshold_days=7) row = type("Row", (), { "id": "dispatch-001", "governance_event_id": "event-001", "dispatch_status": "pending", "dispatched_at": NOW, "started_at": None, "completed_at": None, })() ctx = { "owner": "operator_console", "owner_note": "p0_p1_stale_km_batch", "next_action": "owner_review_stale_km_candidate", "workflow": { "entry_id": "km-001", "project_id": "awoooi", "batch_governance_event_id": "event-batch-001", "batch_dispatch_id": "dispatch-batch-001", "current_stage": "waiting_owner_review", "stage_by_dispatch_status": {"pending": "waiting_owner_review"}, }, } item = _build_owner_review_inbox_item( row=row, candidate=candidate, decision_context=ctx, ) assert item.dispatch_id == "dispatch-001" assert item.batch_dispatch_id == "dispatch-batch-001" assert item.priority_tier == "P0" assert item.priority_score > 200 assert item.workflow_stage == "waiting_owner_review" assert item.recommended_action == "refresh_with_evidence" assert item.correlation_sources == ["incident", "playbook", "sentry", "signoz"] def test_owner_review_burndown_endpoint_returns_completion_progress(self, client): """Burn-down endpoint 應把 pending、completion audit 與 recheck snapshot 集中呈現。""" fake = KnowledgeStaleOwnerReviewBurnDownResponse( project_id="awoooi", burn_down_status="above_threshold", current_snapshot=KnowledgeReviewDraftStaleRatioSnapshot( stale_count=118, total_count=200, stale_ratio=0.59, threshold=0.2, stale_days=7, ), entries_to_threshold=78, pending_owner_reviews=9, completed_owner_reviews=1, completion_audit_total=1, stale_ratio_recheck_total=1, latest_stale_count_delta=-1, latest_stale_ratio_delta=-0.005, returned=1, items=[ KnowledgeStaleOwnerReviewBurnDownItem( completion_dispatch_id="dispatch-audit-001", governance_event_id="event-001", source_dispatch_id="dispatch-001", recheck_dispatch_id="dispatch-recheck-001", entry_id="km-001", project_id="awoooi", dispatch_status="succeeded", workflow_stage="km_writeback_after_approval", review_outcome="refresh_with_evidence", owner="operator_console", completed_at=NOW, stale_ratio_snapshot=KnowledgeReviewDraftStaleRatioSnapshot( stale_count=118, total_count=200, stale_ratio=0.59, threshold=0.2, stale_days=7, ), stale_count_delta=-1, stale_ratio_delta=-0.005, above_threshold=True, ) ], generated_at=NOW, ) captured: dict = {} async def mock_burndown(**kwargs): captured.update(kwargs) return fake with patch( "src.api.v1.ai_governance.query_km_stale_owner_review_burndown", new=mock_burndown, ): r = client.get( "/api/v1/ai/governance/km-stale-owner-review-burndown" "?project_id=awoooi&limit=12" ) assert r.status_code == 200 assert captured == {"project_id": "awoooi", "limit": 12} data = r.json() assert data["schema_version"] == "km_stale_owner_review_burndown_v1" assert data["burn_down_status"] == "above_threshold" assert data["writes_on_read"] is False assert data["manual_review_required"] is True assert data["pending_owner_reviews"] == 9 assert data["completion_audit_total"] == 1 assert data["stale_ratio_recheck_total"] == 1 assert data["latest_stale_count_delta"] == -1 assert data["items"][0]["recheck_dispatch_id"] == "dispatch-recheck-001" def test_owner_review_burndown_items_compute_chronological_delta(self): older_snapshot = { "stale_count": 119, "total_count": 200, "stale_ratio": 0.595, "threshold": 0.2, "stale_days": 7, } newer_snapshot = { "stale_count": 118, "total_count": 200, "stale_ratio": 0.59, "threshold": 0.2, "stale_days": 7, } rows = [ SimpleNamespace( id="dispatch-audit-new", governance_event_id="event-001", dispatch_status="succeeded", completed_at=NOW, decision_context={ "project_id": "awoooi", "owner": "operator_console", "review_outcome": "refresh_with_evidence", "workflow": { "project_id": "awoooi", "entry_id": "km-002", "source_dispatch_id": "dispatch-002", "current_stage": "km_writeback_after_approval", "stale_ratio_snapshot": newer_snapshot, }, "stale_ratio_recheck": {"dispatch_id": "dispatch-recheck-new"}, }, ), SimpleNamespace( id="dispatch-audit-old", governance_event_id="event-001", dispatch_status="succeeded", completed_at=NOW - timedelta(minutes=10), decision_context={ "project_id": "awoooi", "owner": "operator_console", "review_outcome": "refresh_with_evidence", "workflow": { "project_id": "awoooi", "entry_id": "km-001", "source_dispatch_id": "dispatch-001", "current_stage": "km_writeback_after_approval", "stale_ratio_snapshot": older_snapshot, }, "stale_ratio_recheck": {"dispatch_id": "dispatch-recheck-old"}, }, ), ] items = _build_owner_review_burndown_items(rows, project_id="awoooi") assert items[0].completion_dispatch_id == "dispatch-audit-new" assert items[0].stale_count_delta == -1 assert items[0].stale_ratio_delta == pytest.approx(-0.005) assert items[0].above_threshold is True assert items[1].stale_count_delta is None assert _entries_to_stale_threshold(items[0].stale_ratio_snapshot) == 78 def test_owner_review_completion_queue_endpoint_returns_readiness_split(self, client): """Completion queue 應把 ready / blocked / completed / failed 分流給前端。""" fake = KnowledgeStaleOwnerReviewCompletionQueueResponse( project_id="awoooi", status_bucket="all", total=2, returned=2, pending_count=1, ready_count=1, blocked_count=0, completed_count=1, failed_count=0, items=[ KnowledgeStaleOwnerReviewCompletionQueueItem( dispatch_id="dispatch-ready-001", governance_event_id="event-001", entry_id="km-001", project_id="awoooi", title="Sentry checkout failure repair", dispatch_status="pending", workflow_stage="waiting_owner_review", readiness="ready", recommended_completion_outcome="refresh_with_evidence", next_action="preview_stale_km_review_completion", required_owner_fields=["owner_note_or_updated_content"], can_preview=True, can_confirm_after_preview=True, writes_km_on_confirm=True, priority_tier="P0", priority_score=265, recommended_action="refresh_with_evidence", stale_days=35, view_count=7, correlation_sources=["incident", "playbook", "sentry"], reasons=["linked_incident", "linked_playbook"], related_incident_id="INC-20260513-79ED5E", queued_at=NOW, ), KnowledgeStaleOwnerReviewCompletionQueueItem( dispatch_id="dispatch-done-001", governance_event_id="event-001", entry_id="km-002", project_id="awoooi", title="Auto repair canary runbook", dispatch_status="succeeded", workflow_stage="km_candidate_reviewed", readiness="completed", recommended_completion_outcome="archive", next_action="view_stale_km_completion_audit", required_owner_fields=["owner_note"], can_preview=False, can_confirm_after_preview=False, writes_km_on_confirm=False, priority_tier="P1", priority_score=120, recommended_action="archive_or_supersede", stale_days=18, view_count=2, correlation_sources=["playbook"], reasons=["high_stale_days"], completed_at=NOW, ), ], generated_at=NOW, ) captured: dict = {} async def mock_completion_queue(**kwargs): captured.update(kwargs) return fake with patch( "src.api.v1.ai_governance.query_km_stale_owner_review_completion_queue", new=mock_completion_queue, ): r = client.get( "/api/v1/ai/governance/km-stale-owner-review-completion-queue" "?project_id=awoooi&status_bucket=all&priority_tier=P0" "&recommended_completion_outcome=refresh_with_evidence" "&can_preview=true&limit=12" ) assert r.status_code == 200 assert captured == { "project_id": "awoooi", "status_bucket": "all", "priority_tiers": ["P0"], "recommended_completion_outcome": "refresh_with_evidence", "batch_governance_event_id": None, "can_preview": True, "limit": 12, } data = r.json() assert data["schema_version"] == "km_stale_owner_review_completion_queue_v1" assert data["writes_on_read"] is False assert data["manual_review_required"] is True assert data["batch_writes_allowed"] is False assert data["pending_count"] == 1 assert data["completed_count"] == 1 assert data["items"][0]["readiness"] == "ready" assert data["items"][0]["can_preview"] is True assert data["items"][1]["writes_km_on_confirm"] is False def test_completion_queue_batch_preview_endpoint_is_dry_run_only(self, client): fake = KnowledgeStaleOwnerReviewCompletionBatchPreviewResponse( project_id="awoooi", owner="operator_console", owner_note="review top ready items", status_bucket="ready", priority_tiers=["P0", "P1"], recommended_completion_outcome="all", requested_limit=10, candidate_count=1, previewable_count=1, blocked_count=0, completed_count=0, failed_count=0, dry_run_plan_fingerprint="sha256:" + "d" * 64, items=[ KnowledgeStaleOwnerReviewCompletionQueueItem( dispatch_id="dispatch-ready-001", governance_event_id="event-001", entry_id="km-001", project_id="awoooi", title="Sentry checkout failure repair", dispatch_status="pending", workflow_stage="waiting_owner_review", readiness="ready", recommended_completion_outcome="refresh_with_evidence", next_action="preview_stale_km_review_completion", required_owner_fields=["owner_note_or_updated_content"], can_preview=True, can_confirm_after_preview=True, writes_km_on_confirm=True, priority_tier="P0", priority_score=265, recommended_action="refresh_with_evidence", stale_days=35, view_count=7, correlation_sources=["incident", "playbook", "sentry"], reasons=["linked_incident"], queued_at=NOW, ) ], generated_at=NOW, ) captured: dict = {} async def mock_completion_batch_preview(**kwargs): captured.update(kwargs) return fake with patch( "src.api.v1.ai_governance.preview_km_stale_owner_review_completion_batch", new=mock_completion_batch_preview, ): r = client.post( "/api/v1/ai/governance/km-stale-owner-review-completion-queue/batch-preview", json={ "project_id": "awoooi", "status_bucket": "ready", "priority_tiers": ["P0", "P1"], "limit": 10, "owner": "operator_console", "owner_note": "review top ready items", }, ) assert r.status_code == 200 assert isinstance(captured["request"], KnowledgeStaleOwnerReviewCompletionBatchPreviewRequest) data = r.json() assert data["schema_version"] == "km_stale_owner_review_completion_batch_preview_v1" assert data["status"] == "dry_run" assert data["writes_km"] is False assert data["writes_governance_audit"] is False assert data["batch_writes_allowed"] is False assert data["manual_review_required"] is True assert data["previewable_count"] == 1 assert data["items"][0]["can_preview"] is True def test_completion_queue_item_marks_active_review_ready(self): inbox_item = KnowledgeStaleOwnerReviewInboxItem( dispatch_id="dispatch-ready-001", governance_event_id="event-001", entry_id="km-001", project_id="awoooi", title="Sentry checkout failure repair", dispatch_status="pending", workflow_stage="waiting_owner_review", next_action="owner_review_stale_km_candidate", owner="operator_console", owner_note="p0_p1_stale_km_batch", priority_tier="P0", priority_score=265, recommended_action="refresh_with_evidence", stale_days=35, view_count=7, correlation_sources=["incident", "playbook", "sentry"], reasons=["linked_incident"], related_incident_id="INC-20260513-79ED5E", queued_at=NOW, ) item = _build_completion_queue_item(inbox_item) assert item.readiness == "ready" assert item.next_action == "preview_stale_km_review_completion" assert item.recommended_completion_outcome == "refresh_with_evidence" assert item.required_owner_fields == ["owner_note_or_updated_content"] assert item.can_preview is True assert item.can_confirm_after_preview is True assert item.writes_km_on_confirm is True def test_completion_queue_item_marks_terminal_states_non_writable(self): inbox_item = KnowledgeStaleOwnerReviewInboxItem( dispatch_id="dispatch-completed-001", governance_event_id="event-001", entry_id="km-001", project_id="awoooi", title="Old auto repair note", dispatch_status="succeeded", workflow_stage="km_candidate_reviewed", priority_tier="P1", priority_score=120, recommended_action="archive_or_supersede", stale_days=20, view_count=2, correlation_sources=["playbook"], reasons=["high_stale_days"], completed_at=NOW, ) item = _build_completion_queue_item(inbox_item) assert item.readiness == "completed" assert item.next_action == "view_stale_km_completion_audit" assert item.recommended_completion_outcome == "archive" assert item.can_preview is False assert item.can_confirm_after_preview is False assert item.writes_km_on_confirm is False def test_completion_batch_preview_fingerprint_is_stable_and_read_only(self): request = KnowledgeStaleOwnerReviewCompletionBatchPreviewRequest( project_id="awoooi", status_bucket="ready", priority_tiers=["P0", "P1"], limit=10, owner="operator_console", owner_note="top ready items", ) items = [ KnowledgeStaleOwnerReviewCompletionQueueItem( dispatch_id="dispatch-ready-001", governance_event_id="event-001", entry_id="km-001", project_id="awoooi", title="Sentry checkout failure repair", dispatch_status="pending", workflow_stage="waiting_owner_review", readiness="ready", recommended_completion_outcome="refresh_with_evidence", next_action="preview_stale_km_review_completion", required_owner_fields=["owner_note_or_updated_content"], can_preview=True, can_confirm_after_preview=True, writes_km_on_confirm=True, priority_tier="P0", priority_score=265, recommended_action="refresh_with_evidence", stale_days=35, view_count=7, correlation_sources=["incident", "playbook"], reasons=["linked_incident"], queued_at=NOW, ) ] fingerprint = _build_completion_batch_preview_fingerprint( request=request, items=items, ) assert fingerprint.startswith("sha256:") assert len(fingerprint) == 71 def test_stale_owner_review_batch_context_is_operator_visible(self): request = KnowledgeStaleOwnerReviewBatchQueueRequest( project_id="awoooi", priority_tiers=["P0", "P1"], limit=10, owner="operator_console", dry_run=False, ) snapshot = KnowledgeReviewDraftStaleRatioSnapshot( stale_count=119, total_count=200, stale_ratio=0.595, threshold=0.2, stale_days=7, ) items = [ KnowledgeStaleOwnerReviewBatchItem( entry_id="km-001", title="Sentry checkout failure repair", priority_tier="P0", recommended_action="refresh_with_evidence", status="would_queue", workflow_stage="waiting_owner_review", ) ] fingerprint = _build_batch_queue_plan_fingerprint( request=request, items=items, snapshot=snapshot, ) ctx = _build_batch_owner_review_decision_context( batch_governance_event_id="event-batch-001", batch_dispatch_id="dispatch-batch-001", request=request, items=items, stale_ratio_snapshot=snapshot, plan_fingerprint=fingerprint, ) assert fingerprint.startswith("sha256:") assert ctx["decision_path"] == "batch_owner_review_queued" assert ctx["workflow"]["work_kind"] == "km_stale_owner_review_batch" assert ctx["workflow"]["current_stage"] == "batch_owner_review_queued" assert ctx["workflow"]["writes_km"] is False assert ctx["workflow"]["writes_km_without_approval"] is False assert ctx["worker_result"]["status"] == "batch_owner_review_queued" assert ctx["worker_result"]["queued_count"] == 1 assert ctx["ownership"]["lead_agent"] == "Hermes" def test_stale_owner_review_context_is_operator_visible(self): candidate = { "entry_id": "km-001", "project_id": "awoooi", "title": "Sentry checkout failure repair", "stale_days": 35, "priority_tier": "P0", "priority_score": 265, "recommended_action": "refresh_with_evidence", "correlation_sources": ["incident", "playbook", "sentry", "signoz"], "related_incident_id": "INC-20260513-79ED5E", "related_playbook_id": "pb:auto-repair-canary", "related_approval_id": "approval-001", } ctx = _build_stale_owner_review_decision_context( governance_event_id="event-001", entry_id="km-001", candidate=candidate, owner="operator_console", owner_note="prioritize P0", ) assert ctx["decision_path"] == "pending_owner_review" assert ctx["next_action"] == "owner_review_stale_km_candidate" assert ctx["workflow"]["work_kind"] == "km_stale_owner_review" assert ctx["workflow"]["current_stage"] == "waiting_owner_review" assert ctx["workflow"]["entry_id"] == "km-001" assert ctx["workflow"]["writes_km"] is False assert ctx["workflow"]["writes_km_without_approval"] is False assert ctx["workflow"]["stage_by_dispatch_status"]["pending"] == "waiting_owner_review" assert ctx["worker_result"]["status"] == "queued_owner_review" assert ctx["ownership"]["lead_agent"] == "Hermes" def test_complete_stale_candidate_endpoint_returns_writeback_audit_result(self, client): """Owner 審核完成後要回傳 KM write / audit / stale ratio recheck 狀態。""" fake = KnowledgeStaleOwnerReviewCompleteResponse( entry_id="km-001", project_id="awoooi", status="completed", review_outcome="refresh_with_evidence", governance_event_id="event-001", dispatch_id="dispatch-001", audit_dispatch_id="dispatch-audit-001", stale_ratio_recheck_dispatch_id="dispatch-recheck-001", workflow_stage="km_writeback_after_approval", owner="operator_console", owner_approved=True, dry_run=False, writes_km=True, writes_governance_audit=True, stale_ratio_snapshot=KnowledgeReviewDraftStaleRatioSnapshot( stale_count=119, total_count=200, stale_ratio=0.595, threshold=0.2, stale_days=7, ), dry_run_plan_fingerprint="sha256:" + "a" * 64, generated_at=NOW, ) captured: dict = {} async def mock_complete(**kwargs): captured.update(kwargs) return fake with patch( "src.api.v1.ai_governance.complete_km_stale_owner_review", new=mock_complete, ): r = client.post( "/api/v1/ai/governance/km-stale-candidates/km-001/complete-review", json={ "dispatch_id": "dispatch-001", "owner": "operator_console", "owner_approved": True, "dry_run": False, "review_outcome": "refresh_with_evidence", "owner_note": "Reviewed Sentry and SigNoz evidence.", "dry_run_plan_fingerprint": "sha256:" + "a" * 64, }, ) assert r.status_code == 200 assert captured["entry_id"] == "km-001" assert isinstance(captured["request"], KnowledgeStaleOwnerReviewCompleteRequest) data = r.json() assert data["schema_version"] == "km_stale_owner_review_complete_v1" assert data["status"] == "completed" assert data["workflow_stage"] == "km_writeback_after_approval" assert data["writes_km"] is True assert data["writes_governance_audit"] is True assert data["audit_dispatch_id"] == "dispatch-audit-001" assert data["stale_ratio_recheck_dispatch_id"] == "dispatch-recheck-001" assert data["stale_ratio_snapshot"]["stale_ratio"] == pytest.approx(0.595) def test_complete_stale_candidate_endpoint_maps_validation_error(self, client): async def mock_complete(**kwargs): raise KmStaleOwnerReviewError(403, "owner_approved=true is required") with patch( "src.api.v1.ai_governance.complete_km_stale_owner_review", new=mock_complete, ): r = client.post( "/api/v1/ai/governance/km-stale-candidates/km-001/complete-review", json={ "dispatch_id": "dispatch-001", "review_outcome": "archive", "dry_run": False, }, ) assert r.status_code == 403 assert r.json()["detail"] == "owner_approved=true is required" def test_stale_owner_review_completion_fingerprint_and_audit_are_visible(self): record = KnowledgeEntryRecord( id="km-001", project_id="awoooi", title="Sentry checkout failure repair", content="old evidence", entry_type=EntryType.AUTO_RUNBOOK, category="AI系統", tags=["sentry"], source=EntrySource.AI_EXTRACTED, status=EntryStatus.REVIEW, updated_at=NOW - timedelta(days=35), ) dispatch = GovernanceRemediationDispatch( id="dispatch-001", governance_event_id="event-001", event_type="knowledge_degradation", dispatch_status="pending", decision_context={ "workflow": { "entry_id": "km-001", "steps": ["detected", "waiting_owner_review"], } }, executor_type="hermes_km_stale_owner_review", dispatched_at=NOW, ) request = KnowledgeStaleOwnerReviewCompleteRequest( dispatch_id="dispatch-001", owner="operator_console", owner_approved=True, dry_run=False, review_outcome="refresh_with_evidence", owner_note="Reviewed Incident, Sentry, SigNoz, and PlayBook.", ) snapshot = KnowledgeReviewDraftStaleRatioSnapshot( stale_count=119, total_count=200, stale_ratio=0.595, threshold=0.2, stale_days=7, ) fingerprint = _build_completion_plan_fingerprint( record=record, dispatch=dispatch, request=request, superseded_by=None, ) audit = _build_owner_review_completion_audit_context( governance_event_id="event-001", source_dispatch_id="dispatch-001", entry=record, request=request, stale_ratio_snapshot=snapshot, recheck_dispatch_id="dispatch-recheck-001", plan_fingerprint=fingerprint, completed_at=NOW.isoformat(), ) recheck = _build_stale_owner_review_recheck_context( governance_event_id="event-001", entry_id="km-001", request=request, stale_ratio_snapshot=snapshot, plan_fingerprint=fingerprint, ) assert fingerprint.startswith("sha256:") assert _completion_stage_for_outcome("refresh_with_evidence") == "km_writeback_after_approval" assert audit["workflow"]["current_stage"] == "km_writeback_after_approval" assert audit["workflow"]["writes_km_without_approval"] is False assert audit["worker_result"]["status"] == "owner_review_completed" assert audit["worker_result"]["above_threshold"] is True assert audit["stale_ratio_recheck"]["dispatch_id"] == "dispatch-recheck-001" assert recheck["workflow"]["current_stage"] == "stale_ratio_recheck" assert recheck["workflow"]["stale_ratio_snapshot"]["stale_ratio"] == pytest.approx(0.595) def test_archive_endpoint_requires_owner_shape_and_returns_audit_result(self, client): """Owner 批准後的 archive endpoint 應回傳 KM write 與 audit write 結果。""" fake = KnowledgeReviewDraftArchiveResponse( governance_event_id="event-001", canonical_entry_id="km-canonical", requested_duplicate_entry_ids=["km-dup-1", "km-dup-2"], archived_entry_ids=["km-dup-1", "km-dup-2"], status="archived", owner="operator_console", owner_approved=True, dry_run=False, writes_km=True, writes_governance_audit=True, audit_dispatch_id="dispatch-audit-001", stale_ratio_snapshot=KnowledgeReviewDraftStaleRatioSnapshot( stale_count=120, total_count=200, stale_ratio=0.6, threshold=0.2, stale_days=7, ), stale_ratio_recheck_status="completed", stale_ratio_recheck_dispatch_id="dispatch-recheck-001", generated_at=NOW, ) captured: dict = {} async def mock_archive(**kwargs): captured.update(kwargs) return fake with patch( "src.api.v1.ai_governance.archive_km_review_draft_duplicates", new=mock_archive, ): r = client.post( "/api/v1/ai/governance/km-review-drafts/dedupe/event-001/archive-duplicates", json={ "canonical_entry_id": "km-canonical", "duplicate_entry_ids": ["km-dup-1", "km-dup-2"], "owner": "operator_console", "owner_approved": True, "dry_run": False, "dry_run_plan_fingerprint": "sha256:" + "a" * 64, }, ) assert r.status_code == 200 assert captured["governance_event_id"] == "event-001" assert captured["request"].owner_approved is True assert captured["request"].dry_run_plan_fingerprint == "sha256:" + "a" * 64 data = r.json() assert data["status"] == "archived" assert data["writes_km"] is True assert data["writes_governance_audit"] is True assert data["audit_dispatch_id"] == "dispatch-audit-001" assert data["stale_ratio_recheck_status"] == "completed" assert data["stale_ratio_recheck_dispatch_id"] == "dispatch-recheck-001" assert data["stale_ratio_snapshot"]["stale_ratio"] == pytest.approx(0.6) def test_archive_endpoint_maps_validation_error_to_http(self, client): async def mock_archive(**kwargs): raise KmReviewDraftArchiveError(409, "stale plan") with patch( "src.api.v1.ai_governance.archive_km_review_draft_duplicates", new=mock_archive, ): r = client.post( "/api/v1/ai/governance/km-review-drafts/dedupe/event-001/archive-duplicates", json={ "canonical_entry_id": "km-canonical", "duplicate_entry_ids": ["km-dup-1"], "owner_approved": True, }, ) assert r.status_code == 409 assert r.json()["detail"] == "stale plan" def test_archive_plan_validation_rejects_stale_duplicates(self): group = KnowledgeReviewDraftDedupeGroup( governance_event_id="event-001", canonical_entry_id="km-canonical", canonical_title="canonical", preferred_source="dispatch_context", duplicate_entry_ids=["km-dup-1", "km-dup-2"], duplicate_count=2, total_entries=3, suggested_action="owner_review_canonical_then_archive_duplicates", owner_action="review_canonical_and_archive_duplicate_drafts", writes_on_read=False, can_archive_without_owner_approval=False, ) request = KnowledgeReviewDraftArchiveRequest( canonical_entry_id="km-canonical", duplicate_entry_ids=["km-dup-1"], owner_approved=True, ) with pytest.raises(KmReviewDraftArchiveError) as exc: _validate_archive_request_against_plan(group, request, ["km-dup-1"]) assert exc.value.status_code == 409 assert "latest dedupe plan" in exc.value.detail def test_archive_plan_fingerprint_is_required_before_write(self): group = KnowledgeReviewDraftDedupeGroup( governance_event_id="event-001", canonical_entry_id="km-canonical", canonical_title="canonical", preferred_source="dispatch_context", duplicate_entry_ids=["km-dup-2", "km-dup-1"], duplicate_count=2, total_entries=3, suggested_action="owner_review_canonical_then_archive_duplicates", owner_action="review_canonical_and_archive_duplicate_drafts", writes_on_read=False, can_archive_without_owner_approval=False, ) fingerprint = _build_dry_run_plan_fingerprint("event-001", group) assert fingerprint.startswith("sha256:") assert fingerprint == _build_dry_run_plan_fingerprint("event-001", group) _validate_dry_run_plan_fingerprint(fingerprint, fingerprint) with pytest.raises(KmReviewDraftArchiveError) as missing: _validate_dry_run_plan_fingerprint(None, fingerprint) assert missing.value.status_code == 403 assert "dry_run_plan_fingerprint" in missing.value.detail with pytest.raises(KmReviewDraftArchiveError) as mismatch: _validate_dry_run_plan_fingerprint("sha256:" + "0" * 64, fingerprint) assert mismatch.value.status_code == 409 assert "latest dedupe plan" in mismatch.value.detail def test_stale_ratio_recheck_context_is_operator_visible(self): snapshot = KnowledgeReviewDraftStaleRatioSnapshot( stale_count=120, total_count=200, stale_ratio=0.6, threshold=0.2, stale_days=7, ) request = KnowledgeReviewDraftArchiveRequest( canonical_entry_id="km-canonical", duplicate_entry_ids=["km-dup-1", "km-dup-2"], owner="operator_console", owner_approved=True, dry_run_plan_fingerprint="sha256:" + "a" * 64, ) ctx = _build_stale_ratio_recheck_context( governance_event_id="event-001", request=request, archived_ids=["km-dup-1", "km-dup-2"], stale_ratio_snapshot=snapshot, ) assert ctx["next_action"] == "run_stale_ratio_recheck" assert ctx["decision_path"] == "owner_approved_recheck_after_archive" assert ctx["workflow"]["work_kind"] == "km_stale_ratio_recheck" assert ctx["workflow"]["current_stage"] == "stale_ratio_recheck" assert ctx["workflow"]["stage_by_dispatch_status"]["pending"] == "stale_ratio_recheck" assert ctx["workflow"]["writes_km"] is False assert ctx["workflow"]["dry_run_plan_fingerprint"] == "sha256:" + "a" * 64 assert ctx["workflow"]["stale_ratio_snapshot"]["stale_ratio"] == pytest.approx(0.6) assert ctx["worker_result"]["status"] == "stale_ratio_rechecked" assert ctx["worker_result"]["above_threshold"] is True # ============================================================================= # 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)