claimflow-api / tests /test_notifications.py
Minifigures's picture
feat: ClaimFlow API demo backend
ceea9e1 verified
Raw
History Blame Contribute Delete
8.6 kB
import json
import logging
import smtplib
import pytest
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.claimguard import audit
from app.config import Settings
from app.models import (
AuditEvent,
Claim,
ClaimState,
Notification,
NotificationStatus,
Role,
User,
)
from app.services.notifications.base import SendResult, get_provider
from app.services.notifications.console_provider import ConsoleProvider
from app.services.notifications.service import render_claim_returned, send_claim_email
from app.services.notifications.smtp_provider import SmtpProvider
CLAIM_REF = "CLM-NOTIF-00001"
REASON = "The uploaded imaging file could not be opened."
@pytest.fixture()
def claim(session: Session, users: dict[str, User]) -> Claim:
c = Claim(
claim_ref=CLAIM_REF,
claimant_id=users[Role.CLAIMANT.value].id,
claim_type="imaging",
state=ClaimState.RETURNED_TO_CLAIMANT,
)
session.add(c)
session.commit()
return c
def smtp_settings(settings: Settings) -> Settings:
return settings.model_copy(
update={
"email_provider": "smtp",
"smtp_host": "smtp.example.test",
"smtp_user": "mailer",
"smtp_password": "secret",
}
)
class FakeSMTPSSL:
"""Stands in for smtplib.SMTP_SSL: records the login and sent messages."""
instances: list["FakeSMTPSSL"] = []
def __init__(self, host: str, port: int, timeout: float | None = None) -> None:
self.host = host
self.port = port
self.timeout = timeout
self.logged_in: tuple[str, str] | None = None
self.sent: list = []
FakeSMTPSSL.instances.append(self)
def __enter__(self) -> "FakeSMTPSSL":
return self
def __exit__(self, *exc: object) -> bool:
return False
def login(self, user: str, password: str) -> None:
self.logged_in = (user, password)
def send_message(self, message) -> None:
self.sent.append(message)
class ExplodingSMTPSSL:
def __init__(self, *args: object, **kwargs: object) -> None:
raise ConnectionRefusedError("connection refused by smtp.example.test")
# --- console path -------------------------------------------------------------------
def test_console_path_logs_notification_and_audits(
session: Session, settings: Settings, users: dict[str, User], claim: Claim
) -> None:
claimant = users[Role.CLAIMANT.value]
subject, body_text = render_claim_returned(CLAIM_REF, REASON, "Casey")
notification = send_claim_email(
session,
settings,
claim=claim,
recipient=claimant,
subject=subject,
body_text=body_text,
)
session.commit()
row = session.scalar(select(Notification).where(Notification.id == notification.id))
assert row is not None
assert row.status is NotificationStatus.LOGGED
assert row.provider == "console"
assert row.sent_at is None
assert row.claim_id == claim.id
assert row.recipient_id == claimant.id
assert row.body_text == body_text
event = session.scalar(select(AuditEvent).where(AuditEvent.event_type == "email.sent"))
assert event is not None
assert event.claim_id == claim.id
assert event.actor_role == "system"
payload = json.loads(event.payload_json)
assert payload == {
"recipient_id": claimant.id,
"subject": subject,
"provider": "console",
"status": "logged",
}
# The audit log stays PII-free: no email body (it carries the first name).
assert body_text not in event.payload_json
assert "Casey" not in event.payload_json
assert audit.verify_chain(session) == (True, 1)
def test_console_provider_logs_full_email(caplog: pytest.LogCaptureFixture) -> None:
provider = ConsoleProvider()
with caplog.at_level(logging.INFO, logger="claimflow.email"):
result = provider.send(
to="claimant@demo.ca",
subject="Action needed",
body_text="Body line one.",
body_html="<p>Body line one.</p>",
)
assert result == SendResult(ok=True, provider="console")
logged = "\n".join(record.getMessage() for record in caplog.records)
assert "claimant@demo.ca" in logged
assert "Action needed" in logged
assert "Body line one." in logged
assert "<p>Body line one.</p>" in logged
# --- smtp path ----------------------------------------------------------------------
def test_smtp_success_marks_sent(
session: Session,
settings: Settings,
users: dict[str, User],
claim: Claim,
monkeypatch: pytest.MonkeyPatch,
) -> None:
FakeSMTPSSL.instances.clear()
monkeypatch.setattr(smtplib, "SMTP_SSL", FakeSMTPSSL)
cfg = smtp_settings(settings)
claimant = users[Role.CLAIMANT.value]
notification = send_claim_email(
session,
cfg,
claim=claim,
recipient=claimant,
subject="Action needed on your claim",
body_text="Please re-upload your documents.",
body_html="<p>Please re-upload your documents.</p>",
)
session.commit()
assert notification.status is NotificationStatus.SENT
assert notification.sent_at is not None
assert notification.provider == "smtp"
[fake] = FakeSMTPSSL.instances
assert (fake.host, fake.port) == ("smtp.example.test", 465)
assert fake.timeout == 15
assert fake.logged_in == ("mailer", "secret")
[message] = fake.sent
assert message["From"] == cfg.email_from
assert message["To"] == claimant.email
assert message["Subject"] == "Action needed on your claim"
event = session.scalar(select(AuditEvent).where(AuditEvent.event_type == "email.sent"))
assert event is not None
payload = json.loads(event.payload_json)
assert payload["provider"] == "smtp"
assert payload["status"] == "sent"
assert audit.verify_chain(session) == (True, 1)
def test_smtp_failure_marks_failed_without_raising(
session: Session,
settings: Settings,
users: dict[str, User],
claim: Claim,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(smtplib, "SMTP_SSL", ExplodingSMTPSSL)
claimant = users[Role.CLAIMANT.value]
notification = send_claim_email( # must not raise
session,
smtp_settings(settings),
claim=claim,
recipient=claimant,
subject="Action needed on your claim",
body_text="Please re-upload your documents.",
)
session.commit()
assert notification.status is NotificationStatus.FAILED
assert notification.sent_at is None
assert notification.body_text == "Please re-upload your documents." # body kept intact
event = session.scalar(select(AuditEvent).where(AuditEvent.event_type == "email.sent"))
assert event is not None
payload = json.loads(event.payload_json)
assert payload["status"] == "failed"
assert payload["provider"] == "smtp"
assert audit.verify_chain(session) == (True, 1)
def test_smtp_provider_send_reports_exception_detail(
settings: Settings, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(smtplib, "SMTP_SSL", ExplodingSMTPSSL)
result = SmtpProvider(smtp_settings(settings)).send(
to="claimant@demo.ca", subject="s", body_text="b"
)
assert result.ok is False
assert result.provider == "smtp"
assert "connection refused" in result.detail
# --- rendering ----------------------------------------------------------------------
def test_render_claim_returned_renders_subject_and_body() -> None:
subject, body_text = render_claim_returned(CLAIM_REF, REASON, "Casey")
assert CLAIM_REF in subject
assert "Casey" in body_text
assert REASON in body_text
assert CLAIM_REF in body_text
assert "re-upload" in body_text.lower()
# Claimant-facing copy carries no model scores or fraud language.
for banned in ("fraud", "score", "risk", "model"):
assert banned not in body_text.lower()
assert banned not in subject.lower()
# --- provider selection -------------------------------------------------------------
def test_provider_selection_from_settings(settings: Settings) -> None:
assert isinstance(get_provider(settings), ConsoleProvider)
assert isinstance(get_provider(smtp_settings(settings)), SmtpProvider)
# Anything that is not explicitly smtp falls back to the keyless console provider.
other = settings.model_copy(update={"email_provider": "sendgrid"})
assert isinstance(get_provider(other), ConsoleProvider)