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