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>
179 lines
6 KiB
Python
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") == "/"
|