"""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="gets-everything@example.com", minimum_severity=SeverityLevel.LOW, enabled=True, ) high_bar = NotificationDestination( user_id=company.user_id, type=NotificationType.EMAIL, destination_value="critical-only@example.com", minimum_severity=SeverityLevel.CRITICAL, enabled=True, ) disabled = NotificationDestination( user_id=company.user_id, type=NotificationType.EMAIL, destination_value="disabled@example.com", 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