feat(api): Pydantic schemas + Data Repositories

This commit is contained in:
2025-12-07 03:58:02 -05:00
parent fbe9fbba6e
commit a8fbce09c4
23 changed files with 3549 additions and 3 deletions

View File

@@ -0,0 +1,389 @@
"""Tests for IncidentRepository."""
from uuid import uuid4
import asyncpg
import pytest
from app.repositories.incident import IncidentRepository
from app.repositories.org import OrgRepository
from app.repositories.service import ServiceRepository
from app.repositories.user import UserRepository
class TestIncidentRepository:
"""Tests for IncidentRepository conforming to SPECS.md."""
async def _create_org(self, conn: asyncpg.Connection, slug: str) -> uuid4:
"""Helper to create an org."""
org_repo = OrgRepository(conn)
org_id = uuid4()
await org_repo.create(org_id, f"Org {slug}", slug)
return org_id
async def _create_service(self, conn: asyncpg.Connection, org_id: uuid4, slug: str) -> uuid4:
"""Helper to create a service."""
service_repo = ServiceRepository(conn)
service_id = uuid4()
await service_repo.create(service_id, org_id, f"Service {slug}", slug)
return service_id
async def _create_user(self, conn: asyncpg.Connection, email: str) -> uuid4:
"""Helper to create a user."""
user_repo = UserRepository(conn)
user_id = uuid4()
await user_repo.create(user_id, email, "hash")
return user_id
async def test_create_incident_returns_incident_data(self, db_conn: asyncpg.Connection) -> None:
"""Creating an incident returns the incident data with triggered status."""
org_id = await self._create_org(db_conn, "incident-org")
service_id = await self._create_service(db_conn, org_id, "incident-service")
repo = IncidentRepository(db_conn)
incident_id = uuid4()
result = await repo.create(
incident_id, org_id, service_id,
title="Server Down",
description="Main API server is not responding",
severity="critical"
)
assert result["id"] == incident_id
assert result["org_id"] == org_id
assert result["service_id"] == service_id
assert result["title"] == "Server Down"
assert result["description"] == "Main API server is not responding"
assert result["status"] == "triggered" # Initial status per SPECS.md
assert result["severity"] == "critical"
assert result["version"] == 1
assert result["created_at"] is not None
assert result["updated_at"] is not None
async def test_create_incident_initial_status_is_triggered(self, db_conn: asyncpg.Connection) -> None:
"""New incidents always start with 'triggered' status per SPECS.md state machine."""
org_id = await self._create_org(db_conn, "triggered-org")
service_id = await self._create_service(db_conn, org_id, "triggered-service")
repo = IncidentRepository(db_conn)
result = await repo.create(uuid4(), org_id, service_id, "Test", None, "low")
assert result["status"] == "triggered"
async def test_create_incident_initial_version_is_one(self, db_conn: asyncpg.Connection) -> None:
"""New incidents start with version 1 for optimistic locking."""
org_id = await self._create_org(db_conn, "version-org")
service_id = await self._create_service(db_conn, org_id, "version-service")
repo = IncidentRepository(db_conn)
result = await repo.create(uuid4(), org_id, service_id, "Test", None, "medium")
assert result["version"] == 1
async def test_create_incident_severity_must_be_valid(self, db_conn: asyncpg.Connection) -> None:
"""Severity must be critical, high, medium, or low per SPECS.md."""
org_id = await self._create_org(db_conn, "severity-org")
service_id = await self._create_service(db_conn, org_id, "severity-service")
repo = IncidentRepository(db_conn)
# Valid severities
for severity in ["critical", "high", "medium", "low"]:
result = await repo.create(uuid4(), org_id, service_id, f"Test {severity}", None, severity)
assert result["severity"] == severity
# Invalid severity
with pytest.raises(asyncpg.CheckViolationError):
await repo.create(uuid4(), org_id, service_id, "Invalid", None, "extreme")
async def test_get_by_id_returns_incident(self, db_conn: asyncpg.Connection) -> None:
"""get_by_id returns the correct incident."""
org_id = await self._create_org(db_conn, "getbyid-org")
service_id = await self._create_service(db_conn, org_id, "getbyid-service")
repo = IncidentRepository(db_conn)
incident_id = uuid4()
await repo.create(incident_id, org_id, service_id, "My Incident", "Details", "high")
result = await repo.get_by_id(incident_id)
assert result is not None
assert result["id"] == incident_id
assert result["title"] == "My Incident"
async def test_get_by_id_returns_none_for_nonexistent(self, db_conn: asyncpg.Connection) -> None:
"""get_by_id returns None for non-existent incident."""
repo = IncidentRepository(db_conn)
result = await repo.get_by_id(uuid4())
assert result is None
async def test_get_by_org_returns_org_incidents(self, db_conn: asyncpg.Connection) -> None:
"""get_by_org returns incidents for the organization."""
org_id = await self._create_org(db_conn, "list-org")
service_id = await self._create_service(db_conn, org_id, "list-service")
repo = IncidentRepository(db_conn)
await repo.create(uuid4(), org_id, service_id, "Incident 1", None, "low")
await repo.create(uuid4(), org_id, service_id, "Incident 2", None, "medium")
await repo.create(uuid4(), org_id, service_id, "Incident 3", None, "high")
result = await repo.get_by_org(org_id)
assert len(result) == 3
async def test_get_by_org_filters_by_status(self, db_conn: asyncpg.Connection) -> None:
"""get_by_org can filter by status."""
org_id = await self._create_org(db_conn, "filter-org")
service_id = await self._create_service(db_conn, org_id, "filter-service")
repo = IncidentRepository(db_conn)
# Create incidents and transition some
inc1 = uuid4()
inc2 = uuid4()
await repo.create(inc1, org_id, service_id, "Triggered", None, "low")
await repo.create(inc2, org_id, service_id, "Will be Acked", None, "low")
await repo.update_status(inc2, "acknowledged", 1)
result = await repo.get_by_org(org_id, status="triggered")
assert len(result) == 1
assert result[0]["title"] == "Triggered"
async def test_get_by_org_pagination_with_cursor(self, db_conn: asyncpg.Connection) -> None:
"""get_by_org supports cursor-based pagination."""
org_id = await self._create_org(db_conn, "pagination-org")
service_id = await self._create_service(db_conn, org_id, "pagination-service")
repo = IncidentRepository(db_conn)
# Create 5 incidents
for i in range(5):
await repo.create(uuid4(), org_id, service_id, f"Incident {i}", None, "low")
# Get first page - should return limit+1 to check for more
page1 = await repo.get_by_org(org_id, limit=2)
assert len(page1) == 3
# Verify total is 5 when we get all
all_incidents = await repo.get_by_org(org_id, limit=10)
assert len(all_incidents) == 5
async def test_get_by_org_orders_by_created_at_desc(self, db_conn: asyncpg.Connection) -> None:
"""get_by_org returns incidents ordered by created_at descending."""
org_id = await self._create_org(db_conn, "order-org")
service_id = await self._create_service(db_conn, org_id, "order-service")
repo = IncidentRepository(db_conn)
await repo.create(uuid4(), org_id, service_id, "First", None, "low")
await repo.create(uuid4(), org_id, service_id, "Second", None, "low")
await repo.create(uuid4(), org_id, service_id, "Third", None, "low")
result = await repo.get_by_org(org_id)
# Verify ordering - newer items should come first (or same time due to fast execution)
assert len(result) == 3
for i in range(len(result) - 1):
assert result[i]["created_at"] >= result[i + 1]["created_at"]
async def test_get_by_org_tenant_isolation(self, db_conn: asyncpg.Connection) -> None:
"""get_by_org only returns incidents for the specified org."""
org1 = await self._create_org(db_conn, "tenant-org-1")
org2 = await self._create_org(db_conn, "tenant-org-2")
service1 = await self._create_service(db_conn, org1, "tenant-service-1")
service2 = await self._create_service(db_conn, org2, "tenant-service-2")
repo = IncidentRepository(db_conn)
await repo.create(uuid4(), org1, service1, "Org1 Incident", None, "low")
await repo.create(uuid4(), org2, service2, "Org2 Incident", None, "low")
result = await repo.get_by_org(org1)
assert len(result) == 1
assert result[0]["title"] == "Org1 Incident"
class TestIncidentStatusTransitions:
"""Tests for incident status transitions per SPECS.md state machine."""
async def _setup_incident(self, conn: asyncpg.Connection) -> tuple[uuid4, IncidentRepository]:
"""Helper to create org, service, and incident."""
org_repo = OrgRepository(conn)
service_repo = ServiceRepository(conn)
incident_repo = IncidentRepository(conn)
org_id = uuid4()
service_id = uuid4()
incident_id = uuid4()
await org_repo.create(org_id, "Test Org", f"test-org-{uuid4().hex[:8]}")
await service_repo.create(service_id, org_id, "Test Service", f"test-service-{uuid4().hex[:8]}")
await incident_repo.create(incident_id, org_id, service_id, "Test Incident", None, "medium")
return incident_id, incident_repo
async def test_update_status_increments_version(self, db_conn: asyncpg.Connection) -> None:
"""update_status increments version for optimistic locking."""
incident_id, repo = await self._setup_incident(db_conn)
result = await repo.update_status(incident_id, "acknowledged", 1)
assert result is not None
assert result["version"] == 2
async def test_update_status_fails_on_version_mismatch(self, db_conn: asyncpg.Connection) -> None:
"""update_status returns None on version mismatch (optimistic locking)."""
incident_id, repo = await self._setup_incident(db_conn)
# Try with wrong version
result = await repo.update_status(incident_id, "acknowledged", 999)
assert result is None
async def test_update_status_updates_updated_at(self, db_conn: asyncpg.Connection) -> None:
"""update_status updates the updated_at timestamp."""
incident_id, repo = await self._setup_incident(db_conn)
before = await repo.get_by_id(incident_id)
result = await repo.update_status(incident_id, "acknowledged", 1)
# updated_at should be at least as recent as before (may be same in fast execution)
assert result["updated_at"] >= before["updated_at"]
# Also verify status was actually updated
assert result["status"] == "acknowledged"
async def test_status_must_be_valid_value(self, db_conn: asyncpg.Connection) -> None:
"""Status must be triggered, acknowledged, mitigated, or resolved per SPECS.md."""
incident_id, repo = await self._setup_incident(db_conn)
with pytest.raises(asyncpg.CheckViolationError):
await repo.update_status(incident_id, "invalid_status", 1)
async def test_valid_status_transitions(self, db_conn: asyncpg.Connection) -> None:
"""Test the valid status values per SPECS.md."""
incident_id, repo = await self._setup_incident(db_conn)
# Triggered -> Acknowledged
result = await repo.update_status(incident_id, "acknowledged", 1)
assert result["status"] == "acknowledged"
# Acknowledged -> Mitigated
result = await repo.update_status(incident_id, "mitigated", 2)
assert result["status"] == "mitigated"
# Mitigated -> Resolved
result = await repo.update_status(incident_id, "resolved", 3)
assert result["status"] == "resolved"
class TestIncidentEvents:
"""Tests for incident events (timeline) per SPECS.md incident_events table."""
async def _setup_incident(self, conn: asyncpg.Connection) -> tuple[uuid4, uuid4, IncidentRepository]:
"""Helper to create org, service, user, and incident."""
org_repo = OrgRepository(conn)
service_repo = ServiceRepository(conn)
user_repo = UserRepository(conn)
incident_repo = IncidentRepository(conn)
org_id = uuid4()
service_id = uuid4()
user_id = uuid4()
incident_id = uuid4()
await org_repo.create(org_id, "Test Org", f"test-org-{uuid4().hex[:8]}")
await service_repo.create(service_id, org_id, "Test Service", f"test-svc-{uuid4().hex[:8]}")
await user_repo.create(user_id, f"user-{uuid4().hex[:8]}@example.com", "hash")
await incident_repo.create(incident_id, org_id, service_id, "Test Incident", None, "medium")
return incident_id, user_id, incident_repo
async def test_add_event_creates_event(self, db_conn: asyncpg.Connection) -> None:
"""add_event creates an event in the timeline."""
incident_id, user_id, repo = await self._setup_incident(db_conn)
event_id = uuid4()
result = await repo.add_event(
event_id, incident_id, "status_changed",
actor_user_id=user_id,
payload={"from": "triggered", "to": "acknowledged"}
)
assert result["id"] == event_id
assert result["incident_id"] == incident_id
assert result["event_type"] == "status_changed"
assert result["actor_user_id"] == user_id
assert result["payload"] == {"from": "triggered", "to": "acknowledged"}
assert result["created_at"] is not None
async def test_add_event_allows_null_actor(self, db_conn: asyncpg.Connection) -> None:
"""add_event allows null actor_user_id (system events)."""
incident_id, _, repo = await self._setup_incident(db_conn)
result = await repo.add_event(
uuid4(), incident_id, "auto_escalated",
actor_user_id=None,
payload={"reason": "Unacknowledged after 30 minutes"}
)
assert result["actor_user_id"] is None
async def test_add_event_allows_null_payload(self, db_conn: asyncpg.Connection) -> None:
"""add_event allows null payload."""
incident_id, user_id, repo = await self._setup_incident(db_conn)
result = await repo.add_event(
uuid4(), incident_id, "viewed",
actor_user_id=user_id,
payload=None
)
assert result["payload"] is None
async def test_get_events_returns_all_incident_events(self, db_conn: asyncpg.Connection) -> None:
"""get_events returns all events for an incident."""
incident_id, user_id, repo = await self._setup_incident(db_conn)
await repo.add_event(uuid4(), incident_id, "created", user_id, {"title": "Test"})
await repo.add_event(uuid4(), incident_id, "status_changed", user_id, {"to": "acked"})
await repo.add_event(uuid4(), incident_id, "comment_added", user_id, {"text": "Working on it"})
result = await repo.get_events(incident_id)
assert len(result) == 3
event_types = [e["event_type"] for e in result]
assert event_types == ["created", "status_changed", "comment_added"]
async def test_get_events_orders_by_created_at(self, db_conn: asyncpg.Connection) -> None:
"""get_events returns events in chronological order."""
incident_id, user_id, repo = await self._setup_incident(db_conn)
await repo.add_event(uuid4(), incident_id, "first", user_id, None)
await repo.add_event(uuid4(), incident_id, "second", user_id, None)
await repo.add_event(uuid4(), incident_id, "third", user_id, None)
result = await repo.get_events(incident_id)
assert result[0]["event_type"] == "first"
assert result[1]["event_type"] == "second"
assert result[2]["event_type"] == "third"
async def test_get_events_returns_empty_for_no_events(self, db_conn: asyncpg.Connection) -> None:
"""get_events returns empty list for incident with no events."""
incident_id, _, repo = await self._setup_incident(db_conn)
result = await repo.get_events(incident_id)
assert result == []
async def test_event_requires_valid_incident_foreign_key(self, db_conn: asyncpg.Connection) -> None:
"""incident_events.incident_id must reference existing incident."""
incident_id, user_id, repo = await self._setup_incident(db_conn)
with pytest.raises(asyncpg.ForeignKeyViolationError):
await repo.add_event(uuid4(), uuid4(), "test", user_id, None)
async def test_event_requires_valid_user_foreign_key(self, db_conn: asyncpg.Connection) -> None:
"""incident_events.actor_user_id must reference existing user if not null."""
incident_id, _, repo = await self._setup_incident(db_conn)
with pytest.raises(asyncpg.ForeignKeyViolationError):
await repo.add_event(uuid4(), incident_id, "test", uuid4(), None)