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,256 @@
|
||||
"""Notification provider unit tests: console (always succeeds), SMTP (stdlib
|
||||
smtplib mocked, never touches a real socket), Twilio (respx-mocked REST
|
||||
calls), the factory's type routing, and the message builder's per-channel
|
||||
formatting."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.models.alert import Alert
|
||||
from app.models.company import Company
|
||||
from app.models.enums import NotificationType, SeverityLevel
|
||||
from app.notifications.base import NotificationMessage
|
||||
from app.notifications.console import ConsoleProvider
|
||||
from app.notifications.factory import get_notification_provider
|
||||
from app.notifications.message_builder import build_alert_message
|
||||
from app.notifications.smtp_email import SmtpEmailProvider
|
||||
from app.notifications.telnyx_sms import TelnyxSmsProvider
|
||||
from app.notifications.twilio_sms import TwilioSmsProvider
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_console_provider_always_succeeds():
|
||||
result = await ConsoleProvider().send(
|
||||
NotificationMessage(destination_value="dev@local", subject="Test", body_text="hi")
|
||||
)
|
||||
assert result.success is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_smtp_provider_sends_via_smtplib(monkeypatch):
|
||||
sent = {}
|
||||
|
||||
class FakeSmtp:
|
||||
def __init__(self, host, port, timeout=10):
|
||||
sent["host"] = host
|
||||
sent["port"] = port
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def starttls(self):
|
||||
sent["starttls"] = True
|
||||
|
||||
def login(self, username, password):
|
||||
sent["login"] = (username, password)
|
||||
|
||||
def sendmail(self, from_addr, to_addrs, message):
|
||||
sent["from_addr"] = from_addr
|
||||
sent["to_addrs"] = to_addrs
|
||||
sent["message"] = message
|
||||
|
||||
monkeypatch.setattr("app.notifications.smtp_email.smtplib.SMTP", FakeSmtp)
|
||||
|
||||
settings = Settings(
|
||||
smtp_host="mailpit", smtp_port=1025, smtp_from_email="[email protected]"
|
||||
)
|
||||
provider = SmtpEmailProvider(settings)
|
||||
result = await provider.send(
|
||||
NotificationMessage(
|
||||
destination_value="[email protected]",
|
||||
subject="Alert: Pricing change",
|
||||
body_text="Plain text body",
|
||||
body_html="<p>HTML body</p>",
|
||||
)
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert sent["host"] == "mailpit"
|
||||
assert sent["to_addrs"] == ["[email protected]"]
|
||||
assert "starttls" not in sent # smtp_use_tls defaults False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_smtp_provider_reports_failure_without_raising(monkeypatch):
|
||||
class FailingSmtp:
|
||||
def __init__(self, host, port, timeout=10):
|
||||
raise ConnectionRefusedError("no mailpit running")
|
||||
|
||||
monkeypatch.setattr("app.notifications.smtp_email.smtplib.SMTP", FailingSmtp)
|
||||
|
||||
provider = SmtpEmailProvider(Settings())
|
||||
result = await provider.send(
|
||||
NotificationMessage(destination_value="[email protected]", subject="s", body_text="b")
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert "no mailpit running" in result.error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_twilio_provider_not_configured_fails_cleanly():
|
||||
settings = Settings(twilio_account_sid="", twilio_auth_token="", twilio_from_number="")
|
||||
result = await TwilioSmsProvider(settings).send(
|
||||
NotificationMessage(destination_value="+15551234567", subject="s", body_text="b")
|
||||
)
|
||||
assert result.success is False
|
||||
assert "not configured" in result.error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_twilio_provider_sends_via_rest_api():
|
||||
settings = Settings(
|
||||
twilio_account_sid="ACxxxx", twilio_auth_token="secret", twilio_from_number="+15559990000"
|
||||
)
|
||||
with respx.mock:
|
||||
respx.post("https://api.twilio.com/2010-04-01/Accounts/ACxxxx/Messages.json").mock(
|
||||
return_value=httpx.Response(201, json={"sid": "SMxxxxx"})
|
||||
)
|
||||
result = await TwilioSmsProvider(settings).send(
|
||||
NotificationMessage(
|
||||
destination_value="+15551234567", subject="s", body_text="Alert text"
|
||||
)
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.external_message_id == "SMxxxxx"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_twilio_provider_surfaces_api_error():
|
||||
settings = Settings(
|
||||
twilio_account_sid="ACxxxx", twilio_auth_token="secret", twilio_from_number="+15559990000"
|
||||
)
|
||||
with respx.mock:
|
||||
respx.post("https://api.twilio.com/2010-04-01/Accounts/ACxxxx/Messages.json").mock(
|
||||
return_value=httpx.Response(400, text="Invalid 'To' number")
|
||||
)
|
||||
result = await TwilioSmsProvider(settings).send(
|
||||
NotificationMessage(destination_value="not-a-number", subject="s", body_text="b")
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert "400" in result.error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telnyx_provider_not_configured_fails_cleanly():
|
||||
settings = Settings(telnyx_api_key="", telnyx_from_number="")
|
||||
result = await TelnyxSmsProvider(settings).send(
|
||||
NotificationMessage(destination_value="+15551234567", subject="s", body_text="b")
|
||||
)
|
||||
assert result.success is False
|
||||
assert "not configured" in result.error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telnyx_provider_sends_via_rest_api():
|
||||
settings = Settings(telnyx_api_key="KEYxxxx", telnyx_from_number="+15559990000")
|
||||
with respx.mock:
|
||||
respx.post("https://api.telnyx.com/v2/messages").mock(
|
||||
return_value=httpx.Response(200, json={"data": {"id": "msg-abc123"}})
|
||||
)
|
||||
result = await TelnyxSmsProvider(settings).send(
|
||||
NotificationMessage(
|
||||
destination_value="+15551234567", subject="s", body_text="Alert text"
|
||||
)
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.external_message_id == "msg-abc123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telnyx_provider_surfaces_api_error():
|
||||
settings = Settings(telnyx_api_key="KEYxxxx", telnyx_from_number="+15559990000")
|
||||
with respx.mock:
|
||||
respx.post("https://api.telnyx.com/v2/messages").mock(
|
||||
return_value=httpx.Response(
|
||||
403,
|
||||
json={
|
||||
"errors": [
|
||||
{
|
||||
"code": "40300",
|
||||
"title": "Forbidden",
|
||||
"detail": "The from number is not assigned to a messaging profile.",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
)
|
||||
result = await TelnyxSmsProvider(settings).send(
|
||||
NotificationMessage(destination_value="+15551234567", subject="s", body_text="b")
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert "403" in result.error
|
||||
assert "messaging profile" in result.error
|
||||
|
||||
|
||||
def test_factory_routes_by_notification_type():
|
||||
settings = Settings()
|
||||
assert get_notification_provider(NotificationType.EMAIL, settings).provider_name == "smtp"
|
||||
assert get_notification_provider(NotificationType.SMS, settings).provider_name == "twilio_sms"
|
||||
assert get_notification_provider(NotificationType.CONSOLE, settings).provider_name == "console"
|
||||
|
||||
|
||||
def test_factory_routes_sms_by_sms_provider_setting():
|
||||
twilio_settings = Settings(sms_provider="twilio")
|
||||
assert (
|
||||
get_notification_provider(NotificationType.SMS, twilio_settings).provider_name
|
||||
== "twilio_sms"
|
||||
)
|
||||
|
||||
telnyx_settings = Settings(sms_provider="telnyx")
|
||||
assert (
|
||||
get_notification_provider(NotificationType.SMS, telnyx_settings).provider_name
|
||||
== "telnyx_sms"
|
||||
)
|
||||
|
||||
|
||||
def _sample_alert() -> Alert:
|
||||
return Alert(
|
||||
id=uuid.uuid4(),
|
||||
company_id=uuid.uuid4(),
|
||||
detected_change_id=uuid.uuid4(),
|
||||
user_id=uuid.uuid4(),
|
||||
title="New leadership hire announced",
|
||||
summary="A new VP of Engineering was announced on the careers page.",
|
||||
why_it_matters="Signals a scaling push in engineering.",
|
||||
severity=SeverityLevel.HIGH,
|
||||
confidence=0.82,
|
||||
)
|
||||
|
||||
|
||||
def test_message_builder_sms_is_short_and_truncated():
|
||||
company = Company(id=uuid.uuid4(), user_id=uuid.uuid4(), name="Acme Mobility", slug="acme")
|
||||
message = build_alert_message(
|
||||
NotificationType.SMS, "+15551234567", company, _sample_alert(), Settings()
|
||||
)
|
||||
assert len(message.body_text) <= 480
|
||||
assert "HIGH" in message.body_text
|
||||
assert message.body_html is None
|
||||
|
||||
|
||||
def test_message_builder_email_includes_context_and_links():
|
||||
company = Company(id=uuid.uuid4(), user_id=uuid.uuid4(), name="Acme Mobility", slug="acme")
|
||||
alert = _sample_alert()
|
||||
message = build_alert_message(
|
||||
NotificationType.EMAIL, "[email protected]", company, alert, Settings()
|
||||
)
|
||||
assert "Acme Mobility" in message.subject
|
||||
assert alert.summary in message.body_text
|
||||
assert alert.why_it_matters in message.body_text
|
||||
assert "/alerts" in message.body_text
|
||||
assert "/settings" in message.body_text
|
||||
assert message.body_html is not None
|
||||
assert alert.summary in message.body_html
|
||||
Reference in New Issue
Block a user