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).
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user