All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 17m34s
Task 2: AlertGroupingService — Redis 5分鐘滑動視窗,防告警風暴 - apps/api/src/services/alert_grouping_service.py (新增) - webhooks.py 整合:指紋生成後/LLM前短路子告警 - Threshold=3,Graceful Degradation,16 tests Task 3: approval_execution.py 執行失敗重試 - MAX_RETRY=2, RETRY_DELAY_SECONDS=30 - _is_transient_error() 瞬態/永久分類,永久錯誤不重試 - Timeline 記錄重試進度,成功後標注重試次數,29 tests Task 4: report_generation_service.py 自動報告 - 日度巡檢報告:每日 08:00 台北時間,Telegram SRE 群組推送 - Postmortem:Incident resolved + duration > 10 分鐘自動觸發 - main.py lifespan 掛載 run_daily_report_loop(),30 tests 測試: 600 → 675 通過 (+75),0 failed Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
316 lines
11 KiB
Python
316 lines
11 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 datetime import datetime, timedelta, timezone
|
||
|
||
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
|
||
|
||
|
||
# =============================================================================
|
||
# _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
|