Files
awoooi/apps/api/tests/test_report_generation_service.py
Your Name 7e03b9231b
All checks were successful
Code Review / ai-code-review (push) Successful in 19s
CD Pipeline / tests (push) Successful in 1m43s
CD Pipeline / build-and-deploy (push) Successful in 7m20s
CD Pipeline / post-deploy-checks (push) Successful in 3m8s
feat(api): 新增 SRE 戰情室 digest preview
2026-06-18 20:18:28 +08:00

558 lines
20 KiB
Python
Raw 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.
"""
ReportGenerationService 單元測試
================================
ADR-076 Task 4: 自動報告生成
測試範圍:
- DailyKpi 計算屬性auto_repair_rate, auto_resolve_rate
- format_daily_report() 報告格式
- format_postmortem() 事後檢討格式
- _seconds_until_next_report() 排程計算
- PostmortemData dataclass
🔴🔴 遵循「禁止 Mock 測試鐵律」
- 純 Python 邏輯:不需要 DB/Redis/Telegram
- DB/Telegram 整合部分標記 @pytest.mark.integration
建立: 2026-04-14 (台北時區) Claude Haiku 4.5
"""
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
import pytest
from src.services import weekly_report_service as weekly_report_module
from src.services.report_generation_service import (
DAILY_REPORT_HOUR_TAIPEI,
POSTMORTEM_MIN_DURATION_MINUTES,
DailyKpi,
PostmortemData,
ReportGenerationService,
_seconds_until_next_report,
)
from src.services.weekly_report_service import WeeklyReportService
_TZ_TAIPEI = timezone(timedelta(hours=8))
# =============================================================================
# WeeklyReportService Git 資料源可信度
# =============================================================================
class TestWeeklyReportGitStats:
"""週報不能把 Git 資料源失敗偽裝成 0 commits / 0 deploys。"""
def test_git_log_failure_marks_source_failed(self, monkeypatch):
class Result:
returncode = 128
stdout = ""
stderr = "fatal: not a git repository"
monkeypatch.setattr(
weekly_report_module.subprocess,
"run",
lambda *args, **kwargs: Result(),
)
svc = WeeklyReportService(stats_service=object(), k3s_monitor=object())
commits, deploys, source_ok = svc._get_git_stats(datetime.now(_TZ_TAIPEI))
assert commits == 0
assert deploys == 0
assert source_ok is False
def test_git_deploy_log_failure_marks_source_failed_after_commits(self, monkeypatch):
class CommitResult:
returncode = 0
stdout = "abc123 feat: one\nbcd234 fix: two\n"
stderr = ""
class DeployResult:
returncode = 128
stdout = ""
stderr = "fatal: bad revision"
calls = []
def fake_run(*args, **kwargs):
calls.append(args[0])
return CommitResult() if len(calls) == 1 else DeployResult()
monkeypatch.setattr(weekly_report_module.subprocess, "run", fake_run)
svc = WeeklyReportService(stats_service=object(), k3s_monitor=object())
commits, deploys, source_ok = svc._get_git_stats(datetime.now(_TZ_TAIPEI))
assert commits == 2
assert deploys == 0
assert source_ok is False
# =============================================================================
# DailyKpi 計算屬性
# =============================================================================
class TestDailyKpiRates:
"""測試 DailyKpi 計算屬性"""
def _make_kpi(self, **kwargs) -> DailyKpi:
now = datetime.now(_TZ_TAIPEI)
return DailyKpi(
period_start=now - timedelta(hours=24),
period_end=now,
**kwargs,
)
def test_auto_repair_rate_all_success(self):
"""全部成功 → 100%"""
kpi = self._make_kpi(auto_repair_success=10, auto_repair_failed=0)
assert kpi.auto_repair_rate == 1.0
def test_auto_repair_rate_half(self):
"""5 成功 5 失敗 → 50%"""
kpi = self._make_kpi(auto_repair_success=5, auto_repair_failed=5)
assert kpi.auto_repair_rate == 0.5
def test_auto_repair_rate_zero_attempts(self):
"""無嘗試 → 0%(不除以零)"""
kpi = self._make_kpi(auto_repair_success=0, auto_repair_failed=0)
assert kpi.auto_repair_rate == 0.0
def test_auto_resolve_rate(self):
"""10 個告警 6 個自動解決 → 60%"""
kpi = self._make_kpi(total_alerts=10, auto_resolved=6)
assert kpi.auto_resolve_rate == 0.6
def test_auto_resolve_rate_zero_alerts(self):
"""無告警 → 0%(不除以零)"""
kpi = self._make_kpi(total_alerts=0, auto_resolved=0)
assert kpi.auto_resolve_rate == 0.0
# =============================================================================
# format_daily_report
# =============================================================================
class TestFormatDailyReport:
"""測試日度巡檢報告格式"""
def _make_kpi(self, **kwargs) -> DailyKpi:
now = datetime.now(_TZ_TAIPEI)
defaults = {
"total_alerts": 20,
"auto_resolved": 15,
"human_approved": 3,
"auto_repair_success": 12,
"auto_repair_failed": 3,
"km_new_entries": 5,
"playbook_count": 18,
}
defaults.update(kwargs)
return DailyKpi(
period_start=now - timedelta(hours=24),
period_end=now,
**defaults,
)
def test_contains_title(self):
"""報告應包含標題"""
kpi = self._make_kpi()
svc = ReportGenerationService()
report = svc.format_daily_report(kpi)
assert "日度巡檢報告" in report
def test_contains_alert_stats(self):
"""報告應包含告警統計"""
kpi = self._make_kpi(total_alerts=20)
svc = ReportGenerationService()
report = svc.format_daily_report(kpi)
assert "20" in report
def test_contains_auto_repair_rate(self):
"""報告應包含自動修復成功率"""
kpi = self._make_kpi(auto_repair_success=8, auto_repair_failed=2)
svc = ReportGenerationService()
report = svc.format_daily_report(kpi)
# 80.0%
assert "80.0%" in report
def test_contains_km_stats(self):
"""報告應包含 KM 統計"""
kpi = self._make_kpi(km_new_entries=7)
svc = ReportGenerationService()
report = svc.format_daily_report(kpi)
assert "7" in report
def test_contains_playbook_count(self):
"""報告應包含 Playbook 數量"""
kpi = self._make_kpi(playbook_count=18)
svc = ReportGenerationService()
report = svc.format_daily_report(kpi)
assert "18" in report
def test_health_excellent_threshold(self):
"""自動修復率 >= 80% → 優秀"""
kpi = self._make_kpi(auto_repair_success=8, auto_repair_failed=2)
svc = ReportGenerationService()
report = svc.format_daily_report(kpi)
assert "優秀" in report
def test_health_good_threshold(self):
"""自動修復率 50-79% → 良好"""
kpi = self._make_kpi(auto_repair_success=6, auto_repair_failed=4)
svc = ReportGenerationService()
report = svc.format_daily_report(kpi)
assert "良好" in report
def test_health_needs_attention(self):
"""自動修復率 < 50% → 需關注"""
kpi = self._make_kpi(auto_repair_success=3, auto_repair_failed=7)
svc = ReportGenerationService()
report = svc.format_daily_report(kpi)
assert "需關注" in report
def test_category_breakdown_shown(self):
"""有告警分類時應顯示分類分佈"""
kpi = self._make_kpi(
alert_category_breakdown={"kubernetes": 5, "host_resource": 3}
)
svc = ReportGenerationService()
report = svc.format_daily_report(kpi)
assert "kubernetes" in report
def test_contains_taiwan_timezone_note(self):
"""報告應標示台北時間"""
kpi = self._make_kpi()
svc = ReportGenerationService()
report = svc.format_daily_report(kpi)
assert "台北時間" in report
def test_is_html_formatted(self):
"""報告應包含 HTML 標籤Telegram HTML 格式)"""
kpi = self._make_kpi()
svc = ReportGenerationService()
report = svc.format_daily_report(kpi)
assert "<b>" in report
def test_contains_report_source_health_assets(self):
"""日報應顯示資料源健康與自動化資產沉澱"""
kpi = self._make_kpi()
source_health = {
"rollups": {
"source_ok_count": 2,
"source_count": 5,
"confidence_percent": 40,
},
"source_health": [
{"work_item_id": "report-source-gap:incident_summary"},
{"work_item_id": "report-source-gap:ai_performance"},
],
"automation_assets": [
{"label": "KM", "state": "draft_ready", "done_count": 3, "blocked_count": 2},
{"label": "PlayBook", "state": "draft_required", "done_count": 0, "blocked_count": 2},
{"label": "腳本", "state": "readback_only", "done_count": 1, "blocked_count": 0},
{"label": "排程", "state": "no_send_preview", "done_count": 3, "blocked_count": 0},
{"label": "Verifier", "state": "source_health_ready", "done_count": 1, "blocked_count": 2},
],
"all_zero_assessment": {
"all_zero_observed": True,
"verdict": "source_gap_or_no_signal_requires_review",
},
}
svc = ReportGenerationService()
report = svc.format_daily_report(kpi, source_health)
assert "報表資料源 / 沉澱" in report
assert "來源: <code>2/5</code>" in report
assert "report-source-gap:incident_summary" in report
assert "KM: draft_ready 3/5" in report
assert "PlayBook: draft_required 0/2" in report
assert "腳本: readback_only 1/1" in report
assert "排程: no_send_preview 3/3" in report
assert "Verifier: source_health_ready 1/3" in report
assert "全 0 判讀: source_gap_or_no_signal_requires_review" in report
assert "不自動改排程" in report
def test_monthly_preview_contains_no_send_source_health(self):
"""月報 preview 應顯示 no-send 邊界與資產沉澱"""
source_health = {
"rollups": {
"source_ok_count": 2,
"source_count": 5,
"confidence_percent": 40,
"no_send_preview_count": 3,
},
"source_health": [
{"work_item_id": "report-source-gap:resolution_stats"},
],
"no_send_previews": [
{
"cadence_id": "monthly",
"owner_agent": "Hermes",
"delivery_state": "no_send_preview",
"gap_source_ids": ["resolution_stats", "ai_performance"],
},
],
"automation_assets": [
{"label": "KM", "state": "draft_ready", "done_count": 3, "blocked_count": 1},
{"label": "Verifier", "state": "source_health_ready", "done_count": 1, "blocked_count": 1},
],
}
svc = ReportGenerationService()
report = svc.format_monthly_report_preview(
source_health,
generated_at=datetime(2026, 6, 18, 10, 0, tzinfo=_TZ_TAIPEI),
)
assert "月報 no-send preview" in report
assert "Owner: Hermes" in report
assert "實發: 0" in report
assert "來源: <code>2/5</code>" in report
assert "resolution_stats" in report
assert "KM: draft_ready 3/4" in report
assert "Verifier: source_health_ready 1/2" in report
assert "不代表已授權發送或自動修復" in report
def test_sre_digest_preview_contains_assets_and_boundaries(self):
"""SRE 戰情室 digest 應收斂缺口、資產與 no-send 邊界"""
source_health = {
"rollups": {
"source_ok_count": 2,
"source_count": 5,
"source_gap_count": 3,
"confidence_percent": 40,
"no_send_preview_count": 3,
},
"source_health": [
{"work_item_id": "report-source-gap:incident_summary"},
],
"work_items": [
{"work_item_id": "report-source-gap:incident_summary"},
{"work_item_id": "report-source-gap:ai_performance"},
],
"automation_assets": [
{"label": "KM", "state": "draft_ready", "done_count": 3, "blocked_count": 2},
{"label": "PlayBook", "state": "draft_required", "done_count": 0, "blocked_count": 2},
{"label": "Verifier", "state": "source_health_ready", "done_count": 1, "blocked_count": 2},
],
}
svc = ReportGenerationService()
report = svc.format_sre_digest_preview(
source_health,
generated_at=datetime(2026, 6, 18, 10, 0, tzinfo=_TZ_TAIPEI),
)
assert "AwoooI SRE 戰情室 digest no-send preview" in report
assert "來源: <code>2/5</code>" in report
assert "report-source-gap:incident_summary" in report
assert "live Telegram send: 0" in report
assert "Gateway queue write: 0" in report
assert "KM: draft_ready 3/5" in report
assert "PlayBook: draft_required 0/2" in report
assert "Verifier: source_health_ready 1/3" in report
assert "不發 Telegram" in report
assert "不啟動 runtime gate" in report
# =============================================================================
# format_postmortem
# =============================================================================
class TestFormatPostmortem:
"""測試事後檢討報告格式"""
def _make_postmortem(self, **kwargs) -> PostmortemData:
now = datetime.now(_TZ_TAIPEI)
defaults = {
"incident_id": "INC-20260414-001",
"title": "KubePodOOMKilled on awoooi-api",
"duration_minutes": 25.5,
"root_cause": "記憶體洩漏導致 OOMKilled",
"resolution_action": "kubectl rollout restart deployment/awoooi-api",
"ai_provider": "OpenClaw (deepseek-r1:14b)",
"auto_repaired": True,
"retry_count": 0,
"created_at": now - timedelta(minutes=25, seconds=30),
"resolved_at": now,
}
defaults.update(kwargs)
return PostmortemData(**defaults)
def test_contains_incident_id(self):
"""事後檢討應包含 Incident ID"""
data = self._make_postmortem()
svc = ReportGenerationService()
report = svc.format_postmortem(data)
assert "INC-20260414-001" in report
def test_contains_duration(self):
"""事後檢討應包含持續時間"""
data = self._make_postmortem(duration_minutes=25.5)
svc = ReportGenerationService()
report = svc.format_postmortem(data)
assert "25.5" in report
def test_auto_repaired_shown(self):
"""自動修復應顯示標記"""
data = self._make_postmortem(auto_repaired=True)
svc = ReportGenerationService()
report = svc.format_postmortem(data)
assert "自動修復" in report
def test_human_intervene_shown(self):
"""人工介入應顯示標記"""
data = self._make_postmortem(auto_repaired=False)
svc = ReportGenerationService()
report = svc.format_postmortem(data)
assert "人工介入" in report
def test_retry_count_shown(self):
"""重試次數應顯示"""
data = self._make_postmortem(retry_count=2)
svc = ReportGenerationService()
report = svc.format_postmortem(data)
assert "重試 2 次" in report
def test_root_cause_shown(self):
"""根本原因應顯示"""
data = self._make_postmortem(root_cause="記憶體洩漏導致 OOMKilled")
svc = ReportGenerationService()
report = svc.format_postmortem(data)
assert "記憶體洩漏" in report
def test_resolution_action_shown(self):
"""執行動作應顯示在 code 標籤中"""
data = self._make_postmortem(
resolution_action="kubectl rollout restart deployment/awoooi-api"
)
svc = ReportGenerationService()
report = svc.format_postmortem(data)
assert "kubectl rollout restart" in report
assert "<code>" in report
def test_no_root_cause_skips_section(self):
"""無根本原因時不應顯示根本原因區塊"""
data = self._make_postmortem(root_cause=None)
svc = ReportGenerationService()
report = svc.format_postmortem(data)
assert "根本原因" not in report
def test_contains_taiwan_timezone_note(self):
"""事後檢討應標示台北時間"""
data = self._make_postmortem()
svc = ReportGenerationService()
report = svc.format_postmortem(data)
assert "台北時間" in report
class TestTriggerPostmortemPersistence:
"""Postmortem 產出必須同步沉澱到 KM。"""
@pytest.mark.asyncio
async def test_trigger_postmortem_persists_km_before_telegram_send(self, monkeypatch):
now = datetime.now(_TZ_TAIPEI)
created = now - timedelta(minutes=16)
sent_messages: list[str] = []
created_entries: list[object] = []
op_logs: list[dict] = []
class FakeGateway:
async def send_to_group(self, text: str, parse_mode: str = "HTML") -> None:
sent_messages.append(text)
class FakeKnowledgeRepo:
def __init__(self, _db) -> None:
pass
async def create(self, data):
created_entries.append(data)
return SimpleNamespace(id="km-postmortem-1")
class FakeAlertOpRepo:
async def append(self, event_type: str, **kwargs):
op_logs.append({"event_type": event_type, **kwargs})
@asynccontextmanager
async def fake_db_context():
yield SimpleNamespace()
monkeypatch.setattr(
"src.services.telegram_gateway.get_telegram_gateway",
lambda: FakeGateway(),
)
monkeypatch.setattr("src.db.base.get_db_context", fake_db_context)
monkeypatch.setattr(
"src.repositories.knowledge_repository.KnowledgeDBRepository",
FakeKnowledgeRepo,
)
monkeypatch.setattr(
"src.repositories.alert_operation_log_repository.get_alert_operation_log_repository",
lambda: FakeAlertOpRepo(),
)
await ReportGenerationService().trigger_postmortem(
incident_id="INC-20260531-POST",
title="DockerContainerUnhealthy bitan-pharmacy",
created_at=created,
resolved_at=now,
root_cause="容器健康檢查失敗",
resolution_action="OBSERVE",
auto_repaired=False,
)
assert sent_messages
assert created_entries
entry = created_entries[0]
assert entry.entry_type.value == "postmortem"
assert entry.related_incident_id == "INC-20260531-POST"
assert entry.path_type == "postmortem"
assert op_logs[0]["event_type"] == "KM_CONVERTED"
assert op_logs[0]["action_detail"] == "postmortem_persisted"
# =============================================================================
# _seconds_until_next_report
# =============================================================================
class TestSecondsUntilNextReport:
"""測試排程計算邏輯"""
def test_returns_positive_seconds(self):
"""永遠返回正數秒數"""
seconds = _seconds_until_next_report()
assert seconds > 0
def test_returns_at_most_one_day(self):
"""最多等待 24 小時"""
seconds = _seconds_until_next_report()
assert seconds <= 86400
def test_returns_float(self):
"""返回值為 float"""
seconds = _seconds_until_next_report()
assert isinstance(seconds, float)
# =============================================================================
# 常數設定
# =============================================================================
class TestServiceConstants:
"""測試服務常數"""
def test_daily_report_hour(self):
"""日度報告觸發時間應為 08:00 台北時間"""
assert DAILY_REPORT_HOUR_TAIPEI == 8
def test_postmortem_min_duration(self):
"""Postmortem 最低觸發時長應為 10 分鐘"""
assert POSTMORTEM_MIN_DURATION_MINUTES == 10