"""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())