""" Trust Drift Watchdog 整合測試 ============================== P3.1-T2 by Claude 2026-04-27 — Tier-2 三服務感知強化 2026-05-02 ogt + Claude Sonnet 4.6(亞太): 整併雙寫路徑 W-6 改呼叫 governance_agent.check_trust_drift()(唯一 source-of-truth) TrustDriftDetector 降為 lib only,run() 不再自動寫 PG 驗證: 1. ai_slo_watchdog_job W-6 呼叫 governance_agent.check_trust_drift() 2. drift 偵測到時 violation 被加入 violations list 3. 無 drift 時不加入 violations list 4. get_trust_drift_detector() singleton 可正常取得 5. TrustDriftDetector.run() 方法存在且可呼叫(lib only,不寫 PG) 注意:不依賴真實 DB — 全 mock 測試 """ from __future__ import annotations import pytest from unittest.mock import AsyncMock, MagicMock, patch # ───────────────────────────────────────────────────────────────────────────── # Stubs # ───────────────────────────────────────────────────────────────────────────── def _make_dist(drift_detected: bool, drift_type: str | None = None, high_ratio: float = 0.0, low_ratio: float = 0.0, total: int = 20) -> object: """建立 TrustDistribution stub""" dist = MagicMock() dist.drift_detected = drift_detected dist.drift_type = drift_type dist.high_ratio = high_ratio dist.low_ratio = low_ratio dist.total = total return dist # ───────────────────────────────────────────────────────────────────────────── # Test: get_trust_drift_detector singleton # ───────────────────────────────────────────────────────────────────────────── class TestGetTrustDriftDetectorSingleton: def test_singleton_returns_same_instance(self): """同一 process 內兩次呼叫回傳相同 instance""" import importlib import src.services.trust_drift_detector as m # 重設 singleton original = m._detector m._detector = None try: a = m.get_trust_drift_detector() b = m.get_trust_drift_detector() assert a is b finally: m._detector = original def test_singleton_has_run_method(self): """TrustDriftDetector 必須有 run() 方法""" from src.services.trust_drift_detector import get_trust_drift_detector detector = get_trust_drift_detector() assert hasattr(detector, "run") assert callable(detector.run) def test_singleton_has_detect_method(self): """TrustDriftDetector 必須有 detect() 方法""" from src.services.trust_drift_detector import get_trust_drift_detector detector = get_trust_drift_detector() assert hasattr(detector, "detect") # ───────────────────────────────────────────────────────────────────────────── # Test: W-6 watchdog 呼叫 trust_drift_detector.run() # ───────────────────────────────────────────────────────────────────────────── class TestWatchdogW6TrustDrift: """W-6 改呼叫 governance_agent.check_trust_drift() — 2026-05-02 整併雙寫路徑""" @pytest.mark.asyncio async def test_w6_drift_detected_adds_violation(self): """drifted > 0 時 W-6 應在 violations list 加入字串""" mock_agent = AsyncMock() mock_agent.check_trust_drift = AsyncMock(return_value={ "checked": 25, "drifted": 3, "auto_deprecated": 1, "kept": 2, }) violations: list[str] = [] with patch( "src.services.governance_agent.get_governance_agent", return_value=mock_agent, ): from src.services.governance_agent import get_governance_agent trust_result = await get_governance_agent().check_trust_drift() if trust_result.get("drifted", 0) > 0: drifted = trust_result["drifted"] auto_deprecated = trust_result.get("auto_deprecated", 0) kept = trust_result.get("kept", 0) violations.append( f"Trust Drift 偵測到 {drifted} 個 Playbook 信任度低落" f"(auto-deprecated: {auto_deprecated},待人工審核: {kept})" ) assert len(violations) == 1 assert "Trust Drift" in violations[0] assert "3 個 Playbook 信任度低落" in violations[0] assert "auto-deprecated: 1" in violations[0] @pytest.mark.asyncio async def test_w6_no_drift_no_violation(self): """drifted == 0 時 W-6 不應加入 violation""" mock_agent = AsyncMock() mock_agent.check_trust_drift = AsyncMock(return_value={ "checked": 15, "drifted": 0, "auto_deprecated": 0, "kept": 0, }) violations: list[str] = [] with patch( "src.services.governance_agent.get_governance_agent", return_value=mock_agent, ): from src.services.governance_agent import get_governance_agent trust_result = await get_governance_agent().check_trust_drift() if trust_result.get("drifted", 0) > 0: violations.append("Trust Drift violation") assert len(violations) == 0 @pytest.mark.asyncio async def test_w6_exception_isolated(self): """W-6 呼叫失敗時不應 raise,violations list 保持空""" mock_agent = MagicMock() mock_agent.check_trust_drift = AsyncMock(side_effect=Exception("DB connection failed")) violations: list[str] = [] try: with patch( "src.services.governance_agent.get_governance_agent", return_value=mock_agent, ): from src.services.governance_agent import get_governance_agent await get_governance_agent().check_trust_drift() except Exception: pass # 外層 watchdog catch,此處模擬 try/except 隔離 assert len(violations) == 0 @pytest.mark.asyncio async def test_w6_auto_deprecated_reflected_in_violation(self): """auto_deprecated 數量應正確反映在 violation 訊息中""" mock_agent = AsyncMock() mock_agent.check_trust_drift = AsyncMock(return_value={ "checked": 30, "drifted": 5, "auto_deprecated": 4, "kept": 1, }) violations: list[str] = [] with patch( "src.services.governance_agent.get_governance_agent", return_value=mock_agent, ): from src.services.governance_agent import get_governance_agent trust_result = await get_governance_agent().check_trust_drift() if trust_result.get("drifted", 0) > 0: drifted = trust_result["drifted"] auto_deprecated = trust_result.get("auto_deprecated", 0) kept = trust_result.get("kept", 0) violations.append( f"Trust Drift 偵測到 {drifted} 個 Playbook 信任度低落" f"(auto-deprecated: {auto_deprecated},待人工審核: {kept})" ) assert "auto-deprecated: 4" in violations[0] assert "待人工審核: 1" in violations[0] # ───────────────────────────────────────────────────────────────────────────── # Test: watchdog W-6 已在 ai_slo_watchdog_job._check_once() 中存在 # ───────────────────────────────────────────────────────────────────────────── class TestWatchdogW6Wiring: def test_w6_code_calls_governance_agent_check_trust_drift(self): """確認 ai_slo_watchdog_job.py W-6 改呼叫 governance_agent.check_trust_drift() 2026-05-02 ogt + Claude Sonnet 4.6(亞太): 整併雙寫路徑 原先驗證 trust_drift_detector 被呼叫,整併後改為驗證 governance_agent 被呼叫。 detector 降為 lib only,watchdog 不再直接呼叫 detector。 """ import inspect from src.jobs import ai_slo_watchdog_job source = inspect.getsource(ai_slo_watchdog_job) assert "governance_agent" in source, "W-6 應改為呼叫 governance_agent" assert "check_trust_drift" in source, "W-6 應呼叫 governance_agent.check_trust_drift()" # 確認舊路徑已移除 assert "get_trust_drift_detector" not in source, ( "W-6 不應再直接呼叫 get_trust_drift_detector()(雙寫路徑已整併)" ) def test_watchdog_loop_imported_in_watchdog_module(self): """run_ai_slo_watchdog_loop 函式必須可正常 import""" from src.jobs.ai_slo_watchdog_job import run_ai_slo_watchdog_loop assert callable(run_ai_slo_watchdog_loop) def test_detector_run_does_not_call_save_drift_event(self): """TrustDriftDetector.run() 整併後不應自動呼叫 save_drift_event() 2026-05-02 ogt + Claude Sonnet 4.6(亞太): 驗收標準 — lib only AST 分析:run() 的 body 中不應出現 save_drift_event 呼叫。 原實作:run() 會 if dist.drift_detected: await self.save_drift_event(dist) 整併後:run() 只回傳 detect() 的結果,不寫 PG。 """ import ast from pathlib import Path src_path = ( Path(__file__).resolve().parents[1] / "src" / "services" / "trust_drift_detector.py" ) tree = ast.parse(src_path.read_text()) run_func = None for node in ast.walk(tree): if isinstance(node, ast.AsyncFunctionDef) and node.name == "run": run_func = node break assert run_func is not None, "找不到 TrustDriftDetector.run()" for sub in ast.walk(run_func): if ( isinstance(sub, ast.Call) and isinstance(sub.func, ast.Attribute) and sub.func.attr == "save_drift_event" ): raise AssertionError( "BUG:TrustDriftDetector.run() 不應呼叫 save_drift_event()。" "整併後 run() 為 lib only,PG 寫入由 governance_agent 統一負責。" ) # ───────────────────────────────────────────────────────────────────────────── # Test: 同一 drift 場景只觸發一次 PG 寫入(驗收標準 #4) # ───────────────────────────────────────────────────────────────────────────── class TestSinglePgWritePerDriftScenario: """驗收標準 #4:同一 drift 場景只觸發一次 PG 寫入 2026-05-02 ogt + Claude Sonnet 4.6(亞太): 整併雙寫路徑驗收 整併前:watchdog W-6 呼叫 detector.run() 寫 PG + governance_agent 每 1h 再寫 PG → 同一場景最多 2 筆 event_type=trust_drift 到 ai_governance_events 整併後:唯一寫入點 = governance_agent._alert("trust_drift", ...) → 同一場景只有 1 次 PG 寫入 """ @pytest.mark.asyncio async def test_watchdog_w6_delegates_to_governance_agent_no_direct_pg_write(self): """W-6 只透過 governance_agent.check_trust_drift(),不直接呼叫 AiGovernanceEvent insert 驗證:watchdog W-6 觸發時,底層的 PG 寫入由 governance_agent 負責, TrustDriftDetector.save_drift_event() 不被呼叫。 """ from unittest.mock import AsyncMock, patch, MagicMock trust_result = { "checked": 20, "drifted": 3, "auto_deprecated": 1, "kept": 2, } mock_agent = AsyncMock() mock_agent.check_trust_drift = AsyncMock(return_value=trust_result) save_drift_event_calls: list = [] async def _mock_save(dist): save_drift_event_calls.append(dist) violations: list[str] = [] with patch("src.services.governance_agent.get_governance_agent", return_value=mock_agent): from src.services.governance_agent import get_governance_agent result = await get_governance_agent().check_trust_drift() if result.get("drifted", 0) > 0: drifted = result["drifted"] auto_deprecated = result.get("auto_deprecated", 0) kept = result.get("kept", 0) violations.append( f"Trust Drift 偵測到 {drifted} 個 Playbook 信任度低落" f"(auto-deprecated: {auto_deprecated},待人工審核: {kept})" ) # W-6 透過 governance_agent — violations 有 1 筆 assert len(violations) == 1 # save_drift_event 未被直接呼叫(PG 寫入由 governance_agent._alert 統一負責) assert len(save_drift_event_calls) == 0, ( f"save_drift_event 被呼叫了 {len(save_drift_event_calls)} 次," "應為 0(整併後 W-6 不直接寫 PG)" ) def test_detector_run_is_lib_only_no_pg_import_path(self): """TrustDriftDetector.run() 整併後不呼叫 save_drift_event(AST 靜態驗證) 2026-05-02 ogt + Claude Sonnet 4.6(亞太): 使用 AST 驗證實際呼叫,避免 docstring 誤判。 """ import ast from pathlib import Path src_path = ( Path(__file__).resolve().parents[1] / "src" / "services" / "trust_drift_detector.py" ) tree = ast.parse(src_path.read_text()) run_func = None for node in ast.walk(tree): if isinstance(node, ast.AsyncFunctionDef) and node.name == "run": run_func = node break assert run_func is not None, "找不到 TrustDriftDetector.run()" # AST 驗證:run() 內不應有 save_drift_event 呼叫(docstring 不算) for sub in ast.walk(run_func): if ( isinstance(sub, ast.Call) and isinstance(sub.func, ast.Attribute) and sub.func.attr == "save_drift_event" ): raise AssertionError( "BUG:TrustDriftDetector.run() 不應呼叫 save_drift_event()。" "整併後 run() 為 lib only,PG 寫入由 governance_agent 統一負責。" ) # 確認 run() 有呼叫 detect()(核心統計仍保留) found_detect = any( isinstance(sub, ast.Call) and isinstance(sub.func, ast.Attribute) and sub.func.attr == "detect" for sub in ast.walk(run_func) ) assert found_detect, "run() 應呼叫 detect() 並回傳結果"