fix(api): reconcile completed stuck incidents
This commit is contained in:
50
apps/api/tests/test_incident_lifecycle_reconciler.py
Normal file
50
apps/api/tests/test_incident_lifecycle_reconciler.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.jobs.incident_lifecycle_reconciler import (
|
||||
LifecycleCandidate,
|
||||
reconcile_stuck_incidents,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_stuck_incidents_resolves_strong_evidence(monkeypatch):
|
||||
service = SimpleNamespace(resolve_incident=AsyncMock(return_value=object()))
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.jobs.incident_lifecycle_reconciler._fetch_candidates",
|
||||
AsyncMock(
|
||||
return_value=[
|
||||
LifecycleCandidate(
|
||||
incident_id="INC-EXEC-SUCCESS",
|
||||
resolution_type="auto_repair",
|
||||
reason="approval_execution_success",
|
||||
),
|
||||
LifecycleCandidate(
|
||||
incident_id="INC-TIMEOUT",
|
||||
resolution_type="timeout",
|
||||
reason="approval_expired",
|
||||
),
|
||||
]
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.services.incident_service.get_incident_service",
|
||||
lambda: service,
|
||||
)
|
||||
|
||||
resolved, errors = await reconcile_stuck_incidents(limit=2)
|
||||
|
||||
assert (resolved, errors) == (2, 0)
|
||||
assert service.resolve_incident.await_args_list[0].args == (
|
||||
"INC-EXEC-SUCCESS",
|
||||
)
|
||||
assert service.resolve_incident.await_args_list[0].kwargs == {
|
||||
"resolution_type": "auto_repair",
|
||||
}
|
||||
assert service.resolve_incident.await_args_list[1].args == ("INC-TIMEOUT",)
|
||||
assert service.resolve_incident.await_args_list[1].kwargs == {
|
||||
"resolution_type": "timeout",
|
||||
}
|
||||
@@ -18,7 +18,7 @@ from unittest.mock import AsyncMock
|
||||
import pytest
|
||||
|
||||
from src.models.incident import IncidentStatus
|
||||
from src.services.incident_service import IncidentService
|
||||
from src.services.incident_service import IncidentService, normalize_status
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -55,6 +55,9 @@ async def test_resolve_incident_returns_none_when_not_found(monkeypatch):
|
||||
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)
|
||||
|
||||
@@ -62,3 +65,33 @@ async def test_resolve_incident_returns_none_when_not_found(monkeypatch):
|
||||
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user