Some checks failed
Code Review / ai-code-review (push) Successful in 48s
run-migration / migrate (push) Failing after 45s
CD Pipeline / tests (push) Successful in 3m46s
Type Sync Check / check-type-sync (push) Successful in 2m8s
CD Pipeline / build-and-deploy (push) Failing after 31m14s
CD Pipeline / post-deploy-checks (push) Has been skipped
【十二人專家團隊全景掃描 + 並行四軌實施】
統帥質疑「有讓 12-agent 一起協作嗎」後,依照團隊規則完成全鏈路交付:
onboarder + critic + db-expert + debugger + frontend-designer 並行掃描,
找到 6 大 Gap,再由 fullstack-engineer × 4、refactor-specialist 協作落地。
【Track C — trust_drift 雙寫整併】
兩條獨立寫 event_type=trust_drift 路徑互不呼叫,下游 consumer 拿到雙份資料
無法判定 source-of-truth。整併保留 governance_agent.check_trust_drift(功能
更全:auto-deprecate + Telegram + PG),TrustDriftDetector 降為純統計 lib,
W-6 watchdog 改呼叫 governance_agent。新增 TestSinglePgWritePerDriftScenario
驗證同一 drift 場景只觸發一次 PG 寫入。
變更:
- apps/api/src/services/trust_drift_detector.py(lib only,不再寫 PG)
- apps/api/tests/test_trust_drift_watchdog.py(W-6 改 mock governance_agent)
【Track D — governance_remediation_dispatch 派遣表】
ai_governance_events 是不可變 Event Sourcing,不能塞執行狀態。新建派遣表
作為投影層:1 event → 0..N dispatches,狀態可變、可重試、可審計。
- PgEnum 5 種 event_type + 7 階段狀態機(pending → dispatched → executing →
succeeded/failed/cancelled/skipped)
- 失敗重試 INSERT 新 row(不改舊 row 的 status,保留審計痕跡)
- Partial unique index ux_grd_one_active_per_event 強制「同事件唯一活躍」
- 4 個複合 index 支援 worker poll、去重查詢、觀測面板
- FK 對應 ai_governance_events / playbooks / incidents / approval_records
全部 SET NULL(avoid cascade lock,但 governance_event 用 RESTRICT)
變更:
- apps/api/src/db/models.py(GovernanceRemediationDispatch ORM class)
- apps/api/migrations/governance_remediation_dispatch_2026-05-03.sql
- apps/api/src/repositories/governance_remediation_dispatch_repo.py
(6 個 async 函式 + 3 個自訂例外:DispatchAlreadyActive /
InvalidStatusTransition / DispatchNotFound)
- apps/api/src/models/governance_dispatch.py(DecisionContextV1 等 4 schema)
- apps/api/tests/test_governance_remediation_dispatch.py(29 tests)
【Track B — /governance 頁面】
後端 PR1 三個 endpoint + 前端 PR2-5 完整三 Tab。
PR1 後端:
- GET /api/v1/ai/governance/events(events_tab,含 event_type/severity/
狀態/時間範圍篩選 + 分頁)
- GET /api/v1/ai/governance/queue(queue_tab,含 graceful fallback:
dispatch 表不存在時回 table_pending=True 不拋 500)
- GET /api/v1/ai/governance/summary(slo_tab 30d 違反時序圖)
- severity 映射規則寫死(critic 建議未來移 settings)
PR2-5 前端:
- /governance 路由 + AppLayout + Compliance Badge 橫幅 + PageTabs
- SLO Tab:3 KPI 卡片(Syne 28px + StatusOrb + 7d sparkline)+
30d 違反 stacked BarChart
- Events Tab:篩選列 + 表格 + inline 展開行(JSON / 修復建議 / 派遣記錄)
- Queue Tab:HITL 待辦卡片 + 信任度進度條 + 批准/拒絕按鈕(本 PR console.log)
- Sidebar 加入「AI 治理」入口(ShieldCheck icon)
- i18n 雙語完整(governance namespace + nav.governance)
- 7 個新元件:slo-kpi-card / slo-violation-chart / events-table /
events-filter-bar / event-detail-drawer / queue-item-card / queue-history-tabs
變更:
- apps/api/src/api/v1/ai_governance.py(router)
- apps/api/src/services/governance_query_service.py
- apps/api/src/models/governance.py(Pydantic V2 schemas)
- apps/api/tests/test_ai_governance_endpoints.py(21 tests)
- apps/web/src/app/[locale]/governance/(page + 3 tabs)
- apps/web/src/components/governance/(7 元件)
- apps/web/messages/{zh-TW,en}.json(governance namespace)
- apps/web/src/components/layout/sidebar.tsx(+1 行)
- apps/api/src/main.py(router include)
【Track A — GovernanceDispatcher 決策融合】
把治理事件接到 remediation 執行器,走北極星方向決策融合(LLM × Playbook trust
× MCP),符合「禁寫死規則」鐵律。
- 設計鐵律:DecisionFusionAdapter 是新增 wrapper,**不修改任何 Tier 3 檔**
(decision_manager / learning_service / trust_engine),只 consume 既有 API
- 三維融合公式:confidence = 0.4×llm + 0.3×playbook_trust + 0.3×mcp_consistency
(權重加 TODO 標明未來由 AI 自學調整)
- 三分支決策路徑:
confidence ≥ 0.85 → auto_dispatch(status=dispatched)
0.65 ≤ confidence < 0.85 → pending_approval(HITL)
confidence < 0.65 → skip + log
- decision_context JSONB 完整記錄三維輸入快照(給未來 fine-tune 用)
- poll 30s 掃 unresolved 事件,仿 governance loop 模式
- 重複事件擋去重(呼叫 get_active_for_event)
變更:
- apps/api/src/services/governance_dispatcher.py
- apps/api/src/services/decision_fusion_adapter.py
- apps/api/tests/test_governance_dispatcher.py(14 tests)
- apps/api/src/main.py(lifespan task 接 run_governance_dispatcher_loop)
【驗證】
1836 個 unit test 全過(29 skipped 為既有 PG integration env 問題)
【調度教訓 — 已記入 memory】
- vuln-verifier 應在 fullstack-engineer **之前**跑(避免並行讀到已修代碼誤判)
- critic 雙輪審查不可省(第二輪抓到 NaN sentinel + Prom rule 連鎖)
- 北極星「禁寫死規則」搭配 decision-fusion 確實實施
【未動 Tier 3 — 已驗證】
git diff 確認本 commit 完全沒改 decision_manager.py / learning_service.py /
trust_engine.py,只新增 wrapper service consume 既有 API。
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
446 lines
17 KiB
Python
446 lines
17 KiB
Python
# apps/api/tests/test_governance_dispatcher.py | 2026-05-03 @ Asia/Taipei
|
||
"""
|
||
Unit Tests — GovernanceDispatcher (Wave 2E)
|
||
|
||
覆蓋範圍:
|
||
1. high confidence (>= 0.85) → decision_path=auto_dispatch → status=pending(dispatch 建立)
|
||
2. mid confidence (0.65-0.85) → decision_path=pending_approval → dispatch 建立(executor=manual)
|
||
3. low confidence (< 0.65) → decision_path=skip → 不寫 dispatch,返回 None
|
||
4. 重複事件:get_active_for_event 有值 → 不重複 dispatch(返回 None)
|
||
5. LLM 失敗 fallback:fusion 拋 Exception → skip + log,不寫 dispatch
|
||
6. _build_decision_context 完整三維欄位驗證
|
||
|
||
測試策略:mock DB / adapter / repo,不依賴真實 Postgres。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime, timezone, timedelta
|
||
from typing import Any
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
# ─── 模擬 AiGovernanceEvent(避免 DB 連線)────────────────────────────────
|
||
|
||
TAIPEI = timezone(timedelta(hours=8))
|
||
NOW = datetime(2026, 5, 3, 12, 0, tzinfo=TAIPEI)
|
||
|
||
|
||
def _make_governance_event(
|
||
event_id: str = "evt-001",
|
||
event_type: str = "trust_drift",
|
||
) -> MagicMock:
|
||
"""建立 mock AiGovernanceEvent。"""
|
||
evt = MagicMock()
|
||
evt.id = event_id
|
||
evt.event_type = event_type
|
||
evt.triggered_at = NOW
|
||
evt.resolved = False
|
||
evt.details = {
|
||
"status": "warning",
|
||
"impact": {"drifted_count": 3, "total_playbooks": 10},
|
||
"remediation": {"next_action": "review_playbooks"},
|
||
}
|
||
return evt
|
||
|
||
|
||
# ─── FusedDecision factory ──────────────────────────────────────────────────
|
||
|
||
def _make_fused_decision(
|
||
confidence: float = 0.9,
|
||
playbook_id: str | None = "pb-001",
|
||
) -> MagicMock:
|
||
"""建立 mock FusedDecision。"""
|
||
from src.services.decision_fusion_adapter import FusedDecision
|
||
|
||
if confidence >= 0.85:
|
||
path = "auto_dispatch"
|
||
elif confidence >= 0.65:
|
||
path = "pending_approval"
|
||
else:
|
||
path = "skip"
|
||
|
||
return FusedDecision(
|
||
confidence=confidence,
|
||
recommended_action="啟動 Playbook 信任度修復流程",
|
||
matched_playbook_id=playbook_id,
|
||
playbook_trust=0.7 if playbook_id else None,
|
||
llm_reasoning={"parsed_confidence": confidence, "parsed_action": "review"},
|
||
mcp_snapshot={"autonomy_rate": 0.82, "_meta": {"success_count": 2, "total_queries": 2}},
|
||
decision_path=path,
|
||
llm_score=confidence,
|
||
playbook_score=0.7 if playbook_id else 0.3,
|
||
mcp_score=0.8,
|
||
)
|
||
|
||
|
||
# =============================================================================
|
||
# Tests — dispatch_governance_event
|
||
# =============================================================================
|
||
|
||
class TestDispatchGovernanceEvent:
|
||
"""dispatch_governance_event 核心邏輯測試。"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_high_confidence_creates_auto_dispatch(self):
|
||
"""confidence >= 0.85 → decision_path=auto_dispatch → dispatch 建立,executor=playbook_executor。"""
|
||
event = _make_governance_event()
|
||
decision = _make_fused_decision(confidence=0.90)
|
||
|
||
mock_dispatch_row = MagicMock()
|
||
mock_dispatch_row.id = "dispatch-001"
|
||
|
||
with (
|
||
patch(
|
||
"src.services.governance_dispatcher.get_active_for_event",
|
||
new=AsyncMock(return_value=None),
|
||
),
|
||
patch(
|
||
"src.services.governance_dispatcher.get_decision_fusion_adapter",
|
||
) as mock_adapter_factory,
|
||
patch(
|
||
"src.services.governance_dispatcher.create_dispatch",
|
||
new=AsyncMock(return_value=mock_dispatch_row),
|
||
) as mock_create,
|
||
):
|
||
mock_adapter = MagicMock()
|
||
mock_adapter.fuse_decision = AsyncMock(return_value=decision)
|
||
mock_adapter_factory.return_value = mock_adapter
|
||
|
||
from src.services.governance_dispatcher import dispatch_governance_event
|
||
result = await dispatch_governance_event(event)
|
||
|
||
assert result == "dispatch-001"
|
||
mock_create.assert_awaited_once()
|
||
call_kwargs = mock_create.call_args
|
||
assert call_kwargs.kwargs["executor_type"] == "playbook_executor"
|
||
assert call_kwargs.kwargs["event_id"] == "evt-001"
|
||
assert call_kwargs.kwargs["event_type"] == "trust_drift"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_mid_confidence_creates_pending_approval(self):
|
||
"""0.65 <= confidence < 0.85 → decision_path=pending_approval → executor=manual。"""
|
||
event = _make_governance_event()
|
||
decision = _make_fused_decision(confidence=0.75)
|
||
|
||
mock_dispatch_row = MagicMock()
|
||
mock_dispatch_row.id = "dispatch-002"
|
||
|
||
with (
|
||
patch(
|
||
"src.services.governance_dispatcher.get_active_for_event",
|
||
new=AsyncMock(return_value=None),
|
||
),
|
||
patch(
|
||
"src.services.governance_dispatcher.get_decision_fusion_adapter",
|
||
) as mock_adapter_factory,
|
||
patch(
|
||
"src.services.governance_dispatcher.create_dispatch",
|
||
new=AsyncMock(return_value=mock_dispatch_row),
|
||
) as mock_create,
|
||
):
|
||
mock_adapter = MagicMock()
|
||
mock_adapter.fuse_decision = AsyncMock(return_value=decision)
|
||
mock_adapter_factory.return_value = mock_adapter
|
||
|
||
from src.services.governance_dispatcher import dispatch_governance_event
|
||
result = await dispatch_governance_event(event)
|
||
|
||
assert result == "dispatch-002"
|
||
call_kwargs = mock_create.call_args
|
||
assert call_kwargs.kwargs["executor_type"] == "manual"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_low_confidence_skips_dispatch(self):
|
||
"""confidence < 0.65 → decision_path=skip → 不寫 dispatch,返回 None。"""
|
||
event = _make_governance_event()
|
||
decision = _make_fused_decision(confidence=0.40)
|
||
|
||
with (
|
||
patch(
|
||
"src.services.governance_dispatcher.get_active_for_event",
|
||
new=AsyncMock(return_value=None),
|
||
),
|
||
patch(
|
||
"src.services.governance_dispatcher.get_decision_fusion_adapter",
|
||
) as mock_adapter_factory,
|
||
patch(
|
||
"src.services.governance_dispatcher.create_dispatch",
|
||
new=AsyncMock(),
|
||
) as mock_create,
|
||
):
|
||
mock_adapter = MagicMock()
|
||
mock_adapter.fuse_decision = AsyncMock(return_value=decision)
|
||
mock_adapter_factory.return_value = mock_adapter
|
||
|
||
from src.services.governance_dispatcher import dispatch_governance_event
|
||
result = await dispatch_governance_event(event)
|
||
|
||
assert result is None
|
||
mock_create.assert_not_awaited()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_duplicate_event_does_not_dispatch(self):
|
||
"""同一事件已有 active dispatch → 返回 None,不重複 dispatch。"""
|
||
event = _make_governance_event()
|
||
|
||
# 模擬已有活躍 dispatch
|
||
existing_dispatch = MagicMock()
|
||
existing_dispatch.id = "existing-dispatch-001"
|
||
existing_dispatch.dispatch_status = "pending"
|
||
|
||
with (
|
||
patch(
|
||
"src.services.governance_dispatcher.get_active_for_event",
|
||
new=AsyncMock(return_value=existing_dispatch),
|
||
),
|
||
patch(
|
||
"src.services.governance_dispatcher.create_dispatch",
|
||
new=AsyncMock(),
|
||
) as mock_create,
|
||
):
|
||
from src.services.governance_dispatcher import dispatch_governance_event
|
||
result = await dispatch_governance_event(event)
|
||
|
||
assert result is None
|
||
mock_create.assert_not_awaited()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_llm_failure_fallback_to_skip(self):
|
||
"""fusion adapter 拋 Exception → fallback skip,不寫 dispatch,返回 None。"""
|
||
event = _make_governance_event()
|
||
|
||
with (
|
||
patch(
|
||
"src.services.governance_dispatcher.get_active_for_event",
|
||
new=AsyncMock(return_value=None),
|
||
),
|
||
patch(
|
||
"src.services.governance_dispatcher.get_decision_fusion_adapter",
|
||
) as mock_adapter_factory,
|
||
patch(
|
||
"src.services.governance_dispatcher.create_dispatch",
|
||
new=AsyncMock(),
|
||
) as mock_create,
|
||
):
|
||
mock_adapter = MagicMock()
|
||
mock_adapter.fuse_decision = AsyncMock(
|
||
side_effect=RuntimeError("Ollama 連線失敗")
|
||
)
|
||
mock_adapter_factory.return_value = mock_adapter
|
||
|
||
from src.services.governance_dispatcher import dispatch_governance_event
|
||
result = await dispatch_governance_event(event)
|
||
|
||
assert result is None
|
||
mock_create.assert_not_awaited()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_dispatch_already_active_race_condition(self):
|
||
"""並行建立時 DispatchAlreadyActive → 靜默返回 None(冪等)。"""
|
||
event = _make_governance_event()
|
||
decision = _make_fused_decision(confidence=0.90)
|
||
|
||
from src.repositories.governance_remediation_dispatch_repo import DispatchAlreadyActive
|
||
|
||
with (
|
||
patch(
|
||
"src.services.governance_dispatcher.get_active_for_event",
|
||
new=AsyncMock(return_value=None),
|
||
),
|
||
patch(
|
||
"src.services.governance_dispatcher.get_decision_fusion_adapter",
|
||
) as mock_adapter_factory,
|
||
patch(
|
||
"src.services.governance_dispatcher.create_dispatch",
|
||
new=AsyncMock(side_effect=DispatchAlreadyActive("race")),
|
||
),
|
||
):
|
||
mock_adapter = MagicMock()
|
||
mock_adapter.fuse_decision = AsyncMock(return_value=decision)
|
||
mock_adapter_factory.return_value = mock_adapter
|
||
|
||
from src.services.governance_dispatcher import dispatch_governance_event
|
||
result = await dispatch_governance_event(event)
|
||
|
||
assert result is None
|
||
|
||
|
||
# =============================================================================
|
||
# Tests — _build_decision_context
|
||
# =============================================================================
|
||
|
||
class TestBuildDecisionContext:
|
||
"""_build_decision_context 完整三維欄位驗證。"""
|
||
|
||
def test_decision_context_has_all_required_fields(self):
|
||
"""decision_context 必須包含完整三維輸入快照。"""
|
||
from src.services.governance_dispatcher import _build_decision_context
|
||
|
||
event = _make_governance_event()
|
||
decision = _make_fused_decision(confidence=0.90)
|
||
|
||
ctx = _build_decision_context(event, decision)
|
||
|
||
# 版本化
|
||
assert ctx["version"] == "v1"
|
||
|
||
# 觸發來源
|
||
assert ctx["trigger_source"] == "governance_dispatcher"
|
||
assert ctx["triggered_metric"] == "trust_drift"
|
||
|
||
# 三維分數均記錄
|
||
fusion = ctx["fusion_scores"]
|
||
assert "llm_score" in fusion
|
||
assert "playbook_score" in fusion
|
||
assert "mcp_score" in fusion
|
||
assert "confidence" in fusion
|
||
assert "weights" in fusion
|
||
|
||
# LLM 推理摘要
|
||
assert "llm_reasoning" in ctx
|
||
assert isinstance(ctx["llm_reasoning"], dict)
|
||
|
||
# MCP 快照
|
||
assert "mcp_snapshot" in ctx
|
||
assert isinstance(ctx["mcp_snapshot"], dict)
|
||
|
||
# 決策路徑
|
||
assert ctx["decision_path"] in ("auto_dispatch", "pending_approval", "skip")
|
||
|
||
def test_decision_context_no_hardcoded_event_type_rules(self):
|
||
"""decision_context 不得含 hardcode event_type → playbook 對應規則。"""
|
||
from src.services.governance_dispatcher import _build_decision_context
|
||
|
||
for event_type in ("trust_drift", "knowledge_degradation", "llm_hallucination"):
|
||
event = _make_governance_event(event_type=event_type)
|
||
decision = _make_fused_decision(confidence=0.90)
|
||
ctx = _build_decision_context(event, decision)
|
||
|
||
# 驗證 decision 基於信心度,不是 hardcode event_type 規則
|
||
assert ctx["decision_path"] == decision.decision_path
|
||
assert ctx["fusion_scores"]["confidence"] == round(decision.confidence, 4)
|
||
|
||
|
||
# =============================================================================
|
||
# Tests — DecisionFusionAdapter._build_decision_context (adapter 本身單元)
|
||
# =============================================================================
|
||
|
||
class TestDecisionFusionAdapterHelpers:
|
||
"""DecisionFusionAdapter 靜態輔助方法測試。"""
|
||
|
||
def test_summarize_details_with_impact(self):
|
||
"""summarize_details 應提取 impact / status 等關鍵欄位。"""
|
||
from src.services.decision_fusion_adapter import DecisionFusionAdapter
|
||
|
||
details = {
|
||
"status": "warning",
|
||
"impact": {"drifted_count": 3, "threshold": 0.2},
|
||
"remediation": {"next_action": "run_playbook"},
|
||
}
|
||
summary = DecisionFusionAdapter._summarize_details(details)
|
||
|
||
assert "status" in summary
|
||
assert "warning" in summary
|
||
assert len(summary) <= 300
|
||
|
||
def test_summarize_details_empty(self):
|
||
"""空 details → 返回預設提示,不崩潰。"""
|
||
from src.services.decision_fusion_adapter import DecisionFusionAdapter
|
||
|
||
summary = DecisionFusionAdapter._summarize_details({})
|
||
assert summary == "(無詳細資訊)"
|
||
|
||
def test_get_mcp_queries_returns_base_for_all_types(self):
|
||
"""所有 event_type 都應包含基礎指標查詢。"""
|
||
from src.services.decision_fusion_adapter import DecisionFusionAdapter
|
||
|
||
for event_type in ("trust_drift", "knowledge_degradation", "llm_hallucination",
|
||
"execution_blast_radius", "governance_slo_data_gap"):
|
||
queries = DecisionFusionAdapter._get_mcp_queries(event_type)
|
||
assert "autonomy_rate" in queries
|
||
assert "decision_accuracy" in queries
|
||
assert len(queries) >= 2
|
||
|
||
def test_extract_keywords_from_details(self):
|
||
"""_extract_keywords 應從 remediation/actionable/impact 中提取關鍵字。"""
|
||
from src.services.decision_fusion_adapter import DecisionFusionAdapter
|
||
|
||
details = {
|
||
"remediation": {
|
||
"next_action": "run_kb_growth_healthcheck",
|
||
"items": ["check_index", "rebuild_embeddings"],
|
||
},
|
||
}
|
||
keywords = DecisionFusionAdapter._extract_keywords(details)
|
||
assert len(keywords) <= 5
|
||
assert "run_kb_growth_healthcheck" in keywords
|
||
|
||
|
||
# =============================================================================
|
||
# Tests — run_governance_dispatcher_loop(loop 邏輯)
|
||
# =============================================================================
|
||
|
||
class TestRunGovernanceDispatcherLoop:
|
||
"""run_governance_dispatcher_loop 排程迴圈行為測試。"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_loop_processes_events_and_sleeps(self):
|
||
"""loop 一次 cycle 應處理 events 並 sleep。"""
|
||
event = _make_governance_event()
|
||
|
||
call_count = 0
|
||
|
||
async def mock_sleep(seconds):
|
||
nonlocal call_count
|
||
call_count += 1
|
||
if call_count >= 2:
|
||
raise asyncio.CancelledError()
|
||
|
||
with (
|
||
patch(
|
||
"src.services.governance_dispatcher._poll_unresolved_events",
|
||
new=AsyncMock(return_value=[event]),
|
||
),
|
||
patch(
|
||
"src.services.governance_dispatcher.dispatch_governance_event",
|
||
new=AsyncMock(return_value="dispatch-new"),
|
||
),
|
||
patch("asyncio.sleep", side_effect=mock_sleep),
|
||
):
|
||
import asyncio
|
||
from src.services.governance_dispatcher import run_governance_dispatcher_loop
|
||
|
||
with pytest.raises(asyncio.CancelledError):
|
||
await run_governance_dispatcher_loop(interval_seconds=1)
|
||
|
||
# sleep 被呼叫至少一次
|
||
assert call_count >= 1
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_loop_no_events_does_not_crash(self):
|
||
"""無事件時 loop 應平穩 sleep,不報錯。"""
|
||
call_count = 0
|
||
|
||
async def mock_sleep(seconds):
|
||
nonlocal call_count
|
||
call_count += 1
|
||
if call_count >= 1:
|
||
raise asyncio.CancelledError()
|
||
|
||
with (
|
||
patch(
|
||
"src.services.governance_dispatcher._poll_unresolved_events",
|
||
new=AsyncMock(return_value=[]),
|
||
),
|
||
patch("asyncio.sleep", side_effect=mock_sleep),
|
||
):
|
||
import asyncio
|
||
from src.services.governance_dispatcher import run_governance_dispatcher_loop
|
||
|
||
with pytest.raises(asyncio.CancelledError):
|
||
await run_governance_dispatcher_loop(interval_seconds=1)
|
||
|
||
assert call_count >= 1
|