Files
CIAgent/apps/api/tests/integration/test_alert_service.py
T
saksham 1a4c80958f Initial commit: CI Agent competitive-intelligence monitoring app
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).
2026-08-05 10:48:20 -04:00

327 lines
11 KiB
Python

"""Alert creation end-to-end against a real (SQLite) DB: a DetectedChange
above the company's threshold becomes an Alert (via the mock LLM's Task F),
gets dispatched to every enabled destination that also meets its own
threshold, and a NotificationDelivery is recorded per attempt. Two
independent thresholds by design - see alert_service.py docstring."""
from __future__ import annotations
import uuid
import pytest
from sqlalchemy import select
from app.analysis.llm.mock import MockLLMProvider
from app.core.config import Settings
from app.models.company import Company
from app.models.detected_change import DetectedChange
from app.models.enums import (
ChangeType,
MonitoringRunTrigger,
NotificationDeliveryStatus,
NotificationType,
SeverityLevel,
SourceType,
)
from app.models.monitor_configuration import MonitorConfiguration
from app.models.monitoring_run import MonitoringRun
from app.models.notification_delivery import NotificationDelivery
from app.models.notification_destination import (
NotificationDestination,
NotificationDestinationCompany,
)
from app.models.snapshot import Snapshot
from app.models.source import Source
from app.repositories.company_repository import CompanyRepository
from app.services.alert_service import create_alert_for_change, send_test_notification
async def _make_company(
db_session, *, severity_threshold: SeverityLevel = SeverityLevel.MEDIUM
) -> Company:
company = Company(
id=uuid.uuid4(),
user_id=uuid.uuid4(),
name="Acme Mobility Systems",
slug=f"acme-mobility-{uuid.uuid4().hex[:6]}",
)
db_session.add(company)
await db_session.flush()
db_session.add(
MonitorConfiguration(company_id=company.id, severity_threshold=severity_threshold)
)
await db_session.commit()
return await CompanyRepository(db_session).get_for_user(company.id, company.user_id)
async def _make_change(db_session, company: Company, *, severity: SeverityLevel) -> DetectedChange:
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={
"text_added_lines": ["We're hiring a new VP of Engineering."],
"structured_added": ["/careers/vp-engineering"],
},
significance_score=0.7,
confidence_score=0.8,
severity=severity,
summary="New leadership hire posting detected",
)
db_session.add(change)
await db_session.commit()
await db_session.refresh(change)
return change
@pytest.mark.asyncio
async def test_change_below_company_threshold_creates_no_alert(db_session, settings: Settings):
company = await _make_company(db_session, severity_threshold=SeverityLevel.HIGH)
change = await _make_change(db_session, company, severity=SeverityLevel.LOW)
alert = await create_alert_for_change(db_session, settings, MockLLMProvider(), change, company)
assert alert is None
@pytest.mark.asyncio
async def test_change_at_threshold_creates_alert_with_llm_summary(db_session, settings: Settings):
company = await _make_company(db_session, severity_threshold=SeverityLevel.MEDIUM)
change = await _make_change(db_session, company, severity=SeverityLevel.HIGH)
alert = await create_alert_for_change(db_session, settings, MockLLMProvider(), change, company)
assert alert is not None
assert alert.company_id == company.id
assert alert.detected_change_id == change.id
assert alert.severity == SeverityLevel.HIGH
assert alert.confidence == change.confidence_score
assert alert.title
assert alert.summary
assert alert.why_it_matters
assert alert.read is False
assert alert.resolved is False
@pytest.mark.asyncio
async def test_alert_dispatches_only_to_destinations_meeting_their_own_threshold(
db_session, settings: Settings, monkeypatch
):
# EMAIL delivery goes through the real SmtpEmailProvider - mock the
# socket-level smtplib call rather than depending on a live SMTP
# relay (e.g. Mailpit) actually being reachable in the test environment.
class FakeSmtp:
def __init__(self, host, port, timeout=10):
pass
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def starttls(self):
pass
def login(self, username, password):
pass
def sendmail(self, from_addr, to_addrs, message):
pass
monkeypatch.setattr("app.notifications.smtp_email.smtplib.SMTP", FakeSmtp)
company = await _make_company(db_session, severity_threshold=SeverityLevel.LOW)
change = await _make_change(db_session, company, severity=SeverityLevel.MEDIUM)
low_bar = NotificationDestination(
user_id=company.user_id,
type=NotificationType.EMAIL,
destination_value="[email protected]",
minimum_severity=SeverityLevel.LOW,
enabled=True,
)
high_bar = NotificationDestination(
user_id=company.user_id,
type=NotificationType.EMAIL,
destination_value="[email protected]",
minimum_severity=SeverityLevel.CRITICAL,
enabled=True,
)
disabled = NotificationDestination(
user_id=company.user_id,
type=NotificationType.EMAIL,
destination_value="[email protected]",
minimum_severity=SeverityLevel.LOW,
enabled=False,
)
db_session.add_all([low_bar, high_bar, disabled])
await db_session.commit()
db_session.add_all(
NotificationDestinationCompany(destination_id=d.id, company_id=company.id)
for d in (low_bar, high_bar, disabled)
)
await db_session.commit()
alert = await create_alert_for_change(db_session, settings, MockLLMProvider(), change, company)
assert alert is not None
deliveries = (
(
await db_session.execute(
select(NotificationDelivery).where(NotificationDelivery.alert_id == alert.id)
)
)
.scalars()
.all()
)
assert len(deliveries) == 1
assert deliveries[0].destination_id == low_bar.id
assert deliveries[0].status == NotificationDeliveryStatus.SENT
assert deliveries[0].provider == "smtp"
@pytest.mark.asyncio
async def test_sms_destination_skipped_when_sms_disabled(db_session, settings: Settings):
company = await _make_company(db_session, severity_threshold=SeverityLevel.LOW)
change = await _make_change(db_session, company, severity=SeverityLevel.MEDIUM)
sms_destination = NotificationDestination(
user_id=company.user_id,
type=NotificationType.SMS,
destination_value="+15551234567",
minimum_severity=SeverityLevel.LOW,
enabled=True,
)
db_session.add(sms_destination)
await db_session.commit()
disabled_sms_settings = settings.model_copy(update={"notification_sms_enabled": False})
alert = await create_alert_for_change(
db_session, disabled_sms_settings, MockLLMProvider(), change, company
)
assert alert is not None
deliveries = (
(
await db_session.execute(
select(NotificationDelivery).where(NotificationDelivery.alert_id == alert.id)
)
)
.scalars()
.all()
)
assert deliveries == []
@pytest.mark.asyncio
async def test_failed_delivery_is_recorded_with_error(db_session, settings: Settings):
company = await _make_company(db_session, severity_threshold=SeverityLevel.LOW)
change = await _make_change(db_session, company, severity=SeverityLevel.MEDIUM)
unconfigured_sms_settings = settings.model_copy(
update={
"notification_sms_enabled": True,
"twilio_account_sid": "",
"twilio_auth_token": "",
"twilio_from_number": "",
}
)
sms_destination = NotificationDestination(
user_id=company.user_id,
type=NotificationType.SMS,
destination_value="+15551234567",
minimum_severity=SeverityLevel.LOW,
enabled=True,
)
db_session.add(sms_destination)
await db_session.commit()
db_session.add(
NotificationDestinationCompany(destination_id=sms_destination.id, company_id=company.id)
)
await db_session.commit()
alert = await create_alert_for_change(
db_session, unconfigured_sms_settings, MockLLMProvider(), change, company
)
assert alert is not None
deliveries = (
(
await db_session.execute(
select(NotificationDelivery).where(NotificationDelivery.alert_id == alert.id)
)
)
.scalars()
.all()
)
assert len(deliveries) == 1
assert deliveries[0].status == NotificationDeliveryStatus.FAILED
assert "not configured" in deliveries[0].error_message
@pytest.mark.asyncio
async def test_send_test_notification_short_circuits_sms_when_disabled(
db_session, settings: Settings
):
"""The "Send test notification" button must respect NOTIFICATION_SMS_ENABLED
the same way real alert dispatch does - it must never place a real API
call to an SMS vendor while SMS delivery is switched off, even though a
Twilio/Telnyx-configured provider would otherwise happily send one."""
user_id = uuid.uuid4()
sms_destination = NotificationDestination(
user_id=user_id,
type=NotificationType.SMS,
destination_value="+15551234567",
minimum_severity=SeverityLevel.LOW,
enabled=True,
)
db_session.add(sms_destination)
await db_session.commit()
await db_session.refresh(sms_destination)
disabled_sms_settings = settings.model_copy(
update={
"notification_sms_enabled": False,
"sms_provider": "telnyx",
"telnyx_api_key": "would-be-a-real-key",
"telnyx_from_number": "+15559990000",
}
)
result = await send_test_notification(
db_session, disabled_sms_settings, user_id, sms_destination.id
)
assert result.success is False
assert "disabled" in result.error