""" test_incident_service_resolve_idempotency ========================================== 驗證 `IncidentService.resolve_incident` 對已經 RESOLVED 的 incident 必須 idempotent: - 直接 return existing incident - 不呼叫 save_to_working_memory(避免重複 Redis write) - 不呼叫 incident_repository.update_status(避免重複 DB write) - 不觸發 postmortem / KB extract / KM convert / disposition 副作用 對應 critic 必修 #2 — 沒這個單測,未來有人挪 guard 位置會悄悄破功, 重新放大「resolve_incident 重複觸發 postmortem 洗版」的舊風險。 """ import inspect from datetime import UTC, datetime from types import SimpleNamespace from unittest.mock import AsyncMock import pytest from src.models.incident import IncidentStatus from src.services.incident_service import ( IncidentService, normalize_status, parse_decision_chain, parse_incident_outcome, ) def _patch_resolve_side_effects(monkeypatch): """讓 active incident resolve 測試只驗證 resolve contract,不真的觸發下游工作。""" import asyncio import src.services.anomaly_counter as anomaly_counter import src.services.km_conversion_service as km_conversion_service import src.services.knowledge_extractor_service as knowledge_extractor_service def close_create_task(coro): if inspect.iscoroutine(coro): coro.close() return SimpleNamespace() monkeypatch.setattr(asyncio, "create_task", close_create_task) monkeypatch.setattr( knowledge_extractor_service, "get_knowledge_extractor", lambda: SimpleNamespace(extract_from_incident=AsyncMock(return_value=None)), ) monkeypatch.setattr( km_conversion_service, "get_km_conversion_service", lambda: SimpleNamespace(convert=AsyncMock(return_value=None)), ) monkeypatch.setattr( anomaly_counter, "AnomalyCounter", SimpleNamespace(derive_key_from_incident=staticmethod(lambda _incident: None)), ) monkeypatch.setattr( anomaly_counter, "get_anomaly_counter", lambda: SimpleNamespace(), ) @pytest.mark.asyncio async def test_resolve_incident_skips_when_already_resolved(monkeypatch): """RESOLVED 的 incident 重複 resolve 應 idempotent。""" fake_incident = SimpleNamespace( incident_id="INC-IDEMPO-001", status=IncidentStatus.RESOLVED, ) svc = IncidentService() # Mock 入口讀取 → 回 RESOLVED incident monkeypatch.setattr( svc, "get_from_working_memory", AsyncMock(return_value=fake_incident) ) # Mock 後續所有副作用 → 用 AsyncMock 監看是否被呼叫 save_mock = AsyncMock(return_value=True) monkeypatch.setattr(svc, "save_to_working_memory", save_mock) result = await svc.resolve_incident("INC-IDEMPO-001") # 應 return existing incident assert result is fake_incident # 副作用一律不能觸發(guard 必須早於 line 1117 的 status mutation) save_mock.assert_not_called() @pytest.mark.asyncio async def test_resolve_incident_can_close_db_only_active_incident(monkeypatch): """Redis TTL 過期但 DB 仍 active 的舊 incident 必須能收斂,不重新灌回 Redis。""" fake_incident = SimpleNamespace( incident_id="INC-DB-FALLBACK-ACTIVE-001", status=IncidentStatus.INVESTIGATING, resolved_at=None, updated_at=None, signals=[], affected_services=[], decision_chain=None, outcome=None, ) svc = IncidentService() monkeypatch.setattr( svc, "get_from_working_memory", AsyncMock(return_value=None) ) episodic_mock = AsyncMock(return_value=fake_incident) monkeypatch.setattr(svc, "get_from_episodic_memory", episodic_mock) save_mock = AsyncMock(return_value=True) monkeypatch.setattr(svc, "save_to_working_memory", save_mock) _patch_resolve_side_effects(monkeypatch) result = await svc.resolve_incident( "INC-DB-FALLBACK-ACTIVE-001", emit_postmortem=False, ) assert result is fake_incident assert fake_incident.status is IncidentStatus.RESOLVED assert fake_incident.resolved_at is not None episodic_mock.assert_awaited_once_with("INC-DB-FALLBACK-ACTIVE-001") save_mock.assert_not_called() @pytest.mark.asyncio async def test_resolve_incident_returns_none_when_not_found(monkeypatch): """incident 不存在時 return None。確保 guard 不影響 not-found 路徑。""" svc = IncidentService() monkeypatch.setattr( svc, "get_from_working_memory", AsyncMock(return_value=None) ) monkeypatch.setattr( svc, "get_from_episodic_memory", AsyncMock(return_value=None) ) save_mock = AsyncMock(return_value=True) monkeypatch.setattr(svc, "save_to_working_memory", save_mock) result = await svc.resolve_incident("INC-NOT-EXIST") assert result is None save_mock.assert_not_called() @pytest.mark.asyncio async def test_resolve_incident_uses_episodic_memory_for_idempotent_fallback(monkeypatch): """Redis TTL 過期但 DB 已 RESOLVED 時,resolve 應從 DB fallback 並保持冪等。""" fake_incident = SimpleNamespace( incident_id="INC-DB-FALLBACK-001", status=IncidentStatus.RESOLVED, ) svc = IncidentService() monkeypatch.setattr( svc, "get_from_working_memory", AsyncMock(return_value=None) ) episodic_mock = AsyncMock(return_value=fake_incident) monkeypatch.setattr(svc, "get_from_episodic_memory", episodic_mock) save_mock = AsyncMock(return_value=True) monkeypatch.setattr(svc, "save_to_working_memory", save_mock) result = await svc.resolve_incident("INC-DB-FALLBACK-001") assert result is fake_incident episodic_mock.assert_awaited_once_with("INC-DB-FALLBACK-001") save_mock.assert_not_called() def test_normalize_status_accepts_db_enum_name() -> None: """PostgreSQL SQLEnum 會存 Enum name;讀回時必須正規化成 Pydantic value。""" assert normalize_status("INVESTIGATING") == "investigating" assert normalize_status(IncidentStatus.CLOSED) == "closed" def test_parse_decision_chain_skips_legacy_list_payload() -> None: """舊資料 decision_chain 可能是 list;不應阻斷 incident hydrate / resolve。""" assert parse_decision_chain([{"stage": "router"}], "INC-LEGACY-CHAIN") is None def test_parse_incident_outcome_skips_legacy_string_payload() -> None: """舊資料 outcome 可能是字串;不應阻斷 incident hydrate / resolve。""" assert parse_incident_outcome("resolved", "INC-LEGACY-OUTCOME") is None def test_record_to_incident_tolerates_legacy_decision_chain_list() -> None: """DB fallback 必須能讀回舊 incident,即使 decision_chain 不是新 schema。""" now = datetime.now(UTC) record = SimpleNamespace( incident_id="INC-LEGACY-CHAIN", status="INVESTIGATING", severity="P2", signals=[], affected_services=[], decision_chain=[{"stage": "router"}], proposal_ids=[], outcome=None, created_at=now, updated_at=now, resolved_at=None, closed_at=None, ttl_days=7, vectorized=False, notification_type="TYPE-3", alert_category="host_resource", ) incident = IncidentService()._record_to_incident(record) assert incident.incident_id == "INC-LEGACY-CHAIN" assert incident.status == IncidentStatus.INVESTIGATING assert incident.decision_chain is None def test_record_to_incident_tolerates_legacy_outcome_string() -> None: """DB fallback 必須能讀回舊 incident,即使 outcome 不是新 schema。""" now = datetime.now(UTC) record = SimpleNamespace( incident_id="INC-LEGACY-OUTCOME", status="INVESTIGATING", severity="P2", signals=[], affected_services=[], decision_chain=None, proposal_ids=[], outcome="resolved", created_at=now, updated_at=now, resolved_at=None, closed_at=None, ttl_days=7, vectorized=False, notification_type="TYPE-3", alert_category="host_resource", ) incident = IncidentService()._record_to_incident(record) assert incident.incident_id == "INC-LEGACY-OUTCOME" assert incident.status == IncidentStatus.INVESTIGATING assert incident.outcome is None