feat(incidents): add incident lifecycle api and tests

This commit is contained in:
2026-01-03 10:18:21 +00:00
parent ad94833830
commit f427d191e0
10 changed files with 1456 additions and 2 deletions

103
app/api/v1/incidents.py Normal file
View File

@@ -0,0 +1,103 @@
"""Incident API endpoints."""
from datetime import datetime
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from app.api.deps import CurrentUser, get_current_user, require_role
from app.schemas.common import PaginatedResponse
from app.schemas.incident import (
CommentRequest,
IncidentEventResponse,
IncidentResponse,
IncidentStatus,
TransitionRequest,
IncidentCreate,
)
from app.services import IncidentService
router = APIRouter(tags=["incidents"])
incident_service = IncidentService()
@router.get("/incidents", response_model=PaginatedResponse[IncidentResponse])
async def list_incidents(
status: IncidentStatus | None = Query(default=None),
cursor: datetime | None = Query(default=None, description="Cursor (created_at)"),
limit: int = Query(default=20, ge=1, le=100),
current_user: CurrentUser = Depends(get_current_user),
) -> PaginatedResponse[IncidentResponse]:
"""List incidents for the active organization."""
return await incident_service.get_incidents(
current_user,
status=status,
cursor=cursor,
limit=limit,
)
@router.post(
"/services/{service_id}/incidents",
response_model=IncidentResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_incident(
service_id: UUID,
payload: IncidentCreate,
current_user: CurrentUser = Depends(require_role("member")),
) -> IncidentResponse:
"""Create a new incident for the given service (member+)."""
return await incident_service.create_incident(current_user, service_id, payload)
@router.get("/incidents/{incident_id}", response_model=IncidentResponse)
async def get_incident(
incident_id: UUID,
current_user: CurrentUser = Depends(get_current_user),
) -> IncidentResponse:
"""Fetch a single incident by ID."""
return await incident_service.get_incident(current_user, incident_id)
@router.get("/incidents/{incident_id}/events", response_model=list[IncidentEventResponse])
async def get_incident_events(
incident_id: UUID,
current_user: CurrentUser = Depends(get_current_user),
) -> list[IncidentEventResponse]:
"""Get the event timeline for an incident."""
return await incident_service.get_incident_events(current_user, incident_id)
@router.post(
"/incidents/{incident_id}/transition",
response_model=IncidentResponse,
)
async def transition_incident(
incident_id: UUID,
payload: TransitionRequest,
current_user: CurrentUser = Depends(require_role("member")),
) -> IncidentResponse:
"""Transition an incident status (member+)."""
return await incident_service.transition_incident(current_user, incident_id, payload)
@router.post(
"/incidents/{incident_id}/comment",
response_model=IncidentEventResponse,
status_code=status.HTTP_201_CREATED,
)
async def add_comment(
incident_id: UUID,
payload: CommentRequest,
current_user: CurrentUser = Depends(require_role("member")),
) -> IncidentEventResponse:
"""Add a comment to the incident timeline (member+)."""
return await incident_service.add_comment(current_user, incident_id, payload)

72
app/api/v1/org.py Normal file
View File

@@ -0,0 +1,72 @@
"""Organization API endpoints."""
from fastapi import APIRouter, Depends, status
from app.api.deps import CurrentUser, get_current_user, require_role
from app.schemas.org import (
MemberResponse,
NotificationTargetCreate,
NotificationTargetResponse,
OrgResponse,
ServiceCreate,
ServiceResponse,
)
from app.services import OrgService
router = APIRouter(prefix="/org", tags=["org"])
org_service = OrgService()
@router.get("", response_model=OrgResponse)
async def get_org(current_user: CurrentUser = Depends(get_current_user)) -> OrgResponse:
"""Return the active organization summary for the authenticated user."""
return await org_service.get_current_org(current_user)
@router.get("/members", response_model=list[MemberResponse])
async def list_members(current_user: CurrentUser = Depends(require_role("admin"))) -> list[MemberResponse]:
"""List members of the current organization (admin only)."""
return await org_service.get_members(current_user)
@router.get("/services", response_model=list[ServiceResponse])
async def list_services(current_user: CurrentUser = Depends(get_current_user)) -> list[ServiceResponse]:
"""List services for the current organization."""
return await org_service.get_services(current_user)
@router.post("/services", response_model=ServiceResponse, status_code=status.HTTP_201_CREATED)
async def create_service(
payload: ServiceCreate,
current_user: CurrentUser = Depends(require_role("member")),
) -> ServiceResponse:
"""Create a new service within the current organization (member+)."""
return await org_service.create_service(current_user, payload)
@router.get("/notification-targets", response_model=list[NotificationTargetResponse])
async def list_notification_targets(
current_user: CurrentUser = Depends(require_role("admin")),
) -> list[NotificationTargetResponse]:
"""List notification targets for the current organization (admin only)."""
return await org_service.get_notification_targets(current_user)
@router.post(
"/notification-targets",
response_model=NotificationTargetResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_notification_target(
payload: NotificationTargetCreate,
current_user: CurrentUser = Depends(require_role("admin")),
) -> NotificationTargetResponse:
"""Create a notification target for the current organization (admin only)."""
return await org_service.create_notification_target(current_user, payload)

View File

@@ -6,7 +6,7 @@ from typing import AsyncGenerator
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.openapi.utils import get_openapi from fastapi.openapi.utils import get_openapi
from app.api.v1 import auth, health from app.api.v1 import auth, health, incidents, org
from app.config import settings from app.config import settings
from app.db import db, redis_client from app.db import db, redis_client
@@ -35,6 +35,8 @@ app = FastAPI(
app.openapi_tags = [ app.openapi_tags = [
{"name": "auth", "description": "Registration, login, token lifecycle"}, {"name": "auth", "description": "Registration, login, token lifecycle"},
{"name": "org", "description": "Organization membership, services, and notifications"},
{"name": "incidents", "description": "Incident lifecycle and timelines"},
{"name": "health", "description": "Service health probes"}, {"name": "health", "description": "Service health probes"},
] ]
@@ -67,4 +69,6 @@ app.openapi = custom_openapi # type: ignore[assignment]
# Include routers # Include routers
app.include_router(auth.router, prefix=settings.api_v1_prefix) app.include_router(auth.router, prefix=settings.api_v1_prefix)
app.include_router(incidents.router, prefix=settings.api_v1_prefix)
app.include_router(org.router, prefix=settings.api_v1_prefix)
app.include_router(health.router, prefix=settings.api_v1_prefix, tags=["health"]) app.include_router(health.router, prefix=settings.api_v1_prefix, tags=["health"])

View File

@@ -1,5 +1,7 @@
"""Service layer entrypoints.""" """Service layer entrypoints."""
from app.services.auth import AuthService from app.services.auth import AuthService
from app.services.incident import IncidentService
from app.services.org import OrgService
__all__ = ["AuthService"] __all__ = ["AuthService", "OrgService", "IncidentService"]

219
app/services/incident.py Normal file
View File

@@ -0,0 +1,219 @@
"""Incident service implementing incident lifecycle operations."""
from __future__ import annotations
from datetime import datetime
from typing import cast
from uuid import UUID, uuid4
import asyncpg
from asyncpg.pool import PoolConnectionProxy
from app.api.deps import CurrentUser, ensure_org_access
from app.core import exceptions as exc
from app.db import Database, db
from app.repositories import IncidentRepository, ServiceRepository
from app.schemas.common import PaginatedResponse
from app.schemas.incident import (
CommentRequest,
IncidentCreate,
IncidentEventResponse,
IncidentResponse,
TransitionRequest,
)
_ALLOWED_TRANSITIONS: dict[str, set[str]] = {
"triggered": {"acknowledged"},
"acknowledged": {"mitigated"},
"mitigated": {"resolved"},
"resolved": set(),
}
def _as_conn(conn: asyncpg.Connection | PoolConnectionProxy) -> asyncpg.Connection:
"""Helper to satisfy typing when a pool proxy is returned."""
return cast(asyncpg.Connection, conn)
class IncidentService:
"""Encapsulates incident lifecycle operations within an org context."""
def __init__(self, database: Database | None = None) -> None:
self.db = database or db
async def create_incident(
self,
current_user: CurrentUser,
service_id: UUID,
data: IncidentCreate,
) -> IncidentResponse:
"""Create an incident for a service in the active org and record the creation event."""
async with self.db.transaction() as conn:
db_conn = _as_conn(conn)
service_repo = ServiceRepository(db_conn)
incident_repo = IncidentRepository(db_conn)
service = await service_repo.get_by_id(service_id)
if service is None:
raise exc.NotFoundError("Service not found")
ensure_org_access(service["org_id"], current_user)
incident_id = uuid4()
incident = await incident_repo.create(
incident_id=incident_id,
org_id=current_user.org_id,
service_id=service_id,
title=data.title,
description=data.description,
severity=data.severity,
)
await incident_repo.add_event(
uuid4(),
incident_id,
"created",
actor_user_id=current_user.user_id,
payload={
"title": data.title,
"severity": data.severity,
"description": data.description,
},
)
return IncidentResponse(**incident)
async def get_incidents(
self,
current_user: CurrentUser,
*,
status: str | None = None,
cursor: datetime | None = None,
limit: int = 20,
) -> PaginatedResponse[IncidentResponse]:
"""Return paginated incidents for the active organization."""
async with self.db.connection() as conn:
incident_repo = IncidentRepository(_as_conn(conn))
rows = await incident_repo.get_by_org(
org_id=current_user.org_id,
status=status,
cursor=cursor,
limit=limit,
)
has_more = len(rows) > limit
items = rows[:limit]
next_cursor = items[-1]["created_at"].isoformat() if has_more and items else None
incidents = [IncidentResponse(**row) for row in items]
return PaginatedResponse[IncidentResponse](
items=incidents,
next_cursor=next_cursor,
has_more=has_more,
)
async def get_incident(self, current_user: CurrentUser, incident_id: UUID) -> IncidentResponse:
"""Return a single incident, ensuring it belongs to the active org."""
async with self.db.connection() as conn:
incident_repo = IncidentRepository(_as_conn(conn))
incident = await incident_repo.get_by_id(incident_id)
if incident is None:
raise exc.NotFoundError("Incident not found")
ensure_org_access(incident["org_id"], current_user)
return IncidentResponse(**incident)
async def get_incident_events(
self, current_user: CurrentUser, incident_id: UUID
) -> list[IncidentEventResponse]:
"""Return the timeline events for an incident in the active org."""
async with self.db.connection() as conn:
incident_repo = IncidentRepository(_as_conn(conn))
incident = await incident_repo.get_by_id(incident_id)
if incident is None:
raise exc.NotFoundError("Incident not found")
ensure_org_access(incident["org_id"], current_user)
events = await incident_repo.get_events(incident_id)
return [IncidentEventResponse(**event) for event in events]
async def transition_incident(
self,
current_user: CurrentUser,
incident_id: UUID,
data: TransitionRequest,
) -> IncidentResponse:
"""Transition an incident status with optimistic locking and event recording."""
async with self.db.transaction() as conn:
db_conn = _as_conn(conn)
incident_repo = IncidentRepository(db_conn)
incident = await incident_repo.get_by_id(incident_id)
if incident is None:
raise exc.NotFoundError("Incident not found")
ensure_org_access(incident["org_id"], current_user)
self._validate_transition(incident["status"], data.to_status)
updated = await incident_repo.update_status(
incident_id,
data.to_status,
data.version,
)
if updated is None:
raise exc.ConflictError("Incident version mismatch")
payload = {"from": incident["status"], "to": data.to_status}
if data.note:
payload["note"] = data.note
await incident_repo.add_event(
uuid4(),
incident_id,
"status_changed",
actor_user_id=current_user.user_id,
payload=payload,
)
return IncidentResponse(**updated)
async def add_comment(
self,
current_user: CurrentUser,
incident_id: UUID,
data: CommentRequest,
) -> IncidentEventResponse:
"""Add a comment event to the incident timeline."""
async with self.db.connection() as conn:
incident_repo = IncidentRepository(_as_conn(conn))
incident = await incident_repo.get_by_id(incident_id)
if incident is None:
raise exc.NotFoundError("Incident not found")
ensure_org_access(incident["org_id"], current_user)
event = await incident_repo.add_event(
uuid4(),
incident_id,
"comment_added",
actor_user_id=current_user.user_id,
payload={"content": data.content},
)
return IncidentEventResponse(**event)
def _validate_transition(self, current_status: str, to_status: str) -> None:
"""Validate a requested status transition against the allowed state machine."""
if current_status == to_status:
raise exc.BadRequestError("Incident is already in the requested status")
allowed = _ALLOWED_TRANSITIONS.get(current_status, set())
if to_status not in allowed:
raise exc.BadRequestError("Invalid incident status transition")
__all__ = ["IncidentService"]

115
app/services/org.py Normal file
View File

@@ -0,0 +1,115 @@
"""Organization service providing org-scoped operations."""
from __future__ import annotations
from typing import cast
from uuid import UUID, uuid4
import asyncpg
from asyncpg.pool import PoolConnectionProxy
from app.api.deps import CurrentUser
from app.core import exceptions as exc
from app.db import Database, db
from app.repositories import NotificationRepository, OrgRepository, ServiceRepository
from app.schemas.org import (
MemberResponse,
NotificationTargetCreate,
NotificationTargetResponse,
OrgResponse,
ServiceCreate,
ServiceResponse,
)
def _as_conn(conn: asyncpg.Connection | PoolConnectionProxy) -> asyncpg.Connection:
"""Helper to satisfy typing when a pool proxy is returned."""
return cast(asyncpg.Connection, conn)
class OrgService:
"""Encapsulates organization-level operations within the active org context."""
def __init__(self, database: Database | None = None) -> None:
self.db = database or db
async def get_current_org(self, current_user: CurrentUser) -> OrgResponse:
"""Return the active organization summary for the current user."""
async with self.db.connection() as conn:
org_repo = OrgRepository(_as_conn(conn))
org = await org_repo.get_by_id(current_user.org_id)
if org is None:
raise exc.NotFoundError("Organization not found")
return OrgResponse(**org)
async def get_members(self, current_user: CurrentUser) -> list[MemberResponse]:
"""List members of the active organization."""
async with self.db.connection() as conn:
org_repo = OrgRepository(_as_conn(conn))
members = await org_repo.get_members(current_user.org_id)
return [MemberResponse(**member) for member in members]
async def create_service(self, current_user: CurrentUser, data: ServiceCreate) -> ServiceResponse:
"""Create a new service within the active organization."""
async with self.db.connection() as conn:
service_repo = ServiceRepository(_as_conn(conn))
if await service_repo.slug_exists(current_user.org_id, data.slug):
raise exc.ConflictError("Service slug already exists in this organization")
try:
service = await service_repo.create(
service_id=uuid4(),
org_id=current_user.org_id,
name=data.name,
slug=data.slug,
)
except asyncpg.UniqueViolationError as err: # pragma: no cover - race protection
raise exc.ConflictError("Service slug already exists in this organization") from err
return ServiceResponse(**service)
async def get_services(self, current_user: CurrentUser) -> list[ServiceResponse]:
"""List services for the active organization."""
async with self.db.connection() as conn:
service_repo = ServiceRepository(_as_conn(conn))
services = await service_repo.get_by_org(current_user.org_id)
return [ServiceResponse(**svc) for svc in services]
async def create_notification_target(
self,
current_user: CurrentUser,
data: NotificationTargetCreate,
) -> NotificationTargetResponse:
"""Create a notification target for the active organization."""
if data.target_type == "webhook" and data.webhook_url is None:
raise exc.BadRequestError("webhook_url is required for webhook targets")
async with self.db.connection() as conn:
notification_repo = NotificationRepository(_as_conn(conn))
target = await notification_repo.create_target(
target_id=uuid4(),
org_id=current_user.org_id,
name=data.name,
target_type=data.target_type,
webhook_url=str(data.webhook_url) if data.webhook_url else None,
enabled=data.enabled,
)
return NotificationTargetResponse(**target)
async def get_notification_targets(self, current_user: CurrentUser) -> list[NotificationTargetResponse]:
"""List notification targets for the active organization."""
async with self.db.connection() as conn:
notification_repo = NotificationRepository(_as_conn(conn))
targets = await notification_repo.get_targets_by_org(current_user.org_id)
return [NotificationTargetResponse(**target) for target in targets]
__all__ = ["OrgService"]

230
tests/api/test_incidents.py Normal file
View File

@@ -0,0 +1,230 @@
"""Integration tests for incident endpoints."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from uuid import UUID, uuid4
import asyncpg
import pytest
from httpx import AsyncClient
from app.core import security
from app.repositories.incident import IncidentRepository
from tests.api import helpers
pytestmark = pytest.mark.asyncio
API_PREFIX = "/v1"
async def _create_service(conn: asyncpg.Connection, org_id: UUID, slug: str = "api") -> UUID:
service_id = uuid4()
await conn.execute(
"INSERT INTO services (id, org_id, name, slug) VALUES ($1, $2, $3, $4)",
service_id,
org_id,
"API",
slug,
)
return service_id
async def _create_incident(
conn: asyncpg.Connection,
org_id: UUID,
service_id: UUID,
title: str,
severity: str = "low",
created_at: datetime | None = None,
) -> UUID:
repo = IncidentRepository(conn)
incident_id = uuid4()
incident = await repo.create(
incident_id,
org_id,
service_id,
title,
description=None,
severity=severity,
)
if created_at:
await conn.execute(
"UPDATE incidents SET created_at = $1 WHERE id = $2",
created_at,
incident_id,
)
return incident["id"]
async def _login(client: AsyncClient, *, email: str, password: str) -> dict:
response = await client.post(
f"{API_PREFIX}/auth/login",
json={"email": email, "password": password},
)
response.raise_for_status()
return response.json()
async def test_create_incident_requires_member_role(
api_client: AsyncClient, db_admin: asyncpg.Connection
) -> None:
owner_tokens = await helpers.register_user(
api_client,
email="owner-inc@example.com",
password="OwnerInc1!",
org_name="Incident Org",
)
payload = security.decode_access_token(owner_tokens["access_token"])
org_id = UUID(payload["org_id"])
service_id = await _create_service(db_admin, org_id)
viewer_password = "Viewer123!"
viewer_id = uuid4()
await db_admin.execute(
"INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3)",
viewer_id,
"viewer@example.com",
security.hash_password(viewer_password),
)
await db_admin.execute(
"INSERT INTO org_members (id, user_id, org_id, role) VALUES ($1, $2, $3, $4)",
uuid4(),
viewer_id,
org_id,
"viewer",
)
viewer_tokens = await _login(api_client, email="viewer@example.com", password=viewer_password)
forbidden = await api_client.post(
f"{API_PREFIX}/services/{service_id}/incidents",
json={"title": "View only", "description": None, "severity": "low"},
headers={"Authorization": f"Bearer {viewer_tokens['access_token']}"},
)
assert forbidden.status_code == 403
created = await api_client.post(
f"{API_PREFIX}/services/{service_id}/incidents",
json={"title": "Database down", "description": "Primary unavailable", "severity": "critical"},
headers={"Authorization": f"Bearer {owner_tokens['access_token']}"},
)
assert created.status_code == 201
incident_id = UUID(created.json()["id"])
row = await db_admin.fetchrow(
"SELECT status, org_id FROM incidents WHERE id = $1",
incident_id,
)
assert row is not None and row["status"] == "triggered" and row["org_id"] == org_id
event = await db_admin.fetchrow(
"SELECT event_type FROM incident_events WHERE incident_id = $1",
incident_id,
)
assert event is not None and event["event_type"] == "created"
async def test_list_incidents_paginates_and_isolates_org(
api_client: AsyncClient, db_admin: asyncpg.Connection
) -> None:
tokens = await helpers.register_user(
api_client,
email="pager@example.com",
password="Pager123!",
org_name="Pager Org",
)
payload = security.decode_access_token(tokens["access_token"])
org_id = UUID(payload["org_id"])
service_id = await _create_service(db_admin, org_id)
now = datetime.now(UTC)
await _create_incident(db_admin, org_id, service_id, "Old", created_at=now - timedelta(minutes=3))
await _create_incident(db_admin, org_id, service_id, "Mid", created_at=now - timedelta(minutes=2))
await _create_incident(db_admin, org_id, service_id, "New", created_at=now - timedelta(minutes=1))
# Noise in another org
other_org = await helpers.create_org(db_admin, name="Other", slug="other")
other_service = await _create_service(db_admin, other_org, slug="other-api")
await _create_incident(db_admin, other_org, other_service, "Other incident")
response = await api_client.get(
f"{API_PREFIX}/incidents",
params={"limit": 2},
headers={"Authorization": f"Bearer {tokens['access_token']}"},
)
assert response.status_code == 200
body = response.json()
titles = [item["title"] for item in body["items"]]
assert titles == ["New", "Mid"]
assert body["has_more"] is True
assert body["next_cursor"] is not None
async def test_transition_incident_enforces_version_and_updates_status(
api_client: AsyncClient, db_admin: asyncpg.Connection
) -> None:
tokens = await helpers.register_user(
api_client,
email="trans@example.com",
password="Trans123!",
org_name="Trans Org",
)
payload = security.decode_access_token(tokens["access_token"])
org_id = UUID(payload["org_id"])
service_id = await _create_service(db_admin, org_id)
incident_id = await _create_incident(db_admin, org_id, service_id, "Queue backlog")
conflict = await api_client.post(
f"{API_PREFIX}/incidents/{incident_id}/transition",
json={"to_status": "acknowledged", "version": 5, "note": None},
headers={"Authorization": f"Bearer {tokens['access_token']}"},
)
assert conflict.status_code == 409
ok = await api_client.post(
f"{API_PREFIX}/incidents/{incident_id}/transition",
json={"to_status": "acknowledged", "version": 1, "note": "Looking"},
headers={"Authorization": f"Bearer {tokens['access_token']}"},
)
assert ok.status_code == 200
assert ok.json()["status"] == "acknowledged"
assert ok.json()["version"] == 2
async def test_add_comment_appends_event(
api_client: AsyncClient, db_admin: asyncpg.Connection
) -> None:
tokens = await helpers.register_user(
api_client,
email="commenter@example.com",
password="Commenter1!",
org_name="Comment Org",
)
payload = security.decode_access_token(tokens["access_token"])
org_id = UUID(payload["org_id"])
service_id = await _create_service(db_admin, org_id)
incident_id = await _create_incident(db_admin, org_id, service_id, "Add comment")
response = await api_client.post(
f"{API_PREFIX}/incidents/{incident_id}/comment",
json={"content": "Monitoring"},
headers={"Authorization": f"Bearer {tokens['access_token']}"},
)
assert response.status_code == 201
body = response.json()
assert body["event_type"] == "comment_added"
assert body["payload"] == {"content": "Monitoring"}
event_row = await db_admin.fetchrow(
"SELECT event_type, actor_user_id FROM incident_events WHERE id = $1",
UUID(body["id"]),
)
assert event_row is not None
assert event_row["event_type"] == "comment_added"

238
tests/api/test_org.py Normal file
View File

@@ -0,0 +1,238 @@
"""Integration tests for org endpoints."""
from __future__ import annotations
from uuid import UUID, uuid4
import asyncpg
import pytest
from httpx import AsyncClient
from app.core import security
from tests.api import helpers
pytestmark = pytest.mark.asyncio
API_PREFIX = "/v1/org"
async def _create_user_in_org(
conn: asyncpg.Connection,
*,
org_id: UUID,
email: str,
password: str,
role: 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(password),
)
await conn.execute(
"INSERT INTO org_members (id, user_id, org_id, role) VALUES ($1, $2, $3, $4)",
uuid4(),
user_id,
org_id,
role,
)
return user_id
async def _login(client: AsyncClient, *, email: str, password: str) -> dict:
response = await client.post(
"/v1/auth/login",
json={"email": email, "password": password},
)
response.raise_for_status()
return response.json()
async def test_get_org_returns_active_org(api_client: AsyncClient) -> None:
tokens = await helpers.register_user(
api_client,
email="org-owner@example.com",
password="OrgOwner1!",
org_name="Org Owner Inc",
)
response = await api_client.get(
API_PREFIX,
headers={"Authorization": f"Bearer {tokens['access_token']}",},
)
assert response.status_code == 200
data = response.json()
payload = security.decode_access_token(tokens["access_token"])
assert data["id"] == payload["org_id"]
assert data["name"] == "Org Owner Inc"
async def test_get_members_requires_admin(
api_client: AsyncClient,
db_admin: asyncpg.Connection,
) -> None:
owner_tokens = await helpers.register_user(
api_client,
email="owner@example.com",
password="OwnerPass1!",
org_name="Members Co",
)
payload = security.decode_access_token(owner_tokens["access_token"])
org_id = UUID(payload["org_id"])
member_password = "MemberPass1!"
await _create_user_in_org(
db_admin,
org_id=org_id,
email="member@example.com",
password=member_password,
role="member",
)
member_tokens = await _login(api_client, email="member@example.com", password=member_password)
admin_response = await api_client.get(
f"{API_PREFIX}/members",
headers={"Authorization": f"Bearer {owner_tokens['access_token']}"},
)
assert admin_response.status_code == 200
emails = {item["email"] for item in admin_response.json()}
assert emails == {"owner@example.com", "member@example.com"}
member_response = await api_client.get(
f"{API_PREFIX}/members",
headers={"Authorization": f"Bearer {member_tokens['access_token']}"},
)
assert member_response.status_code == 403
async def test_create_service_allows_member_and_persists(
api_client: AsyncClient,
db_admin: asyncpg.Connection,
) -> None:
owner_tokens = await helpers.register_user(
api_client,
email="service-owner@example.com",
password="ServiceOwner1!",
org_name="Service Org",
)
payload = security.decode_access_token(owner_tokens["access_token"])
org_id = UUID(payload["org_id"])
member_password = "CreateSvc1!"
await _create_user_in_org(
db_admin,
org_id=org_id,
email="svc-member@example.com",
password=member_password,
role="member",
)
member_tokens = await _login(api_client, email="svc-member@example.com", password=member_password)
response = await api_client.post(
f"{API_PREFIX}/services",
json={"name": "API Gateway", "slug": "api-gateway"},
headers={"Authorization": f"Bearer {member_tokens['access_token']}"},
)
assert response.status_code == 201
body = response.json()
row = await db_admin.fetchrow(
"SELECT org_id, slug FROM services WHERE id = $1",
UUID(body["id"]),
)
assert row is not None and row["org_id"] == org_id and row["slug"] == "api-gateway"
async def test_create_service_rejects_duplicate_slug(
api_client: AsyncClient,
db_admin: asyncpg.Connection,
) -> None:
tokens = await helpers.register_user(
api_client,
email="dup-owner@example.com",
password="DupOwner1!",
org_name="Dup Org",
)
payload = security.decode_access_token(tokens["access_token"])
org_id = UUID(payload["org_id"])
await db_admin.execute(
"INSERT INTO services (id, org_id, name, slug) VALUES ($1, $2, $3, $4)",
uuid4(),
org_id,
"Existing",
"duplicate",
)
response = await api_client.post(
f"{API_PREFIX}/services",
json={"name": "New", "slug": "duplicate"},
headers={"Authorization": f"Bearer {tokens['access_token']}"},
)
assert response.status_code == 409
async def test_notification_targets_admin_only_and_validation(
api_client: AsyncClient,
db_admin: asyncpg.Connection,
) -> None:
owner_tokens = await helpers.register_user(
api_client,
email="notify-owner@example.com",
password="NotifyOwner1!",
org_name="Notify Org",
)
payload = security.decode_access_token(owner_tokens["access_token"])
org_id = UUID(payload["org_id"])
member_password = "NotifyMember1!"
await _create_user_in_org(
db_admin,
org_id=org_id,
email="notify-member@example.com",
password=member_password,
role="member",
)
member_tokens = await _login(api_client, email="notify-member@example.com", password=member_password)
forbidden = await api_client.post(
f"{API_PREFIX}/notification-targets",
json={"name": "Webhook", "target_type": "webhook", "webhook_url": "https://example.com"},
headers={"Authorization": f"Bearer {member_tokens['access_token']}"},
)
assert forbidden.status_code == 403
missing_url = await api_client.post(
f"{API_PREFIX}/notification-targets",
json={"name": "Bad", "target_type": "webhook"},
headers={"Authorization": f"Bearer {owner_tokens['access_token']}"},
)
assert missing_url.status_code == 400
created = await api_client.post(
f"{API_PREFIX}/notification-targets",
json={"name": "Pager", "target_type": "webhook", "webhook_url": "https://example.com/hook"},
headers={"Authorization": f"Bearer {owner_tokens['access_token']}"},
)
assert created.status_code == 201
target_id = UUID(created.json()["id"])
row = await db_admin.fetchrow(
"SELECT org_id, name FROM notification_targets WHERE id = $1",
target_id,
)
assert row is not None and row["org_id"] == org_id
listing = await api_client.get(
f"{API_PREFIX}/notification-targets",
headers={"Authorization": f"Bearer {owner_tokens['access_token']}"},
)
assert listing.status_code == 200
names = [item["name"] for item in listing.json()]
assert names == ["Pager"]

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

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