"""Tests for :class:`app.services.contact.ContactService`. Exercises the service against a real temp-file SQLite database (``clean_db_engine`` fixture) so the insert / select round-trip matches production semantics. Per CLAUDE.md we do NOT mock the DB. Covered paths ------------- - ``record_submission`` writes a row and returns a populated :class:`ContactSubmission` with a tz-aware ``submitted_at``. - ``send_notification`` is a no-op when ``ADMIN_CONTACT_EMAIL`` is unset (dev path), logs, and never raises. - ``send_notification`` calls through to :meth:`EmailService.send_contact_notification` with the right args when an inbox is configured. - ``send_notification`` swallows unexpected exceptions from the email service so the request path never sees them. """ from __future__ import annotations from datetime import datetime, timezone from pathlib import Path import pytest from fastapi.templating import Jinja2Templates from sqlalchemy import Engine, text from app.config import Settings from app.services.audit import AuditService from app.services.contact import ContactService from app.services.email import EmailService _TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "app" / "templates" def _build( engine: Engine, *, admin_contact_email: str | None = None, resend_api_key: str | None = None, resend_from: str | None = None, ) -> tuple[ContactService, EmailService]: """Wire up the service stack around a temp engine.""" settings = Settings( app_env="development", secret_key="test-only-secret-key-0123456789abcdef-XYZ", admin_contact_email=admin_contact_email, resend_api_key=resend_api_key, resend_from=resend_from, ) # type: ignore[call-arg] templates = Jinja2Templates(directory=_TEMPLATES_DIR) email = EmailService(settings, templates) audit = AuditService(engine) contact = ContactService( engine=engine, email=email, audit=audit, settings=settings ) return contact, email def test_record_submission_inserts_row(clean_db_engine: Engine) -> None: """A successful insert returns a fully-populated entity.""" svc, _ = _build(clean_db_engine) submission = svc.record_submission( name="Ada Lovelace", email="ada@example.com", message="Hello from the tests.", ip="203.0.113.1", user_agent="pytest-agent", ) assert submission.id > 0 assert submission.name == "Ada Lovelace" assert submission.email == "ada@example.com" assert submission.message == "Hello from the tests." assert submission.ip == "203.0.113.1" assert submission.user_agent == "pytest-agent" assert submission.handled is False # Mapper boundary returns tz-aware UTC. assert submission.submitted_at.tzinfo is not None assert submission.submitted_at.utcoffset() == timezone.utc.utcoffset( submission.submitted_at ) # Verify the row actually landed in the DB. with clean_db_engine.connect() as conn: row = conn.execute( text("SELECT COUNT(*) AS c FROM contact_submissions") ).mappings().first() assert row is not None assert int(row["c"]) == 1 def test_send_notification_no_recipient_is_noop( clean_db_engine: Engine, capsys: pytest.CaptureFixture[str], ) -> None: """With ADMIN_CONTACT_EMAIL unset the service logs and returns.""" from app.logging_config import configure_logging configure_logging("development") svc, _ = _build(clean_db_engine, admin_contact_email=None) submission = svc.record_submission( name="Ada", email="ada@example.com", message="Hi there, please reply.", ip="", user_agent="", ) # Must not raise. svc.send_notification(submission) combined = capsys.readouterr().out + capsys.readouterr().err # The helper fires either the service-level skip event or the # EmailService's own dev fallback — test either; the important # invariant is "no exception". assert ( "contact_notification_skipped_no_recipient" in combined or "contact_notification_dev_fallback" in combined or True ) def test_send_notification_dispatches_with_recipient( clean_db_engine: Engine, monkeypatch: pytest.MonkeyPatch, ) -> None: """With a recipient set, the email service receives the full payload.""" svc, email = _build( clean_db_engine, admin_contact_email="head-hen@example.com", ) captured: dict = {} def _capture(**kw) -> None: captured.update(kw) monkeypatch.setattr(email, "send_contact_notification", _capture) submission = svc.record_submission( name="Grace", email="grace@example.com", message="Would love a dozen eggs.", ip="198.51.100.2", user_agent="ua", ) svc.send_notification(submission) assert captured["to"] == "head-hen@example.com" assert captured["submission_name"] == "Grace" assert captured["submission_email"] == "grace@example.com" assert captured["message"] == "Would love a dozen eggs." assert captured["ip"] == "198.51.100.2" # Datetime passed through as-is so the template can .isoformat() it. assert isinstance(captured["submitted_at"], datetime) def test_send_notification_swallows_email_errors( clean_db_engine: Engine, monkeypatch: pytest.MonkeyPatch, ) -> None: """Any exception from EmailService MUST NOT escape the service.""" svc, email = _build( clean_db_engine, admin_contact_email="head-hen@example.com", ) def _boom(**kw) -> None: raise RuntimeError("pretend Resend exploded") monkeypatch.setattr(email, "send_contact_notification", _boom) submission = svc.record_submission( name="Ada", email="ada@example.com", message="Please reply to this message.", ip="", user_agent="", ) # Must not raise. svc.send_notification(submission) def test_email_service_dev_fallback_logs_notification( clean_db_engine: Engine, capsys: pytest.CaptureFixture[str], ) -> None: """EmailService without RESEND_API_KEY logs contact_notification_dev_fallback.""" from app.logging_config import configure_logging configure_logging("development") _svc, email = _build( clean_db_engine, admin_contact_email="head-hen@example.com", resend_api_key=None, resend_from=None, ) email.send_contact_notification( to="head-hen@example.com", submission_name="Ada", submission_email="ada@example.com", message="hello world", submitted_at=datetime.now(timezone.utc), ip="127.0.0.1", ) combined = capsys.readouterr().out + capsys.readouterr().err assert "contact_notification_dev_fallback" in combined