"""Tests for :class:`app.services.email.EmailService`. We exercise the dev-fallback path (no ``RESEND_API_KEY``) and assert: - It does NOT raise. - It emits a structured log event ``magic_link_dev_fallback`` with the full URL so a developer can copy it. - It doesn't call the real Resend SDK. Production-missing-key is validated at startup by the pydantic model validator (covered separately in the config tests — but we also verify the behavior here via a direct settings construction). """ from __future__ import annotations from datetime import datetime, timedelta, timezone from pathlib import Path import pytest from fastapi.templating import Jinja2Templates from app.config import Settings from app.logging_config import configure_logging from app.services.email import EmailService _PACKAGE_ROOT = Path(__file__).resolve().parent.parent / "app" _TEMPLATES_DIR = _PACKAGE_ROOT / "templates" @pytest.fixture def templates() -> Jinja2Templates: """Return a Jinja2Templates pointing at the real app templates dir.""" return Jinja2Templates(directory=_TEMPLATES_DIR) def test_dev_fallback_logs_url( templates: Jinja2Templates, capsys: pytest.CaptureFixture[str] ) -> None: """Missing RESEND_API_KEY in dev -> structured log, no exception. structlog writes via ``PrintLoggerFactory`` (stdout), so we capture stdout rather than the stdlib ``caplog`` handler. """ # Reconfigure structlog so the ConsoleRenderer is active — the test # harness may have left it in another state. configure_logging("development") settings = Settings( app_env="development", resend_api_key=None, resend_from=None, ) # type: ignore[call-arg] svc = EmailService(settings, templates) url = "http://localhost:8000/admin/auth/consume/raw-token-123" svc.send_magic_link( to="head-hen@example.com", url=url, display_name="Head Hen", ttl_min=15, expires_at=datetime.now(timezone.utc) + timedelta(minutes=15), ) captured = capsys.readouterr() combined = captured.out + captured.err assert "magic_link_dev_fallback" in combined assert url in combined def test_production_missing_key_refuses_startup() -> None: """Production without resend key / from / admin_emails raises at init.""" with pytest.raises(ValueError) as excinfo: Settings( app_env="production", secret_key="long-enough-prod-secret-key-value-000000", ) # type: ignore[call-arg] msg = str(excinfo.value) assert "RESEND_API_KEY" in msg assert "RESEND_FROM" in msg assert "ADMIN_EMAILS" in msg def test_dev_fallback_does_not_touch_resend( templates: Jinja2Templates, monkeypatch ) -> None: """If the SDK IS present, the dev fallback must still not call it.""" import resend # type: ignore[import-untyped] called: dict[str, bool] = {"ok": False} def _boom(*a, **kw): # pragma: no cover — shouldn't execute called["ok"] = True raise RuntimeError("should not be called in dev fallback") monkeypatch.setattr(resend.Emails, "send", staticmethod(_boom)) settings = Settings( app_env="development", resend_api_key=None, resend_from=None, ) # type: ignore[call-arg] svc = EmailService(settings, templates) svc.send_magic_link( to="a@b.co", url="http://x/y", display_name="A", ttl_min=15, expires_at=datetime.now(timezone.utc), ) assert called["ok"] is False