FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring, source collection, LLM-based change analysis, enrichment, and account security (Turnstile, escalating lockout, email verification).
201 lines
6.4 KiB
Python
201 lines
6.4 KiB
Python
"""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
|