read.markets/tests/test_verify_subscribe.py
Giorgio Gilestro 00211fec02 ui: collapsible settings sections + welcome-email + larger auth inputs
Settings page tidy-up driven by user feedback that it had grown too busy:

  - Each section (Import, Invite, Email digests, Cloud sync) is now a
    native <details>/<summary> accordion. Import stays open by default
    because /settings#import is the deep-link target from the dashboard
    CTA; the others collapse so the page lands quiet.
  - Manage subscription is a right-aligned gear-icon button instead of
    a rectangular text button — the descriptive copy moves into the
    tooltip. Frees up the Tier row of visual weight.

Auth + modal inputs were too small (verify code box, portfolio restore
PIN): the auth-card selector now covers text inputs as well, and a new
.modal-input class standardises 16px / 12px-padding fields used in the
cloud-sync enable modal and the portfolio restore prompt.

The verify page no longer carries the "Email me the digest" checkbox —
it was misleading on repeat logins (server-side it only applied on
first sign-up but rendered every time). Default-opt-in lives in the
User row at creation; per-user changes happen on /settings. First
successful verify now triggers a one-shot welcome email explaining the
digest cadence and pointing at /settings for opt-out; SMTP failure is
logged but does not block the login.

Tests rewritten to cover the new welcome-email path:
  - first login sends exactly one welcome email
  - returning user gets none
  - SMTP failure does not break the redirect
  - regression guard: returning user who opted out stays opted out

Also lands the paddle merchant-summary doc that was written earlier
during the Paddle → Polar → Stripe onboarding pivot.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 22:32:59 +02:00

179 lines
6 KiB
Python

"""/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") == "/"