"""Alerts API: ownership isolation, filters, and read/resolve mutations. Alerts are only ever created internally by alert_service (never via a user POST), so tests seed rows directly through db_session against the same user_id the HTTP client is authenticated as (looked up via GET /auth/me).""" from __future__ import annotations import uuid import pytest from app.models.alert import Alert from app.models.company import Company from app.models.detected_change import DetectedChange from app.models.enums import ChangeType, MonitoringRunTrigger, SeverityLevel, SourceType from app.models.monitoring_run import MonitoringRun from app.models.snapshot import Snapshot from app.models.source import Source def _register_and_login(client) -> tuple[dict[str, str], uuid.UUID]: email = f"user-{uuid.uuid4().hex[:12]}@example.com" client.post( "/api/v1/auth/register", json={"email": email, "password": "correct-horse-1", "display_name": "Test User"}, ) tokens = client.post( "/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"} ).json() headers = {"Authorization": f"Bearer {tokens['access_token']}"} user_id = uuid.UUID(client.get("/api/v1/auth/me", headers=headers).json()["id"]) return headers, user_id async def _make_alert( db_session, user_id: uuid.UUID, *, severity: SeverityLevel = SeverityLevel.HIGH, read: bool = False, resolved: bool = False, ) -> Alert: company = Company( id=uuid.uuid4(), user_id=user_id, name="Acme Mobility", slug=f"acme-{uuid.uuid4().hex[:6]}" ) db_session.add(company) await db_session.flush() source = Source( company_id=company.id, source_type=SourceType.WEBSITE, name="Website", base_url="https://acme.example", ) db_session.add(source) await db_session.flush() run = MonitoringRun(company_id=company.id, trigger_type=MonitoringRunTrigger.MANUAL) db_session.add(run) await db_session.flush() snapshot = Snapshot( company_id=company.id, source_id=source.id, snapshot_type=source.source_type.value, hash="hash1", structured_summary={}, text_summary="", monitoring_run_id=run.id, ) db_session.add(snapshot) await db_session.flush() change = DetectedChange( company_id=company.id, source_id=source.id, monitoring_run_id=run.id, current_snapshot_id=snapshot.id, change_type=ChangeType.NEW_DOCUMENT, raw_diff={}, significance_score=0.6, confidence_score=0.75, severity=severity, summary="Change detected", ) db_session.add(change) await db_session.flush() alert = Alert( company_id=company.id, detected_change_id=change.id, user_id=user_id, title="New hire announced", summary="A new VP of Engineering was announced.", why_it_matters="Signals a scaling push.", severity=severity, confidence=0.75, read=read, resolved=resolved, ) db_session.add(alert) await db_session.commit() await db_session.refresh(alert) return alert @pytest.mark.asyncio async def test_list_alerts_scoped_to_owner(client, db_session): owner_headers, owner_id = _register_and_login(client) _other_headers, other_id = _register_and_login(client) await _make_alert(db_session, owner_id) await _make_alert(db_session, other_id) resp = client.get("/api/v1/alerts", headers=owner_headers) assert resp.status_code == 200 body = resp.json() assert len(body) == 1 @pytest.mark.asyncio async def test_list_alerts_filters_by_severity(client, db_session): headers, user_id = _register_and_login(client) await _make_alert(db_session, user_id, severity=SeverityLevel.CRITICAL) await _make_alert(db_session, user_id, severity=SeverityLevel.LOW) resp = client.get("/api/v1/alerts", headers=headers, params={"severity": "critical"}) assert resp.status_code == 200 body = resp.json() assert len(body) == 1 assert body[0]["severity"] == "critical" @pytest.mark.asyncio async def test_list_alerts_filters_by_read_and_resolved(client, db_session): headers, user_id = _register_and_login(client) await _make_alert(db_session, user_id, read=True, resolved=False) await _make_alert(db_session, user_id, read=False, resolved=False) resp = client.get("/api/v1/alerts", headers=headers, params={"read": "false"}) assert resp.status_code == 200 body = resp.json() assert len(body) == 1 assert body[0]["read"] is False @pytest.mark.asyncio async def test_get_alert_detail_includes_deliveries(client, db_session): headers, user_id = _register_and_login(client) alert = await _make_alert(db_session, user_id) resp = client.get(f"/api/v1/alerts/{alert.id}", headers=headers) assert resp.status_code == 200 body = resp.json() assert body["id"] == str(alert.id) assert body["deliveries"] == [] @pytest.mark.asyncio async def test_get_alert_not_owned_returns_404(client, db_session): _owner_headers, owner_id = _register_and_login(client) other_headers, _other_id = _register_and_login(client) alert = await _make_alert(db_session, owner_id) resp = client.get(f"/api/v1/alerts/{alert.id}", headers=other_headers) assert resp.status_code == 404 @pytest.mark.asyncio async def test_mark_alert_read(client, db_session): headers, user_id = _register_and_login(client) alert = await _make_alert(db_session, user_id, read=False) resp = client.post(f"/api/v1/alerts/{alert.id}/read", headers=headers) assert resp.status_code == 200 assert resp.json()["read"] is True @pytest.mark.asyncio async def test_resolve_alert(client, db_session): headers, user_id = _register_and_login(client) alert = await _make_alert(db_session, user_id, resolved=False) resp = client.post(f"/api/v1/alerts/{alert.id}/resolve", headers=headers) assert resp.status_code == 200 assert resp.json()["resolved"] is True @pytest.mark.asyncio async def test_patch_alert_updates_both_flags(client, db_session): headers, user_id = _register_and_login(client) alert = await _make_alert(db_session, user_id) resp = client.patch( f"/api/v1/alerts/{alert.id}", json={"read": True, "resolved": True}, headers=headers ) assert resp.status_code == 200 body = resp.json() assert body["read"] is True assert body["resolved"] is True