Some checks failed
CD Pipeline / build-and-deploy (push) Blocked by required conditions
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / post-deploy-checks (push) Blocked by required conditions
CD Pipeline / cancel-stale-cd (push) Has been skipped
Code Review / ai-code-review (push) Has been cancelled
CD Pipeline / tests (push) Failing after 14m8s
Type Sync Check / check-type-sync (push) Successful in 42s
353 lines
16 KiB
Python
353 lines
16 KiB
Python
"""
|
||
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},待 AI 受控複核: {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},待 AI 受控複核: {kept})"
|
||
)
|
||
|
||
assert "auto-deprecated: 4" in violations[0]
|
||
assert "待 AI 受控複核: 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},待 AI 受控複核: {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() 並回傳結果"
|