Files
incidentops/tests/repositories/test_notification.py

363 lines
16 KiB
Python

"""Tests for NotificationRepository."""
from datetime import UTC, datetime
from uuid import uuid4
import asyncpg
import pytest
from app.repositories.incident import IncidentRepository
from app.repositories.notification import NotificationRepository
from app.repositories.org import OrgRepository
from app.repositories.service import ServiceRepository
class TestNotificationTargetRepository:
"""Tests for notification targets per SPECS.md notification_targets table."""
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 test_create_target_returns_target_data(self, db_conn: asyncpg.Connection) -> None:
"""Creating a notification target returns the target data."""
org_id = await self._create_org(db_conn, "target-org")
repo = NotificationRepository(db_conn)
target_id = uuid4()
result = await repo.create_target(
target_id, org_id, "Slack Alerts",
target_type="webhook",
webhook_url="https://hooks.slack.com/services/xxx",
enabled=True
)
assert result["id"] == target_id
assert result["org_id"] == org_id
assert result["name"] == "Slack Alerts"
assert result["target_type"] == "webhook"
assert result["webhook_url"] == "https://hooks.slack.com/services/xxx"
assert result["enabled"] is True
assert result["created_at"] is not None
async def test_create_target_type_must_be_valid(self, db_conn: asyncpg.Connection) -> None:
"""Target type must be webhook, email, or slack per SPECS.md."""
org_id = await self._create_org(db_conn, "type-org")
repo = NotificationRepository(db_conn)
# Valid types
for target_type in ["webhook", "email", "slack"]:
result = await repo.create_target(
uuid4(), org_id, f"{target_type} target",
target_type=target_type
)
assert result["target_type"] == target_type
# Invalid type
with pytest.raises(asyncpg.CheckViolationError):
await repo.create_target(
uuid4(), org_id, "Invalid",
target_type="sms"
)
async def test_create_target_webhook_url_optional(self, db_conn: asyncpg.Connection) -> None:
"""webhook_url is optional (for email/slack types)."""
org_id = await self._create_org(db_conn, "optional-url-org")
repo = NotificationRepository(db_conn)
result = await repo.create_target(
uuid4(), org_id, "Email Alerts",
target_type="email",
webhook_url=None
)
assert result["webhook_url"] is None
async def test_create_target_enabled_defaults_to_true(self, db_conn: asyncpg.Connection) -> None:
"""enabled defaults to True."""
org_id = await self._create_org(db_conn, "default-enabled-org")
repo = NotificationRepository(db_conn)
result = await repo.create_target(
uuid4(), org_id, "Default Enabled",
target_type="webhook"
)
assert result["enabled"] is True
async def test_get_target_by_id_returns_target(self, db_conn: asyncpg.Connection) -> None:
"""get_target_by_id returns the correct target."""
org_id = await self._create_org(db_conn, "getbyid-target-org")
repo = NotificationRepository(db_conn)
target_id = uuid4()
await repo.create_target(target_id, org_id, "My Target", "webhook")
result = await repo.get_target_by_id(target_id)
assert result is not None
assert result["id"] == target_id
assert result["name"] == "My Target"
async def test_get_target_by_id_returns_none_for_nonexistent(self, db_conn: asyncpg.Connection) -> None:
"""get_target_by_id returns None for non-existent target."""
repo = NotificationRepository(db_conn)
result = await repo.get_target_by_id(uuid4())
assert result is None
async def test_get_targets_by_org_returns_all_targets(self, db_conn: asyncpg.Connection) -> None:
"""get_targets_by_org returns all targets for an organization."""
org_id = await self._create_org(db_conn, "multi-target-org")
repo = NotificationRepository(db_conn)
await repo.create_target(uuid4(), org_id, "Target A", "webhook")
await repo.create_target(uuid4(), org_id, "Target B", "email")
await repo.create_target(uuid4(), org_id, "Target C", "slack")
result = await repo.get_targets_by_org(org_id)
assert len(result) == 3
names = {t["name"] for t in result}
assert names == {"Target A", "Target B", "Target C"}
async def test_get_targets_by_org_filters_enabled(self, db_conn: asyncpg.Connection) -> None:
"""get_targets_by_org can filter to only enabled targets."""
org_id = await self._create_org(db_conn, "enabled-filter-org")
repo = NotificationRepository(db_conn)
await repo.create_target(uuid4(), org_id, "Enabled", "webhook", enabled=True)
await repo.create_target(uuid4(), org_id, "Disabled", "webhook", enabled=False)
result = await repo.get_targets_by_org(org_id, enabled_only=True)
assert len(result) == 1
assert result[0]["name"] == "Enabled"
async def test_get_targets_by_org_tenant_isolation(self, db_conn: asyncpg.Connection) -> None:
"""get_targets_by_org only returns targets for the specified org."""
org1 = await self._create_org(db_conn, "isolated-target-org-1")
org2 = await self._create_org(db_conn, "isolated-target-org-2")
repo = NotificationRepository(db_conn)
await repo.create_target(uuid4(), org1, "Org1 Target", "webhook")
await repo.create_target(uuid4(), org2, "Org2 Target", "webhook")
result = await repo.get_targets_by_org(org1)
assert len(result) == 1
assert result[0]["name"] == "Org1 Target"
async def test_update_target_updates_fields(self, db_conn: asyncpg.Connection) -> None:
"""update_target updates the specified fields."""
org_id = await self._create_org(db_conn, "update-target-org")
repo = NotificationRepository(db_conn)
target_id = uuid4()
await repo.create_target(target_id, org_id, "Original", "webhook", enabled=True)
result = await repo.update_target(target_id, name="Updated", enabled=False)
assert result is not None
assert result["name"] == "Updated"
assert result["enabled"] is False
async def test_update_target_partial_update(self, db_conn: asyncpg.Connection) -> None:
"""update_target only updates provided fields."""
org_id = await self._create_org(db_conn, "partial-update-org")
repo = NotificationRepository(db_conn)
target_id = uuid4()
await repo.create_target(
target_id, org_id, "Original Name", "webhook",
webhook_url="https://original.com", enabled=True
)
result = await repo.update_target(target_id, name="New Name")
assert result["name"] == "New Name"
assert result["webhook_url"] == "https://original.com"
assert result["enabled"] is True
async def test_delete_target_removes_target(self, db_conn: asyncpg.Connection) -> None:
"""delete_target removes the target."""
org_id = await self._create_org(db_conn, "delete-target-org")
repo = NotificationRepository(db_conn)
target_id = uuid4()
await repo.create_target(target_id, org_id, "To Delete", "webhook")
result = await repo.delete_target(target_id)
assert result is True
assert await repo.get_target_by_id(target_id) is None
async def test_delete_target_returns_false_for_nonexistent(self, db_conn: asyncpg.Connection) -> None:
"""delete_target returns False for non-existent target."""
repo = NotificationRepository(db_conn)
result = await repo.delete_target(uuid4())
assert result is False
async def test_target_requires_valid_org_foreign_key(self, db_conn: asyncpg.Connection) -> None:
"""notification_targets.org_id must reference existing org."""
repo = NotificationRepository(db_conn)
with pytest.raises(asyncpg.ForeignKeyViolationError):
await repo.create_target(uuid4(), uuid4(), "Orphan Target", "webhook")
class TestNotificationAttemptRepository:
"""Tests for notification attempts per SPECS.md notification_attempts table."""
async def _setup_incident_and_target(self, conn: asyncpg.Connection) -> tuple[uuid4, uuid4, NotificationRepository]:
"""Helper to create org, service, incident, and notification target."""
org_repo = OrgRepository(conn)
service_repo = ServiceRepository(conn)
incident_repo = IncidentRepository(conn)
notification_repo = NotificationRepository(conn)
org_id = uuid4()
service_id = uuid4()
incident_id = uuid4()
target_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 incident_repo.create(incident_id, org_id, service_id, "Test Incident", None, "medium")
await notification_repo.create_target(target_id, org_id, "Test Target", "webhook")
return incident_id, target_id, notification_repo
async def test_create_attempt_returns_attempt_data(self, db_conn: asyncpg.Connection) -> None:
"""Creating a notification attempt returns the attempt data."""
incident_id, target_id, repo = await self._setup_incident_and_target(db_conn)
attempt_id = uuid4()
result = await repo.create_attempt(attempt_id, incident_id, target_id)
assert result["id"] == attempt_id
assert result["incident_id"] == incident_id
assert result["target_id"] == target_id
assert result["status"] == "pending"
assert result["error"] is None
assert result["sent_at"] is None
assert result["created_at"] is not None
async def test_create_attempt_idempotent(self, db_conn: asyncpg.Connection) -> None:
"""create_attempt is idempotent per SPECS.md (unique constraint on incident+target)."""
incident_id, target_id, repo = await self._setup_incident_and_target(db_conn)
# First attempt
result1 = await repo.create_attempt(uuid4(), incident_id, target_id)
# Second attempt with same incident+target
result2 = await repo.create_attempt(uuid4(), incident_id, target_id)
# Should return the same attempt
assert result1["id"] == result2["id"]
async def test_get_attempt_returns_attempt(self, db_conn: asyncpg.Connection) -> None:
"""get_attempt returns the attempt for incident and target."""
incident_id, target_id, repo = await self._setup_incident_and_target(db_conn)
await repo.create_attempt(uuid4(), incident_id, target_id)
result = await repo.get_attempt(incident_id, target_id)
assert result is not None
assert result["incident_id"] == incident_id
assert result["target_id"] == target_id
async def test_get_attempt_returns_none_for_nonexistent(self, db_conn: asyncpg.Connection) -> None:
"""get_attempt returns None for non-existent attempt."""
incident_id, target_id, repo = await self._setup_incident_and_target(db_conn)
result = await repo.get_attempt(incident_id, target_id)
assert result is None
async def test_update_attempt_success_sets_sent_status(self, db_conn: asyncpg.Connection) -> None:
"""update_attempt_success marks attempt as sent."""
incident_id, target_id, repo = await self._setup_incident_and_target(db_conn)
attempt = await repo.create_attempt(uuid4(), incident_id, target_id)
sent_at = datetime.now(UTC)
result = await repo.update_attempt_success(attempt["id"], sent_at)
assert result is not None
assert result["status"] == "sent"
assert result["sent_at"] is not None
assert result["error"] is None
async def test_update_attempt_failure_sets_failed_status(self, db_conn: asyncpg.Connection) -> None:
"""update_attempt_failure marks attempt as failed with error."""
incident_id, target_id, repo = await self._setup_incident_and_target(db_conn)
attempt = await repo.create_attempt(uuid4(), incident_id, target_id)
result = await repo.update_attempt_failure(attempt["id"], "Connection timeout")
assert result is not None
assert result["status"] == "failed"
assert result["error"] == "Connection timeout"
async def test_attempt_status_must_be_valid(self, db_conn: asyncpg.Connection) -> None:
"""Attempt status must be pending, sent, or failed per SPECS.md."""
incident_id, target_id, repo = await self._setup_incident_and_target(db_conn)
# Create with default 'pending' status - valid
result = await repo.create_attempt(uuid4(), incident_id, target_id)
assert result["status"] == "pending"
# Transition to 'sent' - valid
result = await repo.update_attempt_success(result["id"], datetime.now(UTC))
assert result["status"] == "sent"
async def test_get_pending_attempts_returns_pending_with_target_info(self, db_conn: asyncpg.Connection) -> None:
"""get_pending_attempts returns pending attempts with target details."""
incident_id, target_id, repo = await self._setup_incident_and_target(db_conn)
await repo.create_attempt(uuid4(), incident_id, target_id)
result = await repo.get_pending_attempts(incident_id)
assert len(result) == 1
assert result[0]["status"] == "pending"
assert result[0]["target_id"] == target_id
assert "target_type" in result[0]
assert "target_name" in result[0]
async def test_get_pending_attempts_excludes_sent(self, db_conn: asyncpg.Connection) -> None:
"""get_pending_attempts excludes sent attempts."""
incident_id, target_id, repo = await self._setup_incident_and_target(db_conn)
attempt = await repo.create_attempt(uuid4(), incident_id, target_id)
await repo.update_attempt_success(attempt["id"], datetime.now(UTC))
result = await repo.get_pending_attempts(incident_id)
assert len(result) == 0
async def test_get_pending_attempts_excludes_failed(self, db_conn: asyncpg.Connection) -> None:
"""get_pending_attempts excludes failed attempts."""
incident_id, target_id, repo = await self._setup_incident_and_target(db_conn)
attempt = await repo.create_attempt(uuid4(), incident_id, target_id)
await repo.update_attempt_failure(attempt["id"], "Error")
result = await repo.get_pending_attempts(incident_id)
assert len(result) == 0
async def test_attempt_requires_valid_incident_foreign_key(self, db_conn: asyncpg.Connection) -> None:
"""notification_attempts.incident_id must reference existing incident."""
_, target_id, repo = await self._setup_incident_and_target(db_conn)
with pytest.raises(asyncpg.ForeignKeyViolationError):
await repo.create_attempt(uuid4(), uuid4(), target_id)
async def test_attempt_requires_valid_target_foreign_key(self, db_conn: asyncpg.Connection) -> None:
"""notification_attempts.target_id must reference existing target."""
incident_id, _, repo = await self._setup_incident_and_target(db_conn)
with pytest.raises(asyncpg.ForeignKeyViolationError):
await repo.create_attempt(uuid4(), incident_id, uuid4())