363 lines
16 KiB
Python
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())
|