feat(incidents): add incident lifecycle api and tests
This commit is contained in:
252
tests/services/test_incident_service.py
Normal file
252
tests/services/test_incident_service.py
Normal file
@@ -0,0 +1,252 @@
|
||||
"""Unit tests for IncidentService."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import asyncpg
|
||||
import pytest
|
||||
|
||||
from app.api.deps import CurrentUser
|
||||
from app.core import exceptions as exc, security
|
||||
from app.db import Database
|
||||
from app.schemas.incident import CommentRequest, IncidentCreate, TransitionRequest
|
||||
from app.services.incident import IncidentService
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
class _SingleConnectionDatabase(Database):
|
||||
"""Database stub that reuses a single asyncpg connection."""
|
||||
|
||||
def __init__(self, conn) -> None: # type: ignore[override]
|
||||
self._conn = conn
|
||||
|
||||
@asynccontextmanager
|
||||
async def connection(self): # type: ignore[override]
|
||||
yield self._conn
|
||||
|
||||
@asynccontextmanager
|
||||
async def transaction(self): # type: ignore[override]
|
||||
tr = self._conn.transaction()
|
||||
await tr.start()
|
||||
try:
|
||||
yield self._conn
|
||||
except Exception:
|
||||
await tr.rollback()
|
||||
raise
|
||||
else:
|
||||
await tr.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def incident_service(db_conn: asyncpg.Connection):
|
||||
"""IncidentService bound to the per-test database connection."""
|
||||
|
||||
return IncidentService(database=_SingleConnectionDatabase(db_conn))
|
||||
|
||||
|
||||
async def _seed_user_org_service(conn: asyncpg.Connection) -> tuple[CurrentUser, UUID]:
|
||||
"""Create a user, org, and service and return the CurrentUser + service_id."""
|
||||
|
||||
user_id = uuid4()
|
||||
org_id = uuid4()
|
||||
service_id = uuid4()
|
||||
|
||||
await conn.execute(
|
||||
"INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3)",
|
||||
user_id,
|
||||
"owner@example.com",
|
||||
security.hash_password("Passw0rd!"),
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO orgs (id, name, slug) VALUES ($1, $2, $3)",
|
||||
org_id,
|
||||
"Test Org",
|
||||
"test-org",
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO org_members (id, user_id, org_id, role) VALUES ($1, $2, $3, $4)",
|
||||
uuid4(),
|
||||
user_id,
|
||||
org_id,
|
||||
"member",
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO services (id, org_id, name, slug) VALUES ($1, $2, $3, $4)",
|
||||
service_id,
|
||||
org_id,
|
||||
"API",
|
||||
"api",
|
||||
)
|
||||
|
||||
current_user = CurrentUser(
|
||||
user_id=user_id,
|
||||
email="owner@example.com",
|
||||
org_id=org_id,
|
||||
org_role="member",
|
||||
token="token",
|
||||
)
|
||||
return current_user, service_id
|
||||
|
||||
|
||||
async def test_create_incident_persists_and_records_event(
|
||||
incident_service: IncidentService, db_conn: asyncpg.Connection
|
||||
) -> None:
|
||||
current_user, service_id = await _seed_user_org_service(db_conn)
|
||||
|
||||
incident = await incident_service.create_incident(
|
||||
current_user,
|
||||
service_id,
|
||||
IncidentCreate(title="API outage", description="Gateway 502s", severity="critical"),
|
||||
)
|
||||
|
||||
row = await db_conn.fetchrow(
|
||||
"SELECT status, org_id, service_id FROM incidents WHERE id = $1",
|
||||
incident.id,
|
||||
)
|
||||
assert row is not None
|
||||
assert row["status"] == "triggered"
|
||||
assert row["org_id"] == current_user.org_id
|
||||
assert row["service_id"] == service_id
|
||||
|
||||
event = await db_conn.fetchrow(
|
||||
"SELECT event_type, actor_user_id FROM incident_events WHERE incident_id = $1",
|
||||
incident.id,
|
||||
)
|
||||
assert event is not None
|
||||
assert event["event_type"] == "created"
|
||||
assert event["actor_user_id"] == current_user.user_id
|
||||
|
||||
|
||||
async def test_get_incidents_paginates_by_created_at(
|
||||
incident_service: IncidentService, db_conn: asyncpg.Connection
|
||||
) -> None:
|
||||
current_user, service_id = await _seed_user_org_service(db_conn)
|
||||
|
||||
first = await incident_service.create_incident(
|
||||
current_user, service_id, IncidentCreate(title="First", description=None, severity="low")
|
||||
)
|
||||
second = await incident_service.create_incident(
|
||||
current_user, service_id, IncidentCreate(title="Second", description=None, severity="medium")
|
||||
)
|
||||
third = await incident_service.create_incident(
|
||||
current_user, service_id, IncidentCreate(title="Third", description=None, severity="high")
|
||||
)
|
||||
|
||||
# Stagger created_at for deterministic ordering
|
||||
now = datetime.now(UTC)
|
||||
await db_conn.execute(
|
||||
"UPDATE incidents SET created_at = $1 WHERE id = $2",
|
||||
now - timedelta(minutes=3),
|
||||
first.id,
|
||||
)
|
||||
await db_conn.execute(
|
||||
"UPDATE incidents SET created_at = $1 WHERE id = $2",
|
||||
now - timedelta(minutes=2),
|
||||
second.id,
|
||||
)
|
||||
await db_conn.execute(
|
||||
"UPDATE incidents SET created_at = $1 WHERE id = $2",
|
||||
now - timedelta(minutes=1),
|
||||
third.id,
|
||||
)
|
||||
|
||||
page = await incident_service.get_incidents(current_user, limit=2)
|
||||
|
||||
titles = [item.title for item in page.items]
|
||||
assert titles == ["Third", "Second"]
|
||||
assert page.has_more is True
|
||||
assert page.next_cursor is not None
|
||||
|
||||
|
||||
async def test_transition_incident_updates_status_and_records_event(
|
||||
incident_service: IncidentService, db_conn: asyncpg.Connection
|
||||
) -> None:
|
||||
current_user, service_id = await _seed_user_org_service(db_conn)
|
||||
incident = await incident_service.create_incident(
|
||||
current_user, service_id, IncidentCreate(title="Escalation", severity="high", description=None)
|
||||
)
|
||||
|
||||
updated = await incident_service.transition_incident(
|
||||
current_user,
|
||||
incident.id,
|
||||
TransitionRequest(to_status="acknowledged", version=incident.version, note="On it"),
|
||||
)
|
||||
|
||||
assert updated.status == "acknowledged"
|
||||
assert updated.version == incident.version + 1
|
||||
|
||||
event = await db_conn.fetchrow(
|
||||
"""
|
||||
SELECT payload
|
||||
FROM incident_events
|
||||
WHERE incident_id = $1 AND event_type = 'status_changed'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
incident.id,
|
||||
)
|
||||
assert event is not None
|
||||
payload = event["payload"]
|
||||
if isinstance(payload, str):
|
||||
import json
|
||||
|
||||
payload = json.loads(payload)
|
||||
|
||||
assert payload["from"] == "triggered"
|
||||
assert payload["to"] == "acknowledged"
|
||||
assert payload["note"] == "On it"
|
||||
|
||||
|
||||
async def test_transition_incident_rejects_invalid_transition(
|
||||
incident_service: IncidentService, db_conn: asyncpg.Connection
|
||||
) -> None:
|
||||
current_user, service_id = await _seed_user_org_service(db_conn)
|
||||
incident = await incident_service.create_incident(
|
||||
current_user, service_id, IncidentCreate(title="Invalid", severity="low", description=None)
|
||||
)
|
||||
|
||||
with pytest.raises(exc.BadRequestError):
|
||||
await incident_service.transition_incident(
|
||||
current_user,
|
||||
incident.id,
|
||||
TransitionRequest(to_status="resolved", version=incident.version, note=None),
|
||||
)
|
||||
|
||||
|
||||
async def test_transition_incident_conflict_on_version_mismatch(
|
||||
incident_service: IncidentService, db_conn: asyncpg.Connection
|
||||
) -> None:
|
||||
current_user, service_id = await _seed_user_org_service(db_conn)
|
||||
incident = await incident_service.create_incident(
|
||||
current_user, service_id, IncidentCreate(title="Version", severity="medium", description=None)
|
||||
)
|
||||
|
||||
with pytest.raises(exc.ConflictError):
|
||||
await incident_service.transition_incident(
|
||||
current_user,
|
||||
incident.id,
|
||||
TransitionRequest(to_status="acknowledged", version=999, note=None),
|
||||
)
|
||||
|
||||
|
||||
async def test_add_comment_creates_event(
|
||||
incident_service: IncidentService, db_conn: asyncpg.Connection
|
||||
) -> None:
|
||||
current_user, service_id = await _seed_user_org_service(db_conn)
|
||||
incident = await incident_service.create_incident(
|
||||
current_user, service_id, IncidentCreate(title="Comment", severity="low", description=None)
|
||||
)
|
||||
|
||||
event = await incident_service.add_comment(
|
||||
current_user,
|
||||
incident.id,
|
||||
CommentRequest(content="Investigating"),
|
||||
)
|
||||
|
||||
assert event.event_type == "comment_added"
|
||||
assert event.payload == {"content": "Investigating"}
|
||||
Reference in New Issue
Block a user