558 lines
20 KiB
Python
558 lines
20 KiB
Python
"""
|
||
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
|