fix(api): reconcile completed stuck incidents
All checks were successful
Code Review / ai-code-review (push) Successful in 11s
CD Pipeline / tests (push) Successful in 1m2s
CD Pipeline / build-and-deploy (push) Successful in 3m34s
CD Pipeline / post-deploy-checks (push) Successful in 1m35s

This commit is contained in:
Your Name
2026-05-19 11:45:15 +08:00
parent 50833a0efb
commit d0835a7be1
7 changed files with 393 additions and 61 deletions

View 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",
}

View File

@@ -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"