""" SelfHealingValidator 整合測試 ================================ W2 PR-V1: 飛輪斷鏈 C6 修復驗收測試 測試項目: 1. test_validator_called_after_verification — ENABLE=True 時,verify() 完成後 assess_self_healing 被呼叫 2. test_low_score_triggers_rollback_proposal — score < 0.5 時,Telegram rollback 提案被發送 3. test_high_score_no_action — score >= 0.5 時,Telegram 不觸發 4. test_validator_failure_does_not_block_main_flow — assess_self_healing 拋例外,verify() 仍返回正確結果 5. test_feature_flag_disabled_skips — ENABLE=False 時,assess_self_healing 不被呼叫 2026-04-28 ogt + Claude Sonnet 4.6: W2 PR-V1 初始建立 """ from __future__ import annotations import pytest from unittest.mock import AsyncMock, MagicMock, patch from src.services.post_execution_verifier import PostExecutionVerifier from src.services.evidence_snapshot import EvidenceSnapshot from src.services.self_healing_validator import assess_self_healing # ───────────────────────────────────────────────────────────────────────────── # Stubs # ───────────────────────────────────────────────────────────────────────────── def _stub_incident( alertname: str = "KubePodCrashLooping", namespace: str = "awoooi-prod", pod: str = "api-xyz", ) -> object: class _Signal: labels = { "alertname": alertname, "namespace": namespace, "pod": pod, } class _Incident: incident_id = "INC-TEST" signals = [_Signal()] return _Incident() def _stub_snapshot(incident_id: str = "INC-TEST") -> EvidenceSnapshot: snap = EvidenceSnapshot(incident_id=incident_id) snap.pre_execution_state = {"status": "CrashLoopBackOff"} return snap # ───────────────────────────────────────────────────────────────────────────── # assess_self_healing 單元測試(無 IO) # ───────────────────────────────────────────────────────────────────────────── class TestAssessSelfHealing: """assess_self_healing() 純函數測試""" def test_success_result_gives_high_score(self): result = assess_self_healing( pre_state={"status": "CrashLoopBackOff"}, post_state={"status": "Running", "containers": "1/1"}, verification_result="success", action_taken="restart_service:api", ) assert result["score"] >= 0.5 assert result["root_cause_cleared"] is True def test_failed_result_gives_zero_score(self): result = assess_self_healing( pre_state={"status": "Running"}, post_state={"status": "CrashLoopBackOff"}, verification_result="failed", action_taken="patch_config", ) assert result["score"] == 0.0 assert result["root_cause_cleared"] is False def test_degraded_result_gives_low_score(self): result = assess_self_healing( pre_state=None, post_state={"status": "Pending"}, verification_result="degraded", action_taken="scale_up", ) assert result["score"] < 0.5 def test_regression_reduces_score(self): """執行後出現新 CrashLoopBackOff → regression penalty 扣分""" result = assess_self_healing( pre_state={"status": "Running"}, post_state={"status": "Running", "reason": "CrashLoopBackOff"}, verification_result="success", action_taken="restart_service", ) # regression 要扣分 assert "crashloopbackoff" in result["regressions"] # 即使 verification_result=success,regression 導致扣分 assert result["score"] < 1.0 def test_no_regression_full_score_on_success(self): """乾淨的 success:無 regression、root cause 解除 → score=1.0""" result = assess_self_healing( pre_state={"status": "CrashLoopBackOff"}, post_state={"status": "Running", "containers": "1/1"}, verification_result="success", action_taken="restart_service:api", ) assert result["score"] == 1.0 assert result["regressions"] == [] def test_timeout_gives_low_base_score(self): result = assess_self_healing( pre_state=None, post_state={}, verification_result="timeout", action_taken="restart_service", ) assert result["score"] == 0.2 def test_detail_is_human_readable(self): result = assess_self_healing( pre_state=None, post_state={"status": "Running"}, verification_result="success", action_taken="restart", ) assert "base=" in result["detail"] # ───────────────────────────────────────────────────────────────────────────── # 整合測試:verify() → _run_self_healing_validator # ───────────────────────────────────────────────────────────────────────────── class TestVerifyIntegration: """PostExecutionVerifier.verify() 串接 SelfHealingValidator 整合測試""" @pytest.mark.asyncio async def test_validator_called_after_verification(self): """ENABLE=True → verify() 完成後 assess_self_healing 被呼叫""" verifier = PostExecutionVerifier() incident = _stub_incident() with ( patch.object( verifier, "_collect_post_state", new=AsyncMock(return_value={"status": "Running"}), ), patch("src.services.post_execution_verifier._update_snapshot", new=AsyncMock()), patch( "src.services.post_execution_verifier._run_self_healing_validator", new=AsyncMock(), ) as mock_validator, ): await verifier.verify( incident=incident, snapshot=None, action_taken="restart_service:api", warmup_sec=0.0, ) mock_validator.assert_called_once() call_kwargs = mock_validator.call_args.kwargs assert call_kwargs["incident_id"] == "INC-TEST" assert call_kwargs["verification_result"] == "success" @pytest.mark.asyncio async def test_low_score_triggers_rollback_proposal(self): """score < 0.5 → Telegram rollback 提案被發送""" with ( patch( "src.services.self_healing_validator.assess_self_healing", return_value={ "score": 0.2, "root_cause_cleared": False, "regressions": ["crashloopbackoff"], "detail": "base=0.40; regression_penalty=0.15", "verification_result": "degraded", "action_taken": "restart_service", }, ), patch( "src.services.post_execution_verifier._send_rollback_proposal_alert", new=AsyncMock(), ) as mock_send, patch( "src.core.config.get_settings", return_value=MagicMock(ENABLE_SELF_HEALING_VALIDATOR=True), ), ): from src.services.post_execution_verifier import _run_self_healing_validator await _run_self_healing_validator( incident_id="INC-LOW", snapshot=None, pre_state={"status": "Running"}, post_state={"status": "CrashLoopBackOff"}, verification_result="degraded", action_taken="restart_service", ) mock_send.assert_called_once() call_kwargs = mock_send.call_args.kwargs assert call_kwargs["score"] == 0.2 assert call_kwargs["incident_id"] == "INC-LOW" @pytest.mark.asyncio async def test_high_score_no_action(self): """score >= 0.5 → Telegram rollback 提案不發送""" with ( patch( "src.services.self_healing_validator.assess_self_healing", return_value={ "score": 1.0, "root_cause_cleared": True, "regressions": [], "detail": "base=1.00", "verification_result": "success", "action_taken": "restart_service", }, ), patch( "src.services.post_execution_verifier._send_rollback_proposal_alert", new=AsyncMock(), ) as mock_send, patch( "src.core.config.get_settings", return_value=MagicMock(ENABLE_SELF_HEALING_VALIDATOR=True), ), ): from src.services.post_execution_verifier import _run_self_healing_validator await _run_self_healing_validator( incident_id="INC-HIGH", snapshot=None, pre_state={"status": "CrashLoopBackOff"}, post_state={"status": "Running"}, verification_result="success", action_taken="restart_service", ) mock_send.assert_not_called() @pytest.mark.asyncio async def test_validator_failure_does_not_block_main_flow(self): """assess_self_healing 拋例外,verify() 仍返回正確結果""" verifier = PostExecutionVerifier() incident = _stub_incident() with ( patch.object( verifier, "_collect_post_state", new=AsyncMock(return_value={"status": "Running"}), ), patch("src.services.post_execution_verifier._update_snapshot", new=AsyncMock()), # _run_self_healing_validator 本身 raise → 應被吞掉 patch( "src.services.post_execution_verifier._run_self_healing_validator", new=AsyncMock(side_effect=RuntimeError("validator exploded")), ), ): # verify() 不應 raise,仍返回 "success" result = await verifier.verify( incident=incident, snapshot=None, action_taken="restart_service:api", warmup_sec=0.0, ) # verify() 的主流程結果不受影響 # 注意:_run_self_healing_validator 由 verify() await 直接呼叫, # 其例外由 verify() 的 try/except(approve_execution 層級)或自身包住 # 此測試確認即使 validator 炸掉,result 仍是正確的驗證結果 assert result == "success" @pytest.mark.asyncio async def test_feature_flag_disabled_skips(self): """ENABLE_SELF_HEALING_VALIDATOR=False → assess_self_healing 不被呼叫""" import src.services.self_healing_validator as _shv with ( patch.object(_shv, "assess_self_healing") as mock_assess, patch( "src.core.config.get_settings", return_value=MagicMock(ENABLE_SELF_HEALING_VALIDATOR=False), ), ): from src.services.post_execution_verifier import _run_self_healing_validator await _run_self_healing_validator( incident_id="INC-FLAG", snapshot=None, pre_state=None, post_state={"status": "Running"}, verification_result="success", action_taken="restart_service", ) mock_assess.assert_not_called() @pytest.mark.asyncio async def test_snapshot_self_healing_score_updated(self): """score 補填 EvidenceSnapshot.self_healing_score""" snap = _stub_snapshot() snap.update_self_healing = AsyncMock() with ( patch( "src.services.self_healing_validator.assess_self_healing", return_value={ "score": 0.85, "root_cause_cleared": True, "regressions": [], "detail": "base=1.00", "verification_result": "success", "action_taken": "restart_service", }, ), patch( "src.services.post_execution_verifier._send_rollback_proposal_alert", new=AsyncMock(), ), patch( "src.core.config.get_settings", return_value=MagicMock(ENABLE_SELF_HEALING_VALIDATOR=True), ), ): from src.services.post_execution_verifier import _run_self_healing_validator await _run_self_healing_validator( incident_id="INC-SNAP", snapshot=snap, pre_state={"status": "CrashLoopBackOff"}, post_state={"status": "Running"}, verification_result="success", action_taken="restart_service", ) snap.update_self_healing.assert_called_once() call_kwargs = snap.update_self_healing.call_args.kwargs assert call_kwargs["score"] == 0.85 assert call_kwargs["detail"]["root_cause_cleared"] is True assert call_kwargs["detail"]["regressions"] == []