""" IncidentEngine 單元測試 ======================== Phase 6.4e 驗證點 2: 使用 Mock MemoryProvider 驗證 IncidentEngine 能正確處理告警信號 """ import sys from pathlib import Path from datetime import datetime, timezone from typing import Any # 添加 src 到 Python Path src_path = Path(__file__).parent.parent / "src" sys.path.insert(0, str(src_path)) # ============================================================================= # Mock Memory Provider (完全隔離,不依賴外部) # ============================================================================= class MockIncidentMemory: """Mock 記憶體提供者 - 純記憶體實作""" def __init__(self): self._incidents: dict[str, Any] = {} self._ns_index: dict[str, str] = {} # namespace → incident_id self._target_index: dict[str, str] = {} # target → incident_id async def load_incident(self, incident_id: str): """載入 Incident""" return self._incidents.get(incident_id) async def save_incident(self, incident, ttl_seconds: int = 604800) -> bool: """儲存 Incident""" self._incidents[incident.incident_id] = incident return True async def persist_incident(self, incident) -> bool: """持久化 (Mock 直接返回成功)""" return True async def find_related_incident( self, namespace: str, target: str, window_minutes: int = 30, ): """尋找相關 Incident""" # 檢查 namespace 索引 if namespace in self._ns_index: incident_id = self._ns_index[namespace] incident = self._incidents.get(incident_id) if incident and incident.status.value in ["investigating", "mitigating"]: return incident # 檢查 target 索引 if target in self._target_index: incident_id = self._target_index[target] incident = self._incidents.get(incident_id) if incident and incident.status.value in ["investigating", "mitigating"]: return incident return None async def update_index( self, incident_id: str, namespace: str, target: str, ) -> bool: """更新索引""" self._ns_index[namespace] = incident_id self._target_index[target] = incident_id return True class MockBlastRadiusAnalyzer: """Mock 爆炸半徑分析器""" def analyze(self, target: str) -> list[str]: """返回受影響服務 (Mock 固定回應)""" return [target, f"{target}-dependent"] # ============================================================================= # 測試案例 # ============================================================================= def test_incident_engine_import(): """測試:能正確 import IncidentEngine""" from lewooogo_brain.engines.incident_engine import IncidentEngine from lewooogo_brain.interfaces.incident_processor import IIncidentProcessor assert issubclass(IncidentEngine, IIncidentProcessor) print("✅ IncidentEngine import 成功,實作 IIncidentProcessor") def test_incident_engine_create_incident(): """測試:處理新告警時創建 Incident""" import asyncio from lewooogo_brain.engines.incident_engine import IncidentEngine memory = MockIncidentMemory() analyzer = MockBlastRadiusAnalyzer() engine = IncidentEngine(memory=memory, blast_analyzer=analyzer) signal_data = { "source": "prometheus", "alert_name": "HighCPUUsage", "severity": "critical", "namespace": "awoooi-prod", "target": "awoooi-api", "message": "CPU usage exceeded 90%", "labels": {"app": "awoooi-api"}, } async def run_test(): incident = await engine.process_signal(signal_data) return incident incident = asyncio.get_event_loop().run_until_complete(run_test()) assert incident is not None, "Failed to create incident" assert incident.incident_id.startswith("INC-"), f"Invalid incident ID: {incident.incident_id}" assert incident.severity.value == "P0", f"Expected P0, got {incident.severity.value}" assert len(incident.signals) == 1, f"Expected 1 signal, got {len(incident.signals)}" assert "awoooi-api" in incident.affected_services print(f"✅ Incident 創建成功:") print(f" - ID: {incident.incident_id}") print(f" - Severity: {incident.severity.value}") print(f" - Signals: {len(incident.signals)}") print(f" - Affected: {incident.affected_services}") def test_incident_engine_aggregate_signals(): """測試:相關告警聚合到同一 Incident""" import asyncio from lewooogo_brain.engines.incident_engine import IncidentEngine memory = MockIncidentMemory() engine = IncidentEngine(memory=memory) # 第一個告警 signal1 = { "source": "prometheus", "alert_name": "HighCPUUsage", "severity": "warning", "namespace": "awoooi-prod", "target": "awoooi-api", "message": "CPU at 80%", } # 相同 namespace/target 的第二個告警 signal2 = { "source": "grafana", "alert_name": "HighMemoryUsage", "severity": "critical", "namespace": "awoooi-prod", "target": "awoooi-api", "message": "Memory at 95%", } async def run_test(): incident1 = await engine.process_signal(signal1) incident2 = await engine.process_signal(signal2) return incident1, incident2 incident1, incident2 = asyncio.get_event_loop().run_until_complete(run_test()) assert incident1 is not None assert incident2 is not None assert incident1.incident_id == incident2.incident_id, "Signals should aggregate" assert len(incident2.signals) == 2, f"Expected 2 signals, got {len(incident2.signals)}" # 嚴重度應升級為 P0 (critical) assert incident2.severity.value == "P0", f"Severity should escalate to P0" print(f"✅ 告警聚合成功:") print(f" - Incident ID: {incident2.incident_id}") print(f" - Total Signals: {len(incident2.signals)}") print(f" - Final Severity: {incident2.severity.value}") def test_incident_engine_deduplication(): """測試:相同 Fingerprint 的告警去重""" import asyncio from lewooogo_brain.engines.incident_engine import IncidentEngine memory = MockIncidentMemory() engine = IncidentEngine(memory=memory) # 兩個完全相同的告警 signal = { "source": "prometheus", "alert_name": "PodCrashLooping", "severity": "critical", "namespace": "awoooi-prod", "target": "awoooi-worker", "message": "Pod restart count > 5", } async def run_test(): incident1 = await engine.process_signal(signal) incident2 = await engine.process_signal(signal) # 重複 return incident1, incident2 incident1, incident2 = asyncio.get_event_loop().run_until_complete(run_test()) assert incident1 is not None assert incident2 is not None assert incident1.incident_id == incident2.incident_id # 重複告警應被去重,signal 數量仍為 1 assert len(incident2.signals) == 1, f"Expected 1 signal (dedup), got {len(incident2.signals)}" print(f"✅ 告警去重成功:") print(f" - Signals after dedup: {len(incident2.signals)}") def test_incident_engine_update_status(): """測試:更新 Incident 狀態""" import asyncio from lewooogo_brain.engines.incident_engine import IncidentEngine from lewooogo_brain.interfaces.incident_processor import IncidentStatus memory = MockIncidentMemory() engine = IncidentEngine(memory=memory) signal = { "source": "test", "alert_name": "TestAlert", "severity": "warning", "namespace": "test", "target": "test-service", } async def run_test(): incident = await engine.process_signal(signal) assert incident.status == IncidentStatus.INVESTIGATING success = await engine.update_status(incident.incident_id, IncidentStatus.RESOLVED) assert success, "Failed to update status" updated = await engine.get_incident(incident.incident_id) return updated updated = asyncio.get_event_loop().run_until_complete(run_test()) assert updated is not None assert updated.status == IncidentStatus.RESOLVED assert updated.resolved_at is not None print(f"✅ 狀態更新成功:") print(f" - Status: {updated.status.value}") print(f" - Resolved At: {updated.resolved_at}") def test_incident_engine_no_external_deps(): """測試:IncidentEngine 不依賴任何外部模組""" import importlib import lewooogo_brain.engines.incident_engine as module # 取得所有 import source = Path(module.__file__).read_text() # 禁止的 import patterns forbidden = [ "from src.core", "from src.db", "from src.services", "import redis", "from redis", "import sqlalchemy", "from sqlalchemy", ] violations = [] for pattern in forbidden: if pattern in source: violations.append(pattern) assert len(violations) == 0, f"Found forbidden imports: {violations}" print("✅ 無外部依賴,完全積木化") if __name__ == "__main__": print("=" * 60) print("🧪 IncidentEngine 單元測試") print("=" * 60) tests = [ test_incident_engine_import, test_incident_engine_create_incident, test_incident_engine_aggregate_signals, test_incident_engine_deduplication, test_incident_engine_update_status, test_incident_engine_no_external_deps, ] passed = 0 failed = 0 for test in tests: print(f"\n🔬 {test.__name__}") try: test() passed += 1 except AssertionError as e: print(f"❌ FAILED: {e}") failed += 1 except Exception as e: print(f"❌ ERROR: {type(e).__name__}: {e}") failed += 1 print("\n" + "=" * 60) print(f"📊 結果: {passed} 通過, {failed} 失敗") print("=" * 60) if failed > 0: sys.exit(1)