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"}
|
||||
219
tests/services/test_org_service.py
Normal file
219
tests/services/test_org_service.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""Unit tests covering OrgService flows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.deps import CurrentUser
|
||||
from app.core import exceptions as exc, security
|
||||
from app.db import Database
|
||||
from app.repositories import NotificationRepository, OrgRepository, ServiceRepository
|
||||
from app.schemas.org import NotificationTargetCreate, ServiceCreate
|
||||
from app.services.org import OrgService
|
||||
|
||||
|
||||
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 org_service(db_conn):
|
||||
"""OrgService bound to the per-test database connection."""
|
||||
|
||||
return OrgService(database=_SingleConnectionDatabase(db_conn))
|
||||
|
||||
|
||||
async def _create_user(conn, email: str) -> UUID:
|
||||
user_id = uuid4()
|
||||
await conn.execute(
|
||||
"INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3)",
|
||||
user_id,
|
||||
email,
|
||||
security.hash_password("Password123!"),
|
||||
)
|
||||
return user_id
|
||||
|
||||
|
||||
async def _create_org(conn, name: str, slug: str | None = None) -> UUID:
|
||||
org_id = uuid4()
|
||||
org_repo = OrgRepository(conn)
|
||||
await org_repo.create(org_id, name, slug or name.lower().replace(" ", "-"))
|
||||
return org_id
|
||||
|
||||
|
||||
async def _add_membership(conn, user_id: UUID, org_id: UUID, role: str) -> None:
|
||||
await conn.execute(
|
||||
"INSERT INTO org_members (id, user_id, org_id, role) VALUES ($1, $2, $3, $4)",
|
||||
uuid4(),
|
||||
user_id,
|
||||
org_id,
|
||||
role,
|
||||
)
|
||||
|
||||
|
||||
async def _create_service(conn, org_id: UUID, name: str, slug: str) -> None:
|
||||
repo = ServiceRepository(conn)
|
||||
await repo.create(uuid4(), org_id, name, slug)
|
||||
|
||||
|
||||
async def _create_notification_target(conn, org_id: UUID, name: str) -> None:
|
||||
repo = NotificationRepository(conn)
|
||||
await repo.create_target(uuid4(), org_id, name, "webhook", "https://example.com/hook")
|
||||
|
||||
|
||||
def _make_user(user_id: UUID, email: str, org_id: UUID, role: str) -> CurrentUser:
|
||||
return CurrentUser(user_id=user_id, email=email, org_id=org_id, org_role=role, token="token")
|
||||
|
||||
|
||||
async def test_get_current_org_returns_summary(org_service, db_conn):
|
||||
org_id = await _create_org(db_conn, "Current Org", slug="current-org")
|
||||
user_id = await _create_user(db_conn, "owner@example.com")
|
||||
await _add_membership(db_conn, user_id, org_id, "admin")
|
||||
|
||||
current_user = _make_user(user_id, "owner@example.com", org_id, "admin")
|
||||
|
||||
result = await org_service.get_current_org(current_user)
|
||||
|
||||
assert result.id == org_id
|
||||
assert result.slug == "current-org"
|
||||
|
||||
|
||||
async def test_get_current_org_raises_not_found(org_service, db_conn):
|
||||
user_id = await _create_user(db_conn, "ghost@example.com")
|
||||
missing_org = uuid4()
|
||||
current_user = _make_user(user_id, "ghost@example.com", missing_org, "admin")
|
||||
|
||||
with pytest.raises(exc.NotFoundError):
|
||||
await org_service.get_current_org(current_user)
|
||||
|
||||
|
||||
async def test_get_members_returns_org_members(org_service, db_conn):
|
||||
org_id = await _create_org(db_conn, "Members Org", slug="members-org")
|
||||
admin_id = await _create_user(db_conn, "admin@example.com")
|
||||
member_id = await _create_user(db_conn, "member@example.com")
|
||||
await _add_membership(db_conn, admin_id, org_id, "admin")
|
||||
await _add_membership(db_conn, member_id, org_id, "member")
|
||||
|
||||
current_user = _make_user(admin_id, "admin@example.com", org_id, "admin")
|
||||
members = await org_service.get_members(current_user)
|
||||
|
||||
emails = {m.email for m in members}
|
||||
assert emails == {"admin@example.com", "member@example.com"}
|
||||
|
||||
|
||||
async def test_create_service_rejects_duplicate_slug(org_service, db_conn):
|
||||
org_id = await _create_org(db_conn, "Dup Org", slug="dup-org")
|
||||
user_id = await _create_user(db_conn, "service@example.com")
|
||||
await _add_membership(db_conn, user_id, org_id, "member")
|
||||
await _create_service(db_conn, org_id, "Existing", "duplicate")
|
||||
|
||||
current_user = _make_user(user_id, "service@example.com", org_id, "member")
|
||||
with pytest.raises(exc.ConflictError):
|
||||
await org_service.create_service(current_user, ServiceCreate(name="New", slug="duplicate"))
|
||||
|
||||
|
||||
async def test_create_service_persists_service(org_service, db_conn):
|
||||
org_id = await _create_org(db_conn, "Service Org", slug="service-org")
|
||||
user_id = await _create_user(db_conn, "creator@example.com")
|
||||
await _add_membership(db_conn, user_id, org_id, "member")
|
||||
current_user = _make_user(user_id, "creator@example.com", org_id, "member")
|
||||
|
||||
result = await org_service.create_service(current_user, ServiceCreate(name="API", slug="api"))
|
||||
|
||||
assert result.name == "API"
|
||||
row = await db_conn.fetchrow(
|
||||
"SELECT name, org_id FROM services WHERE id = $1",
|
||||
result.id,
|
||||
)
|
||||
assert row is not None and row["org_id"] == org_id
|
||||
|
||||
|
||||
async def test_get_services_returns_only_org_services(org_service, db_conn):
|
||||
org_id = await _create_org(db_conn, "Own Org", slug="own-org")
|
||||
other_org = await _create_org(db_conn, "Other Org", slug="other-org")
|
||||
user_id = await _create_user(db_conn, "viewer@example.com")
|
||||
await _add_membership(db_conn, user_id, org_id, "viewer")
|
||||
|
||||
await _create_service(db_conn, org_id, "Owned", "owned")
|
||||
await _create_service(db_conn, other_org, "Foreign", "foreign")
|
||||
|
||||
current_user = _make_user(user_id, "viewer@example.com", org_id, "viewer")
|
||||
services = await org_service.get_services(current_user)
|
||||
|
||||
assert len(services) == 1
|
||||
assert services[0].name == "Owned"
|
||||
|
||||
|
||||
async def test_create_notification_target_requires_webhook_url(org_service, db_conn):
|
||||
org_id = await _create_org(db_conn, "Webhook Org", slug="webhook-org")
|
||||
user_id = await _create_user(db_conn, "admin-webhook@example.com")
|
||||
await _add_membership(db_conn, user_id, org_id, "admin")
|
||||
current_user = _make_user(user_id, "admin-webhook@example.com", org_id, "admin")
|
||||
|
||||
with pytest.raises(exc.BadRequestError):
|
||||
await org_service.create_notification_target(
|
||||
current_user,
|
||||
NotificationTargetCreate(name="Hook", target_type="webhook", webhook_url=None),
|
||||
)
|
||||
|
||||
|
||||
async def test_create_notification_target_persists_target(org_service, db_conn):
|
||||
org_id = await _create_org(db_conn, "Notify Org", slug="notify-org")
|
||||
user_id = await _create_user(db_conn, "notify@example.com")
|
||||
await _add_membership(db_conn, user_id, org_id, "admin")
|
||||
current_user = _make_user(user_id, "notify@example.com", org_id, "admin")
|
||||
|
||||
target = await org_service.create_notification_target(
|
||||
current_user,
|
||||
NotificationTargetCreate(
|
||||
name="Pager", target_type="webhook", webhook_url="https://example.com/hook"
|
||||
),
|
||||
)
|
||||
|
||||
assert target.enabled is True
|
||||
row = await db_conn.fetchrow(
|
||||
"SELECT org_id, name FROM notification_targets WHERE id = $1",
|
||||
target.id,
|
||||
)
|
||||
assert row is not None and row["org_id"] == org_id
|
||||
|
||||
|
||||
async def test_get_notification_targets_scopes_to_org(org_service, db_conn):
|
||||
org_id = await _create_org(db_conn, "Scope Org", slug="scope-org")
|
||||
other_org = await _create_org(db_conn, "Scope Other", slug="scope-other")
|
||||
user_id = await _create_user(db_conn, "scope@example.com")
|
||||
await _add_membership(db_conn, user_id, org_id, "admin")
|
||||
|
||||
await _create_notification_target(db_conn, org_id, "Own Target")
|
||||
await _create_notification_target(db_conn, other_org, "Other Target")
|
||||
|
||||
current_user = _make_user(user_id, "scope@example.com", org_id, "admin")
|
||||
targets = await org_service.get_notification_targets(current_user)
|
||||
|
||||
assert len(targets) == 1
|
||||
assert targets[0].name == "Own Target"
|
||||
Reference in New Issue
Block a user