""" 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, _seconds_until_next_monthly_report, _seconds_until_next_weekly_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()) monkeypatch.setattr(svc, "_has_local_git_worktree", lambda cwd: True) monkeypatch.setattr(svc, "_get_gitea_commit_stats", lambda since: (0, 0, False)) 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_log_failure_uses_gitea_fallback(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()) monkeypatch.setattr(svc, "_has_local_git_worktree", lambda cwd: True) monkeypatch.setattr(svc, "_get_gitea_commit_stats", lambda since: (12, 4, True)) commits, deploys, source_ok = svc._get_git_stats(datetime.now(_TZ_TAIPEI)) assert commits == 12 assert deploys == 4 assert source_ok is True 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()) monkeypatch.setattr(svc, "_has_local_git_worktree", lambda cwd: True) monkeypatch.setattr(svc, "_get_gitea_commit_stats", lambda since: (0, 0, False)) 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_missing_local_git_worktree_uses_gitea_without_subprocess(self, monkeypatch): def fail_run(*_args, **_kwargs): raise AssertionError("subprocess git log should not run without local .git") monkeypatch.setattr(weekly_report_module.subprocess, "run", fail_run) svc = WeeklyReportService(stats_service=object(), k3s_monitor=object()) monkeypatch.setattr(svc, "_has_local_git_worktree", lambda cwd: False) monkeypatch.setattr(svc, "_get_gitea_commit_stats", lambda since: (21, 7, True)) commits, deploys, source_ok = svc._get_git_stats(datetime.now(_TZ_TAIPEI)) assert commits == 21 assert deploys == 7 assert source_ok is True def test_gitea_commit_stats_counts_deploy_markers(self, monkeypatch): payload = [ {"commit": {"message": "chore(cd): deploy abc123 [skip ci]\n"}}, {"commit": {"message": "fix(api): repair source health\n"}}, {"commit": {"message": "docs(logbook): record deploy verification [skip ci]\n"}}, ] class Response: headers = {} def __enter__(self): return self def __exit__(self, *_args): return None def read(self): return weekly_report_module.json.dumps(payload).encode("utf-8") def fake_urlopen(request, timeout): assert "/api/v1/repos/wooo/awoooi/commits?" in request.full_url assert timeout == 1 assert "Authorization" not in request.headers return Response() monkeypatch.setattr(weekly_report_module, "urlopen", fake_urlopen) monkeypatch.setattr(weekly_report_module.settings, "GITEA_API_URL", "https://gitea.example.test") monkeypatch.setattr(weekly_report_module.settings, "GITEA_REPO_OWNER", "wooo") monkeypatch.setattr(weekly_report_module.settings, "GITEA_REPO_NAME", "awoooi") monkeypatch.setattr(weekly_report_module.settings, "GITEA_API_TOKEN", "") svc = WeeklyReportService(stats_service=object(), k3s_monitor=object()) commits, deploys, source_ok = svc._get_gitea_commit_stats(datetime.now(_TZ_TAIPEI)) assert commits == 3 assert deploys == 2 assert source_ok is True def test_gitea_commit_stats_prefers_anonymous_read_before_token(self, monkeypatch): payload = [{"commit": {"message": "fix(api): one\n"}}] seen_authorization = [] class Response: headers = {"X-Total-Count": "1"} def __enter__(self): return self def __exit__(self, *_args): return None def read(self): return weekly_report_module.json.dumps(payload).encode("utf-8") def fake_urlopen(request, timeout): seen_authorization.append(request.headers.get("Authorization")) return Response() monkeypatch.setattr(weekly_report_module, "urlopen", fake_urlopen) monkeypatch.setattr(weekly_report_module.settings, "GITEA_API_URL", "https://gitea.example.test") monkeypatch.setattr(weekly_report_module.settings, "GITEA_REPO_OWNER", "wooo") monkeypatch.setattr(weekly_report_module.settings, "GITEA_REPO_NAME", "awoooi") monkeypatch.setattr(weekly_report_module.settings, "GITEA_API_TOKEN", "redacted-token") svc = WeeklyReportService(stats_service=object(), k3s_monitor=object()) monkeypatch.setattr(svc, "_gitea_api_candidates", lambda: ["https://gitea.example.test"]) commits, deploys, source_ok = svc._get_gitea_commit_stats(datetime.now(_TZ_TAIPEI)) assert commits == 1 assert deploys == 0 assert source_ok is True assert seen_authorization == [None] def test_gitea_commit_stats_uses_total_header_with_limited_scan(self, monkeypatch): payload = [{"commit": {"message": "fix(api): one\n"}} for _ in range(50)] calls = [] class Response: headers = {"X-Total-Count": "4804"} def __enter__(self): return self def __exit__(self, *_args): return None def read(self): return weekly_report_module.json.dumps(payload).encode("utf-8") def fake_urlopen(request, timeout): calls.append(request.full_url) return Response() monkeypatch.setattr(weekly_report_module, "urlopen", fake_urlopen) monkeypatch.setattr(weekly_report_module.settings, "GITEA_API_URL", "https://gitea.example.test") monkeypatch.setattr(weekly_report_module.settings, "GITEA_REPO_OWNER", "wooo") monkeypatch.setattr(weekly_report_module.settings, "GITEA_REPO_NAME", "awoooi") monkeypatch.setattr(weekly_report_module.settings, "GITEA_API_TOKEN", "") svc = WeeklyReportService(stats_service=object(), k3s_monitor=object()) monkeypatch.setattr(svc, "_gitea_api_candidates", lambda: ["https://gitea.example.test"]) commits, deploys, source_ok = svc._get_gitea_commit_stats(datetime.now(_TZ_TAIPEI)) assert commits == 4804 assert deploys == 0 assert source_ok is True assert len(calls) == 1 # ============================================================================= # 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 "" 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 "來源: 2/5" 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_report_contains_telegram_gateway_source_health(self): """月報應顯示 Telegram Gateway 派送與資產沉澱。""" 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( source_health, generated_at=datetime(2026, 6, 18, 10, 0, tzinfo=_TZ_TAIPEI), ) assert "AWOOOI 月報" in report assert "Owner: Hermes" in report assert "Telegram Gateway" in report assert "來源: 2/5" 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 "AI Agent 受控接手" in report def test_weekly_and_monthly_report_schedule_helpers_return_positive_seconds(self): assert _seconds_until_next_report() > 0 assert _seconds_until_next_weekly_report() > 0 assert _seconds_until_next_monthly_report() > 0 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 "來源: 2/5" 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 "" 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