"""/verify behaviour: returning users keep their digest preference, and first-login triggers the welcome email exactly once. The verify page used to carry a "Email me the digest" checkbox; that was removed (it was misleading on repeat logins). Default-opt-in lives in the User row at creation; per-user changes happen on /settings. """ from __future__ import annotations import asyncio def _build(tmp_path): from fastapi import FastAPI from fastapi.testclient import TestClient from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app import db as db_mod from app.db import Base from app.models import User from app.routers import auth as auth_router from app.auth import _pending_serializer engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/v.db") factory = async_sessionmaker(engine, expire_on_commit=False) db_mod._engine = engine db_mod._session_factory = factory async def _seed(): async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) async with factory() as s: s.add(User(id=10, email="newbie@x", tier="free", email_digest_opt_in=True)) await s.commit() asyncio.run(_seed()) app = FastAPI() app.include_router(auth_router.router) pending = _pending_serializer().dumps({"email": "newbie@x", "uid": 10, "ref": None}) return TestClient(app), pending def test_returning_user_login_preserves_unsubscribe(tmp_path, monkeypatch): """A user who unsubscribed (via Settings or the one-click link) must not be silently re-enrolled when they log in again. The handler now never touches email_digest_opt_in, so this is a regression guard against accidentally adding that back.""" from datetime import datetime, timezone from app.services import otp_service async def _ok(*args, **kwargs): return None monkeypatch.setattr(otp_service, "verify", _ok) client, pending = _build(tmp_path) # Simulate a returning user: previously logged in, then opted out. async def _make_returning(): from app import db as db_mod from app.models import User async with db_mod._session_factory() as s: u = await s.get(User, 10) u.last_login_at = datetime(2026, 5, 20, 12, 0, tzinfo=timezone.utc) u.email_digest_opt_in = False await s.commit() asyncio.run(_make_returning()) r = client.post( "/verify", data={"code": "000000"}, cookies={"cassandra_pending": pending}, follow_redirects=False, ) assert r.status_code == 303 async def _check(): from app import db as db_mod from app.models import User async with db_mod._session_factory() as s: u = await s.get(User, 10) assert u.email_digest_opt_in is False, "returning user re-enrolled" asyncio.run(_check()) def test_first_login_triggers_welcome_email(tmp_path, monkeypatch): """A user signing in for the first time gets exactly one welcome email. The send is best-effort — failure must not block login.""" from unittest.mock import AsyncMock from app.services import otp_service from app.routers import auth as auth_router async def _ok(*args, **kwargs): return None monkeypatch.setattr(otp_service, "verify", _ok) send_mock = AsyncMock() monkeypatch.setattr(auth_router, "send_welcome_email", send_mock) client, pending = _build(tmp_path) r = client.post( "/verify", data={"code": "000000"}, cookies={"cassandra_pending": pending}, follow_redirects=False, ) assert r.status_code == 303 assert send_mock.await_count == 1, "first login should send a welcome email" assert send_mock.await_args.args == ("newbie@x",) def test_returning_user_login_does_not_resend_welcome(tmp_path, monkeypatch): """The welcome email is one-shot: a returning user (last_login_at is not None) must not get a second copy.""" from datetime import datetime, timezone from unittest.mock import AsyncMock from app.services import otp_service from app.routers import auth as auth_router async def _ok(*args, **kwargs): return None monkeypatch.setattr(otp_service, "verify", _ok) send_mock = AsyncMock() monkeypatch.setattr(auth_router, "send_welcome_email", send_mock) client, pending = _build(tmp_path) # Mark the user as already-known. async def _make_returning(): from app import db as db_mod from app.models import User async with db_mod._session_factory() as s: u = await s.get(User, 10) u.last_login_at = datetime(2026, 5, 20, 12, 0, tzinfo=timezone.utc) await s.commit() asyncio.run(_make_returning()) r = client.post( "/verify", data={"code": "000000"}, cookies={"cassandra_pending": pending}, follow_redirects=False, ) assert r.status_code == 303 assert send_mock.await_count == 0, "returning user should not re-get welcome" def test_welcome_email_failure_does_not_block_login(tmp_path, monkeypatch): """SMTP errors are best-effort — the user still gets a session cookie and lands on /. We rely on a log line for operational visibility.""" from unittest.mock import AsyncMock from app.services import otp_service from app.routers import auth as auth_router async def _ok(*args, **kwargs): return None monkeypatch.setattr(otp_service, "verify", _ok) async def _boom(*args, **kwargs): raise RuntimeError("SMTP down") monkeypatch.setattr(auth_router, "send_welcome_email", AsyncMock(side_effect=_boom)) client, pending = _build(tmp_path) r = client.post( "/verify", data={"code": "000000"}, cookies={"cassandra_pending": pending}, follow_redirects=False, ) # Login still succeeds; redirect to dashboard, session cookie set. assert r.status_code == 303, r.text assert r.headers.get("location") == "/"