# apps/api/tests/test_p2_db_fixes.py # 2026-04-26 P2-DB-Fix by Claude — db-expert P0 三修 驗收測試 """ P0.1 / P0.2 / P0.3 三修驗收測試 ================================ 測試分類:unit(全部 mock DB,無真實 PG 依賴) 覆蓋: P0.1 — test_governance_agent_writes_to_pg GovernanceAgent._alert() 呼叫時,AiGovernanceEvent INSERT 被執行 P0.2 — test_consensus_engine_persists_to_pg ConsensusEngine._save_consensus() 寫入 N+1 行到 agent_sessions P0.3 — migration SQL syntax check(pyparsing-free,用 re 驗證關鍵字) — approval_db.update_decision_fusion 呼叫正確欄位 """ from __future__ import annotations import re from pathlib import Path from typing import Any from unittest.mock import ANY, AsyncMock, MagicMock, call, patch import pytest # ============================================================================= # P0.1 — GovernanceAgent._alert() 寫入 ai_governance_events # ============================================================================= class TestGovernanceAgentWritesToPg: """P0.1: _alert() 必須在 logger + Telegram 前先寫 PG""" @pytest.mark.asyncio async def test_pg_insert_called_on_alert(self): """_alert() 被呼叫 → AiGovernanceEvent INSERT 觸發(PG 寫入優先)""" from src.services.governance_agent import GovernanceAgent alerter = AsyncMock() alerter.alert_governance = AsyncMock() agent = GovernanceAgent(alerter=alerter) mock_db = AsyncMock() mock_db.execute = AsyncMock() mock_db.commit = AsyncMock() with patch("src.services.governance_agent.get_db_context") as mock_ctx: mock_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db) mock_ctx.return_value.__aexit__ = AsyncMock(return_value=False) await agent._alert("llm_hallucination", {"rate": 0.15, "failed": 15}) # PG 寫入必須觸發 mock_db.execute.assert_called_once() mock_db.commit.assert_called_once() # Telegram 告警也要觸發(既有行為不破壞) alerter.alert_governance.assert_called_once_with( "llm_hallucination", {"rate": 0.15, "failed": 15} ) @pytest.mark.asyncio async def test_pg_failure_does_not_block_telegram(self): """PG 寫入失敗 → 不阻斷 Telegram 告警(ADR-085 保底設計)""" from src.services.governance_agent import GovernanceAgent alerter = AsyncMock() alerter.alert_governance = AsyncMock() agent = GovernanceAgent(alerter=alerter) mock_db = AsyncMock() mock_db.execute = AsyncMock(side_effect=RuntimeError("PG down")) with patch("src.services.governance_agent.get_db_context") as mock_ctx: mock_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db) mock_ctx.return_value.__aexit__ = AsyncMock(return_value=False) # 不應拋例外 await agent._alert("execution_blast_radius", {"rate": 0.25}) # Telegram 仍然被呼叫 alerter.alert_governance.assert_called_once() @pytest.mark.asyncio async def test_pg_insert_uses_correct_event_type(self): """INSERT 時 event_type 欄位必須與 _alert() 入參一致""" from src.services.governance_agent import GovernanceAgent alerter = AsyncMock() alerter.alert_governance = AsyncMock() agent = GovernanceAgent(alerter=alerter) captured_stmt = {} async def capture_execute(stmt): captured_stmt["stmt"] = stmt mock_db = AsyncMock() mock_db.execute = capture_execute mock_db.commit = AsyncMock() with patch("src.services.governance_agent.get_db_context") as mock_ctx: mock_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db) mock_ctx.return_value.__aexit__ = AsyncMock(return_value=False) await agent._alert("trust_drift", {"drifted_count": 3}) # INSERT 語句必須被捕捉到(不是 None) assert captured_stmt.get("stmt") is not None # ============================================================================= # P0.2 — ConsensusEngine._save_consensus() 寫入 agent_sessions # ============================================================================= class TestConsensusEnginePersistsToPg: """P0.2: _save_consensus() 必須同時寫 Redis 和 PG(N opinions + 1 coordinator)""" def _make_result(self, n_opinions: int = 3) -> Any: """建立 ConsensusResult mock""" from src.services.consensus_engine import ( AgentOpinion, AgentType, ConsensusResult, ) from datetime import datetime, timezone opinions = [] agent_types = [AgentType.SRE, AgentType.SECURITY, AgentType.COST, AgentType.PERFORMANCE] for i in range(n_opinions): opinions.append( AgentOpinion( agent_type=agent_types[i % len(agent_types)], action=f"action_{i}", reasoning=f"reasoning_{i}", confidence=0.8, risk_assessment="medium", ) ) return ConsensusResult( consensus_id="CS-TEST-001", incident_id="INC-TEST-001", opinions=opinions, consensus_score=0.75, recommended_action="restart service", final_reasoning="consensus reached", risk_level="medium", ) @pytest.mark.asyncio async def test_pg_insert_called_with_n_plus_1_rows(self): """3 opinions → INSERT 4 行(3 agent + 1 coordinator)""" from src.services.consensus_engine import ConsensusEngine result = self._make_result(n_opinions=3) engine = ConsensusEngine() mock_redis = AsyncMock() mock_redis.set = AsyncMock() mock_db = AsyncMock() mock_db.execute = AsyncMock() mock_db.commit = AsyncMock() # lazy import 從 src.db.base 取,patch 目標必須是來源模組 with patch("src.services.consensus_engine.get_redis", return_value=mock_redis): with patch("src.db.base.get_db_context") as mock_ctx: mock_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db) mock_ctx.return_value.__aexit__ = AsyncMock(return_value=False) await engine._save_consensus(result) # Redis Phase A 雙寫(新 namespace + legacy 熱快取) mock_redis.set.assert_has_calls( [ call("__platform__:consensus:CS-TEST-001", ANY, ex=3600), call("consensus:CS-TEST-001", ANY, ex=3600), ] ) # PG 寫(永久記錄) mock_db.execute.assert_called_once() mock_db.commit.assert_called_once() # 驗證傳入 execute 的 rows 數量 = opinions + 1 coordinator call_args = mock_db.execute.call_args assert call_args is not None rows_arg = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("rows") if rows_arg is not None: assert len(rows_arg) == 4 # 3 opinions + 1 coordinator @pytest.mark.asyncio async def test_coordinator_row_has_correct_vote(self): """coordinator 行:consensus_score >= 0.6 → vote='approve'""" from src.services.consensus_engine import ConsensusEngine result = self._make_result(n_opinions=2) # consensus_score=0.75 >= 0.6 → approve engine = ConsensusEngine() captured_rows: list[dict] = [] mock_redis = AsyncMock() mock_redis.set = AsyncMock() async def capture_execute(_stmt, rows=None): if rows: captured_rows.extend(rows) mock_db = AsyncMock() mock_db.execute = capture_execute mock_db.commit = AsyncMock() with patch("src.services.consensus_engine.get_redis", return_value=mock_redis): with patch("src.db.base.get_db_context") as mock_ctx: mock_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db) mock_ctx.return_value.__aexit__ = AsyncMock(return_value=False) await engine._save_consensus(result) # 找 coordinator 行 coordinator_rows = [r for r in captured_rows if r.get("agent_role") == "coordinator"] if coordinator_rows: assert coordinator_rows[0]["vote"] == "approve" @pytest.mark.asyncio async def test_pg_failure_does_not_block_redis(self): """PG 寫入失敗 → Redis 仍完成(ADR-085 保底)""" from src.services.consensus_engine import ConsensusEngine result = self._make_result(n_opinions=2) engine = ConsensusEngine() mock_redis = AsyncMock() mock_redis.set = AsyncMock() mock_db = AsyncMock() mock_db.execute = AsyncMock(side_effect=RuntimeError("PG down")) with patch("src.services.consensus_engine.get_redis", return_value=mock_redis): with patch("src.db.base.get_db_context") as mock_ctx: mock_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db) mock_ctx.return_value.__aexit__ = AsyncMock(return_value=False) # 不應拋例外 await engine._save_consensus(result) # Redis Phase A 雙寫已完成(在 PG 嘗試之前) mock_redis.set.assert_has_calls( [ call("__platform__:consensus:CS-TEST-001", ANY, ex=3600), call("consensus:CS-TEST-001", ANY, ex=3600), ] ) # ============================================================================= # P0.3 — Migration SQL syntax smoke test # ============================================================================= class TestMigrationSqlSyntax: """P0.3: migration SQL 必須包含必要關鍵字,格式合法""" def _read_sql(self, filename: str) -> str: path = Path(__file__).parent.parent / "migrations" / filename return path.read_text() def test_migration_contains_required_statements(self): """p2_decision_fusion_columns.sql 必須包含 ALTER TABLE + 3 欄位 + 2 index""" sql = self._read_sql("p2_decision_fusion_columns.sql") assert "ALTER TABLE approval_records" in sql assert "composite_score" in sql assert "complexity_tier" in sql assert "decision_fusion_details" in sql assert "chk_complexity_tier" in sql assert "ix_approval_composite_score" in sql assert "ix_approval_complexity_tier" in sql assert "CONCURRENTLY" in sql def test_rollback_contains_drop_statements(self): """p2_decision_fusion_columns_rollback.sql 必須包含 DROP COLUMN + DROP INDEX""" sql = self._read_sql("p2_decision_fusion_columns_rollback.sql") assert "DROP COLUMN" in sql assert "composite_score" in sql assert "complexity_tier" in sql assert "decision_fusion_details" in sql assert "DROP INDEX" in sql assert "ix_approval_composite_score" in sql assert "ix_approval_complexity_tier" in sql def test_migration_has_transaction_boundary(self): """migration SQL 必須有 BEGIN/COMMIT 包住 DDL""" sql = self._read_sql("p2_decision_fusion_columns.sql") assert re.search(r"\bBEGIN\b", sql) assert re.search(r"\bCOMMIT\b", sql) def test_check_constraint_values_match_orm(self): """CHECK constraint 的合法值必須與 ORM complexity_tier String(16) 一致""" sql = self._read_sql("p2_decision_fusion_columns.sql") # 四個 tier 都要出現在 CHECK constraint 中 for tier in ("low", "medium", "high", "critical"): assert tier in sql, f"Missing tier '{tier}' in CHECK constraint" # ============================================================================= # P0.3 — approval_db.update_decision_fusion 方法驗收 # ============================================================================= class TestApprovalDbUpdateDecisionFusion: """P0.3: update_decision_fusion 必須以 incident_id + PENDING status 為條件更新""" @pytest.mark.asyncio async def test_update_called_with_correct_values(self): """update_decision_fusion 呼叫 → UPDATE approval_records 含正確欄位""" from src.services.approval_db import ApprovalDBService mock_result = MagicMock() mock_result.rowcount = 1 mock_db = AsyncMock() mock_db.execute = AsyncMock(return_value=mock_result) mock_db.commit = AsyncMock() # get_db_context autocommit with patch("src.services.approval_db.get_db_context") as mock_ctx: mock_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db) mock_ctx.return_value.__aexit__ = AsyncMock(return_value=False) svc = ApprovalDBService() rowcount = await svc.update_decision_fusion( incident_id="INC-20260426-001", composite_score=0.82, complexity_tier="medium", fusion_details={"composite": 0.82, "openclaw": 0.85}, ) assert rowcount == 1 mock_db.execute.assert_called_once() @pytest.mark.asyncio async def test_update_returns_zero_when_no_pending(self): """找不到 PENDING approval → rowcount=0(不拋例外)""" from src.services.approval_db import ApprovalDBService mock_result = MagicMock() mock_result.rowcount = 0 mock_db = AsyncMock() mock_db.execute = AsyncMock(return_value=mock_result) with patch("src.services.approval_db.get_db_context") as mock_ctx: mock_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db) mock_ctx.return_value.__aexit__ = AsyncMock(return_value=False) svc = ApprovalDBService() rowcount = await svc.update_decision_fusion( incident_id="INC-NONEXISTENT", composite_score=0.5, complexity_tier="low", fusion_details={}, ) assert rowcount == 0