Files
awoooi/apps/api/tests/test_matched_playbook_id_e2e.py
Your Name 6878e62af7 feat(flywheel): W1 PR-P1 + ADR-091 T1 — 飛輪 80→90 第一波
依 onboarder 端到端閉環審計挖出的 10 條斷鏈 + critic 鐵律違反全景,
W1 第一波修復飛輪鐵證 1 + 2 的核心斷鏈 C1。

## W1 PR-P1 — matched_playbook_id 四斷點守門 (C1 修復)
fullstack 探勘發現 4 斷點之前 session 已修,本 PR 補:
- ENABLE_PLAYBOOK_MATCHING feature flag (default=true)
  rollback: kubectl set env deployment/awoooi-api ENABLE_PLAYBOOK_MATCHING=false
- proposal_service._try_playbook_match_id 入口加 flag check
- 7 個 e2e 測試補上保護網(之前無測試覆蓋)

斷鏈 C1 證據鏈:proposal_service.generate_proposal() → matched_playbook_id
→ approval_db → approval_repository → learning_service._update_playbook_stats
24h 後 playbooks.trust_score 應有真實 EWMA 更新。

## ADR-091 T1 — auto_generate_rule 雙寫 DB (鐵證 1 第一步)
飛輪鐵證 1:alert_rule_catalog.source='ai_generated' 全 codebase 0 筆。
auto_generate_rule() 寫 alert_rules.yaml 但不寫 DB → AI 自學成果與 catalog 雙軌脫鉤。

修法(依 ADR-091 §1 D1):
- 新增 _insert_catalog_ai_generated():YAML 寫入成功後雙寫
  source='ai_generated', confidence=0.5, review_status='draft', created_by_agent
- 新增 _parse_for_to_seconds() helper("30s"/"5m"/"2h" → seconds)
- ON CONFLICT (rule_name) DO NOTHING 冪等保證
- transaction 策略:YAML + DB 不在同一 transaction(YAML 已成 SoT,DB 失敗只 log)
- ENABLE_AI_RULE_CATALOG_WRITE feature flag (default=true)
  rollback: kubectl set env deployment/awoooi-api ENABLE_AI_RULE_CATALOG_WRITE=false

13 個測試覆蓋:parse helper 8 + 業務邏輯 5(success/db_fail/idempotent/flag/SQL_lit)

## 驗證
1572 unit tests 全綠(+20 新增:PR-P1 7 + ADR-091 T1 13)

## 期望影響
飛輪自主化評分:42 → 65(+23 = C1 +3 + 鐵證 1 +20)

## 已知債(critic PR review 揭示,下一個 commit 處理)
- KMWriter 統一契約 3 條 caller 路徑被旁路(C1/M1/M2)
- KMWriter 冪等聲明與實作不符(M3 缺 ON CONFLICT)
- Alertmanager equal:[] 爆炸抑制 + 版本未驗(M4/M5)
- drift checker regex 脆弱(M7 應改 AST)
- governance health score skipped 失真(M6)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:44:39 +08:00

453 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
飛輪閉環 C1 修復 E2E 測試 — matched_playbook_id 全鏈路
=========================================================
W1 PR-P1 by Claude Sonnet 4.6 (2026-04-28 台北時區)
測試覆蓋四個斷點的修復驗證:
1. proposal_service 在相似度 >= 0.85 時填 matched_playbook_id
2. proposal_service 在相似度 < 0.85 時保留 None
3. approval_db 正確序列化 matched_playbook_id 到 record_data dict
4. learning_service 在 matched_playbook_id 存在時觸發 _update_playbook_statsEWMA
🔴 遵循 feedback_no_mock_testing.md:
- 禁止 MagicMock/AsyncMock/unittest.mock.patch
- 使用純 Python Stub 類別 + pytest monkeypatch替換 module-level singleton
- 不連線真實 DB / Redis
Feature Flag:
- ENABLE_PLAYBOOK_MATCHING=false → _try_playbook_match_id 立即回傳 Nonerollback 路徑)
"""
from __future__ import annotations
from uuid import UUID
import pytest
from src.models.approval import (
ApprovalRequest,
ApprovalRequestCreate,
ApprovalStatus,
RiskLevel,
)
from src.models.incident import Incident, IncidentStatus, Severity, Signal
from src.models.playbook import (
ActionType,
Playbook,
PlaybookStatus,
RepairStep,
RiskLevel as PlaybookRiskLevel,
SymptomPattern,
)
from src.utils.timezone import now_taipei
# =============================================================================
# Stubs
# =============================================================================
class StubRecommendation:
"""PlaybookService.get_recommendations 回傳的推薦結果 Stub"""
def __init__(self, playbook: Playbook, similarity_score: float = 0.9) -> None:
self.playbook = playbook
self.similarity_score = similarity_score
class StubPlaybookService:
"""PlaybookService 的輕量 Stub — 記錄 record_execution 呼叫"""
def __init__(self, recommendations: list | None = None) -> None:
self._recommendations = recommendations or []
self.record_execution_calls: list[dict] = []
async def get_recommendations(self, symptoms, top_k: int = 1) -> list:
return self._recommendations
async def record_execution(self, playbook_id: str, success: bool) -> bool:
self.record_execution_calls.append({"playbook_id": playbook_id, "success": success})
return True
class StubApprovalService:
"""ApprovalDBService 的輕量 Stub — 記錄 create_approval 呼叫"""
def __init__(self) -> None:
self.created: list[ApprovalRequestCreate] = []
async def create_approval(self, request: ApprovalRequestCreate) -> ApprovalRequest:
self.created.append(request)
# 回傳最小合法的 ApprovalRequest
return ApprovalRequest(
id=UUID("00000000-0000-0000-0000-000000000001"),
action=request.action,
description=request.description,
status=ApprovalStatus.PENDING,
risk_level=request.risk_level or RiskLevel.MEDIUM,
required_signatures=1,
current_signatures=0,
requested_by=request.requested_by,
created_at=now_taipei(),
matched_playbook_id=request.matched_playbook_id,
incident_id=request.incident_id,
)
class StubLearningRepo:
"""LearningRepository 的極簡 Stub"""
async def record_repair(self, **kwargs) -> bool:
return True
async def get_all_repair_stats(self, *a, **kw):
return {}
async def get_repair_history(self, *a, **kw):
return []
async def get_repair_stats(self, *a, **kw):
return {}
async def record_disposition(self, *a, **kw):
return True
async def get_dispositions(self, *a, **kw):
return {}
class StubTrustRepo:
"""TrustRepository 的極簡 Stub"""
async def upsert(self, **kwargs) -> None:
pass
async def save_trust_record(self, *a, **kw):
pass
async def load_trust_record(self, *a, **kw):
return None
async def get_all_trust_records(self, *a, **kw):
return []
# =============================================================================
# Factories
# =============================================================================
def _make_incident(
incident_id: str = "INC-C1-001",
severity: Severity = Severity.P2,
) -> Incident:
now = now_taipei()
return Incident(
incident_id=incident_id,
status=IncidentStatus.INVESTIGATING,
severity=severity,
affected_services=["c1-test-service"],
signals=[
Signal(
alert_name="HighCpuLoad",
severity=severity,
source="prometheus",
fired_at=now,
labels={"namespace": "awoooi-prod", "alertname": "HighCpuLoad"},
)
],
)
def _make_playbook(
playbook_id: str = "PB-C1-001",
trust_score: float = 0.75,
) -> Playbook:
return Playbook(
playbook_id=playbook_id,
name="C1 測試 Playbook",
description="飛輪斷鏈 C1 修復 E2E 測試用",
status=PlaybookStatus.APPROVED,
symptom_pattern=SymptomPattern(
alert_names=["HighCpuLoad"],
affected_services=["c1-test-service"],
severity_range=["P2"],
),
repair_steps=[
RepairStep(
step_number=1,
action_type=ActionType.MANUAL,
command="kubectl rollout restart deployment/c1-test-service",
risk_level=PlaybookRiskLevel.LOW,
)
],
trust_score=trust_score,
success_count=8,
failure_count=2,
)
# =============================================================================
# Test 1: proposal 在相似度 >= 0.85 時填 matched_playbook_id
# =============================================================================
@pytest.mark.asyncio
async def test_proposal_fills_matched_playbook_id_when_above_threshold(monkeypatch):
"""
proposal_service._try_playbook_match_id 在相似度 >= 0.85 + status=APPROVED 時
應回傳 playbook_id非 None
"""
playbook = _make_playbook(playbook_id="PB-C1-ABOVE", trust_score=0.8)
stub_pb_service = StubPlaybookService(
recommendations=[StubRecommendation(playbook, similarity_score=0.92)]
)
# 替換 module-level playbook_service singleton
import src.services.playbook_service as _pb_mod
monkeypatch.setattr(_pb_mod, "_service", stub_pb_service)
from src.services.proposal_service import ProposalService
service = ProposalService.__new__(ProposalService)
incident = _make_incident()
result = await service._try_playbook_match_id(incident)
assert result == "PB-C1-ABOVE", (
f"相似度 0.92 >= 0.85 應回傳 playbook_id實際得到: {result}"
)
# =============================================================================
# Test 2: proposal 在相似度 < 0.85 時保留 None
# =============================================================================
@pytest.mark.asyncio
async def test_proposal_keeps_none_when_below_threshold(monkeypatch):
"""
proposal_service._try_playbook_match_id 在相似度 < 0.85 時應回傳 None。
"""
playbook = _make_playbook(playbook_id="PB-C1-BELOW", trust_score=0.5)
stub_pb_service = StubPlaybookService(
recommendations=[StubRecommendation(playbook, similarity_score=0.70)]
)
import src.services.playbook_service as _pb_mod
monkeypatch.setattr(_pb_mod, "_service", stub_pb_service)
from src.services.proposal_service import ProposalService
service = ProposalService.__new__(ProposalService)
incident = _make_incident()
result = await service._try_playbook_match_id(incident)
assert result is None, (
f"相似度 0.70 < 0.85 應回傳 None實際得到: {result}"
)
# =============================================================================
# Test 3: approval_db approval_request_to_record_data 序列化 matched_playbook_id
# =============================================================================
def test_approval_db_persists_matched_playbook_id():
"""
approval_db.approval_request_to_record_data 應將 matched_playbook_id
正確序列化到 DB dict不遺失欄位
"""
from src.services.approval_db import approval_request_to_record_data
from src.models.approval import ApprovalRequestCreate, BlastRadius, DataImpact
request = ApprovalRequestCreate(
action="kubectl rollout restart deployment/c1-test-service",
description="C1 test",
risk_level=RiskLevel.MEDIUM,
requested_by="c1-e2e-test",
incident_id="INC-C1-001",
matched_playbook_id="PB-C1-001",
blast_radius=BlastRadius(
affected_pods=2,
estimated_downtime="< 2 min",
related_services=["c1-test-service"],
data_impact=DataImpact.NONE,
),
)
data = approval_request_to_record_data(request, RiskLevel.MEDIUM, required_sigs=1)
assert "matched_playbook_id" in data, "record_data 必須包含 matched_playbook_id 鍵"
assert data["matched_playbook_id"] == "PB-C1-001", (
f"matched_playbook_id 應為 'PB-C1-001',實際: {data['matched_playbook_id']}"
)
def test_approval_db_record_data_none_when_not_provided():
"""
matched_playbook_id 未提供時record_data 應包含 None不拋例外
"""
from src.services.approval_db import approval_request_to_record_data
request = ApprovalRequestCreate(
action="kubectl rollout restart deployment/test",
description="No playbook test",
risk_level=RiskLevel.LOW,
requested_by="c1-e2e-test",
)
data = approval_request_to_record_data(request, RiskLevel.LOW, required_sigs=0)
assert "matched_playbook_id" in data
assert data["matched_playbook_id"] is None
# =============================================================================
# Test 4: learning_service 在 matched_playbook_id 存在時觸發 EWMA 更新
# =============================================================================
@pytest.mark.asyncio
async def test_learning_service_updates_trust_when_matched(monkeypatch):
"""
learning_service.process_execution_result 在 approval.matched_playbook_id
非 None 時,應呼叫 _update_playbook_stats最終觸發 EWMA trust_score 更新)。
"""
playbook_id = "PB-C1-LEARN-001"
stub_pb_service = StubPlaybookService()
playbook = _make_playbook(playbook_id=playbook_id)
stub_pb_service._recommendations = [] # 不需要 recommendations
# 替換 playbook_service singleton_update_playbook_stats lazy import 會呼叫 get_playbook_service()
import src.services.playbook_service as _pb_mod
monkeypatch.setattr(_pb_mod, "_service", stub_pb_service)
from src.services.learning_service import LearningService, ExecutionResult
svc = LearningService(
repository=StubLearningRepo(),
trust_repository=StubTrustRepo(),
)
approval = ApprovalRequest(
id=UUID("00000000-0000-0000-0000-000000000002"),
action="kubectl rollout restart deployment/c1-service",
description="C1 learning test",
status=ApprovalStatus.APPROVED,
risk_level=RiskLevel.MEDIUM,
required_signatures=1,
current_signatures=1,
requested_by="c1-e2e-test",
created_at=now_taipei(),
matched_playbook_id=playbook_id,
)
result = ExecutionResult(
approval_id=str(approval.id),
incident_id="INC-C1-LEARN-001",
action=approval.action,
success=True,
)
learning_record = await svc.process_execution_result(approval, result)
assert learning_record.playbook_updated is True, (
"matched_playbook_id 不為 None 時playbook_updated 應為 TrueEWMA 觸發)"
)
assert len(stub_pb_service.record_execution_calls) == 1, (
"record_execution 應被呼叫一次"
)
assert stub_pb_service.record_execution_calls[0]["playbook_id"] == playbook_id
assert stub_pb_service.record_execution_calls[0]["success"] is True
@pytest.mark.asyncio
async def test_learning_service_skips_update_when_no_match():
"""
approval.matched_playbook_id is None 時learning_service 不更新 Playbook stats。
playbook_updated 應為 Falserecord_execution 不被呼叫。
"""
stub_pb_service = StubPlaybookService()
from src.services.learning_service import LearningService, ExecutionResult
svc = LearningService(
repository=StubLearningRepo(),
trust_repository=StubTrustRepo(),
)
approval = ApprovalRequest(
id=UUID("00000000-0000-0000-0000-000000000003"),
action="kubectl rollout restart deployment/unmatched-service",
description="No match test",
status=ApprovalStatus.APPROVED,
risk_level=RiskLevel.LOW,
required_signatures=0,
current_signatures=0,
requested_by="c1-e2e-test",
created_at=now_taipei(),
matched_playbook_id=None,
)
result = ExecutionResult(
approval_id=str(approval.id),
incident_id="INC-C1-NO-MATCH-001",
action=approval.action,
success=True,
)
learning_record = await svc.process_execution_result(approval, result)
assert learning_record.playbook_updated is False, (
"matched_playbook_id=None 時 playbook_updated 應為 False"
)
assert len(stub_pb_service.record_execution_calls) == 0, (
"matched_playbook_id=None 時不應呼叫 record_execution"
)
# =============================================================================
# Test 5: Feature Flag ENABLE_PLAYBOOK_MATCHING=false 回滾路徑
# =============================================================================
@pytest.mark.asyncio
async def test_feature_flag_disabled_returns_none(monkeypatch):
"""
ENABLE_PLAYBOOK_MATCHING=false 時_try_playbook_match_id 必須直接回傳 None
不呼叫 PlaybookService完全回滾到修復前行為
"""
playbook = _make_playbook(playbook_id="PB-C1-FLAG")
stub_pb_service = StubPlaybookService(
recommendations=[StubRecommendation(playbook, similarity_score=0.99)]
)
import src.services.playbook_service as _pb_mod
monkeypatch.setattr(_pb_mod, "_service", stub_pb_service)
# 替換 settings 使 ENABLE_PLAYBOOK_MATCHING=false
# proposal_service 用 `from src.core.config import get_settings`
# 所以必須替換 proposal_service 模組命名空間中的 get_settings 符號。
from src.core.config import Settings
fake_settings = Settings.model_construct(
ENABLE_PLAYBOOK_MATCHING=False,
DATABASE_URL="postgresql+asyncpg://x:y@localhost/z",
)
import src.services.proposal_service as _ps_mod
monkeypatch.setattr(_ps_mod, "get_settings", lambda: fake_settings)
from src.services.proposal_service import ProposalService
service = ProposalService.__new__(ProposalService)
incident = _make_incident()
result = await service._try_playbook_match_id(incident)
assert result is None, (
"ENABLE_PLAYBOOK_MATCHING=false 時應回傳 None不進行匹配"
)
# StubPlaybookService 的 get_recommendations 沒有被呼叫
assert stub_pb_service._recommendations is not None # stub 本身沒被呼叫(沒計數器,靠上面 assert result is None 驗證)