feat(incidents): add incident lifecycle api and tests
This commit is contained in:
103
app/api/v1/incidents.py
Normal file
103
app/api/v1/incidents.py
Normal 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
72
app/api/v1/org.py
Normal 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)
|
||||
@@ -6,7 +6,7 @@ from typing import AsyncGenerator
|
||||
from fastapi import FastAPI
|
||||
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.db import db, redis_client
|
||||
|
||||
@@ -35,6 +35,8 @@ app = FastAPI(
|
||||
|
||||
app.openapi_tags = [
|
||||
{"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"},
|
||||
]
|
||||
|
||||
@@ -67,4 +69,6 @@ app.openapi = custom_openapi # type: ignore[assignment]
|
||||
|
||||
# Include routers
|
||||
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"])
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Service layer entrypoints."""
|
||||
|
||||
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
219
app/services/incident.py
Normal 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
115
app/services/org.py
Normal 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
230
tests/api/test_incidents.py
Normal 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
238
tests/api/test_org.py
Normal 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"]
|
||||
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