""" DiagnosisAggregator 路徑 A 整合測試 ========================================== P3.1-T2-PathA by Claude 2026-04-27 — DiagAggregator 信號分類層補 PDI 路徑 A 策略:ENABLE_DIAGNOSIS_AGGREGATOR=True(預設啟用) DA 只取 PDI 已收集的 raw 資料做信號分類,不重複呼叫 K8s/SignOz API。 驗證: 1. ENABLE_DIAGNOSIS_AGGREGATOR=True(路徑 A 預設啟用) 2. _collect_diagnosis_aggregator 走 classify_signals_from_raw(不打外部 API) 3. aggregator 呼叫失敗時不影響主路徑(exception 隔離) 4. EvidenceSnapshot.extra_diagnosis 為 dict 結構化資料 5. build_summary() 包含 Signal Classification 區塊 6. DiagnosisAggregator.classify_signals_from_raw 純邏輯無 IO 注意:不依賴真實 K8s/SignOz — 全 mock 測試 """ from __future__ import annotations import pytest from unittest.mock import AsyncMock, MagicMock, patch # ───────────────────────────────────────────────────────────────────────────── # Test: EvidenceSnapshot.extra_diagnosis 欄位 # ───────────────────────────────────────────────────────────────────────────── class TestEvidenceSnapshotExtraDiagnosis: def test_extra_diagnosis_field_exists(self): """EvidenceSnapshot 應有 extra_diagnosis 欄位,預設 None,型別為 dict | None""" from src.services.evidence_snapshot import EvidenceSnapshot snap = EvidenceSnapshot(incident_id="INC-001") assert hasattr(snap, "extra_diagnosis") assert snap.extra_diagnosis is None def test_build_summary_includes_signal_classification(self): """extra_diagnosis 有 signals 時 build_summary 應包含 Signal Classification""" from src.services.evidence_snapshot import EvidenceSnapshot snap = EvidenceSnapshot(incident_id="INC-001") snap.extra_diagnosis = { "signal_count": 2, "signals": [ {"signal_type": "crash_loop", "severity": "critical", "message": "CrashLoopBackOff"}, {"signal_type": "oom_killed", "severity": "critical", "message": "OOMKilled"}, ], } summary = snap.build_summary() assert "Signal Classification" in summary assert "crash_loop" in summary def test_build_summary_no_extra_diagnosis_no_section(self): """extra_diagnosis=None 時 build_summary 不應包含 Signal Classification""" from src.services.evidence_snapshot import EvidenceSnapshot snap = EvidenceSnapshot(incident_id="INC-001") snap.extra_diagnosis = None summary = snap.build_summary() assert "Signal Classification" not in summary def test_build_summary_empty_signals_no_section(self): """extra_diagnosis signals=[] 時 build_summary 不應包含 Signal Classification""" from src.services.evidence_snapshot import EvidenceSnapshot snap = EvidenceSnapshot(incident_id="INC-001") snap.extra_diagnosis = {"signal_count": 0, "signals": []} summary = snap.build_summary() assert "Signal Classification" not in summary def test_extra_diagnosis_not_persisted_to_db_record(self): """extra_diagnosis 是 in-memory only,save() 不應包含此欄位到 DB model""" from src.services.evidence_snapshot import EvidenceSnapshot from src.db.models import IncidentEvidence # 確認 DB model 沒有 extra_diagnosis 欄位(in-memory only 設計) assert not hasattr(IncidentEvidence, "extra_diagnosis"), \ "extra_diagnosis 應為 in-memory only,不應存在於 DB model" # ───────────────────────────────────────────────────────────────────────────── # Test: ENABLE_DIAGNOSIS_AGGREGATOR feature flag # ───────────────────────────────────────────────────────────────────────────── class TestDiagnosisAggregatorFeatureFlag: def test_feature_flag_exists_in_settings(self): """config.py 應有 ENABLE_DIAGNOSIS_AGGREGATOR 欄位,路徑 A 預設 True""" from src.core.config import settings assert hasattr(settings, "ENABLE_DIAGNOSIS_AGGREGATOR") # 路徑 A 啟用:預設 True assert settings.ENABLE_DIAGNOSIS_AGGREGATOR is True def test_feature_flag_default_true(self): """直接從 Settings class 確認路徑 A 預設值為 True""" from src.core.config import Settings import inspect source = inspect.getsource(Settings) assert "ENABLE_DIAGNOSIS_AGGREGATOR" in source assert "default=True" in source def test_aggregator_guarded_by_flag_in_investigate(self): """investigate() 4.6 區塊有 ENABLE_DIAGNOSIS_AGGREGATOR flag 守門(source inspection)""" import inspect from src.services.pre_decision_investigator import PreDecisionInvestigator source = inspect.getsource(PreDecisionInvestigator.investigate) assert "ENABLE_DIAGNOSIS_AGGREGATOR" in source, \ "investigate() 應有 ENABLE_DIAGNOSIS_AGGREGATOR feature flag 守門" assert "_collect_diagnosis_aggregator" in source, \ "investigate() 應呼叫 _collect_diagnosis_aggregator" @pytest.mark.asyncio async def test_collect_diagnosis_aggregator_uses_classify_signals_from_raw(self): """路徑 A:_collect_diagnosis_aggregator 應呼叫 classify_signals_from_raw(非 collect_pod_diagnosis)""" from src.services.pre_decision_investigator import PreDecisionInvestigator from src.services.evidence_snapshot import EvidenceSnapshot investigator = PreDecisionInvestigator() class _Inc: incident_id = "INC-TEST-002" signals = [] snap = EvidenceSnapshot(incident_id="INC-TEST-002") snap.k8s_state = {"phase": "CrashLoopBackOff"} snap.recent_logs = None snap.metrics_snapshot = None mock_aggregator = MagicMock() mock_aggregator.classify_signals_from_raw = MagicMock(return_value=[]) mock_aggregator.collect_pod_diagnosis = AsyncMock() # 不應被呼叫 with patch("src.services.diagnosis_aggregator.get_diagnosis_aggregator", return_value=mock_aggregator): await investigator._collect_diagnosis_aggregator(snap, _Inc()) # 路徑 A:classify_signals_from_raw 被呼叫,collect_pod_diagnosis 不被呼叫 mock_aggregator.classify_signals_from_raw.assert_called_once() mock_aggregator.collect_pod_diagnosis.assert_not_called() @pytest.mark.asyncio async def test_da_classify_signals_no_api_call(self): """路徑 A 核心驗證:classify_signals_from_raw 是純邏輯,不打外部 API""" from src.services.diagnosis_aggregator import DiagnosisAggregator agg = DiagnosisAggregator.__new__(DiagnosisAggregator) # 不 init k8s_service / signoz_client(純邏輯方法不依賴這些) with patch("src.services.diagnosis_aggregator.get_k8s_diagnostics_service"), \ patch("src.services.diagnosis_aggregator.get_signoz_client"): signals = agg.classify_signals_from_raw( k8s_data={"phase": "CrashLoopBackOff", "restart_count": 10}, logs_data=None, metrics_data={"error_rate": 25.0}, ) # 應產出信號 assert len(signals) >= 1 signal_types = [s.signal_type for s in signals] assert "crash_loop" in signal_types assert "high_error_rate" in signal_types @pytest.mark.asyncio async def test_collect_diagnosis_aggregator_fills_extra_diagnosis_dict(self): """路徑 A:extra_diagnosis 應為 dict 結構化資料(非 string)""" from src.services.pre_decision_investigator import PreDecisionInvestigator from src.services.evidence_snapshot import EvidenceSnapshot from src.services.diagnosis_aggregator import DiagnosisSignal, DiagnosisSeverity investigator = PreDecisionInvestigator() class _Inc: incident_id = "INC-TEST-003" signals = [] snap = EvidenceSnapshot(incident_id="INC-TEST-003") snap.k8s_state = {"phase": "CrashLoopBackOff"} snap.recent_logs = None snap.metrics_snapshot = None fake_signal = DiagnosisSignal( source="k8s_state", signal_type="crash_loop", severity=DiagnosisSeverity.CRITICAL, message="CrashLoopBackOff detected", ) mock_aggregator = MagicMock() mock_aggregator.classify_signals_from_raw = MagicMock(return_value=[fake_signal]) with patch("src.services.diagnosis_aggregator.get_diagnosis_aggregator", return_value=mock_aggregator): await investigator._collect_diagnosis_aggregator(snap, _Inc()) assert snap.extra_diagnosis is not None assert isinstance(snap.extra_diagnosis, dict) assert snap.extra_diagnosis["signal_count"] == 1 assert snap.extra_diagnosis["signals"][0]["signal_type"] == "crash_loop" @pytest.mark.asyncio async def test_da_signals_appear_in_evidence_summary(self): """路徑 A:extra_diagnosis signals 應出現在 build_summary 的 Signal Classification 區塊""" from src.services.evidence_snapshot import EvidenceSnapshot snap = EvidenceSnapshot(incident_id="INC-TEST-SC-001") snap.extra_diagnosis = { "signal_count": 1, "signals": [{"signal_type": "oom_killed", "severity": "critical", "message": "OOMKilled"}], } summary = snap.build_summary() assert "Signal Classification" in summary assert "oom_killed" in summary @pytest.mark.asyncio async def test_collect_diagnosis_aggregator_exception_isolated(self): """路徑 A:aggregator 內部異常時 snap.extra_diagnosis 維持 None(exception 被內層 catch)""" from src.services.pre_decision_investigator import PreDecisionInvestigator from src.services.evidence_snapshot import EvidenceSnapshot investigator = PreDecisionInvestigator() class _Inc: incident_id = "INC-TEST-004" signals = [] snap = EvidenceSnapshot(incident_id="INC-TEST-004") mock_aggregator = MagicMock() mock_aggregator.classify_signals_from_raw = MagicMock( side_effect=Exception("classify error") ) # 路徑 A:_collect_diagnosis_aggregator 有內層 try/except,不會 raise with patch("src.services.diagnosis_aggregator.get_diagnosis_aggregator", return_value=mock_aggregator): await investigator._collect_diagnosis_aggregator(snap, _Inc()) assert snap.extra_diagnosis is None # ───────────────────────────────────────────────────────────────────────────── # Test: DiagnosisAggregator 基本結構 # ───────────────────────────────────────────────────────────────────────────── class TestDiagnosisAggregatorInterface: def test_get_diagnosis_aggregator_singleton(self): """get_diagnosis_aggregator() singleton 可正常取得""" import src.services.diagnosis_aggregator as m original = m._aggregator m._aggregator = None try: a = m.get_diagnosis_aggregator() b = m.get_diagnosis_aggregator() assert a is b finally: m._aggregator = original def test_collect_pod_diagnosis_method_exists(self): """DiagnosisAggregator 必須有 collect_pod_diagnosis 方法""" from src.services.diagnosis_aggregator import DiagnosisAggregator agg = DiagnosisAggregator.__new__(DiagnosisAggregator) assert hasattr(agg, "collect_pod_diagnosis") assert callable(agg.collect_pod_diagnosis) def test_collect_service_diagnosis_method_exists(self): """DiagnosisAggregator 必須有 collect_service_diagnosis 方法""" from src.services.diagnosis_aggregator import DiagnosisAggregator assert hasattr(DiagnosisAggregator, "collect_service_diagnosis") def test_diagnosis_context_get_llm_prompt_context(self): """DiagnosisContext.get_llm_prompt_context() 回傳非空字串""" from src.services.diagnosis_aggregator import DiagnosisContext ctx = DiagnosisContext(target="test-pod", namespace="awoooi-prod") result = ctx.get_llm_prompt_context() assert isinstance(result, str) assert "test-pod" in result def test_pre_decision_investigator_has_collect_diagnosis_method(self): """PreDecisionInvestigator 必須有 _collect_diagnosis_aggregator 方法""" from src.services.pre_decision_investigator import PreDecisionInvestigator assert hasattr(PreDecisionInvestigator, "_collect_diagnosis_aggregator") assert callable(PreDecisionInvestigator._collect_diagnosis_aggregator)