383 lines
13 KiB
Python
383 lines
13 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.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
|