""" 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 "" 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 "" 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