390 lines
17 KiB
Python
390 lines
17 KiB
Python
|
|
"""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)
|