172 lines
5.8 KiB
Python
172 lines
5.8 KiB
Python
"""
|
||
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 洗版」的舊風險。
|
||
"""
|
||
|
||
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,
|
||
)
|
||
|
||
|
||
@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_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
|