Files
awoooi/apps/api/tests/test_report_generation_service.py
Your Name e2ab879636
Some checks failed
CD Pipeline / tests (push) Failing after 52s
CD Pipeline / build-and-deploy (push) Has been skipped
CD Pipeline / post-deploy-checks (push) Has been skipped
Code Review / ai-code-review (push) Successful in 11s
fix(alerts): correct telegram execution truth
2026-05-31 13:58:39 +08:00

383 lines
13 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.report_generation_service import (
DAILY_REPORT_HOUR_TAIPEI,
POSTMORTEM_MIN_DURATION_MINUTES,
DailyKpi,
PostmortemData,
ReportGenerationService,
_seconds_until_next_report,
)
_TZ_TAIPEI = timezone(timedelta(hours=8))
# =============================================================================
# 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 = dict(
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
# =============================================================================
# format_postmortem
# =============================================================================
class TestFormatPostmortem:
"""測試事後檢討報告格式"""
def _make_postmortem(self, **kwargs) -> PostmortemData:
now = datetime.now(_TZ_TAIPEI)
defaults = dict(
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