diff --git a/alembic/versions/0027_user_acknowledgements.py b/alembic/versions/0027_user_acknowledgements.py new file mode 100644 index 0000000..7332ee2 --- /dev/null +++ b/alembic/versions/0027_user_acknowledgements.py @@ -0,0 +1,61 @@ +"""user_acknowledgements: affirmative sign-up acknowledgement audit table. + +Revision ID: 0027 +Revises: 0026 +Create Date: 2026-05-29 + +One row per (user, version) acceptance of the /login acknowledgement. +See app/models.py::UserAcknowledgement and app/legal.py for context. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = "0027" +down_revision: Union[str, None] = "0026" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "user_acknowledgements", + sa.Column( + "id", + sa.BigInteger().with_variant(sa.Integer(), "sqlite"), + primary_key=True, autoincrement=True, + ), + sa.Column( + "user_id", sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("version", sa.SmallInteger(), nullable=False), + sa.Column("lang", sa.String(length=8), nullable=False), + sa.Column( + "accepted_at", sa.DateTime(timezone=True), nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + ) + op.create_index( + "ix_user_acknowledgements_user_version", + "user_acknowledgements", ["user_id", "version"], + ) + op.create_index( + "ix_user_acknowledgements_accepted_at", + "user_acknowledgements", ["accepted_at"], + ) + + +def downgrade() -> None: + op.drop_index( + "ix_user_acknowledgements_accepted_at", + table_name="user_acknowledgements", + ) + op.drop_index( + "ix_user_acknowledgements_user_version", + table_name="user_acknowledgements", + ) + op.drop_table("user_acknowledgements") diff --git a/app/legal.py b/app/legal.py new file mode 100644 index 0000000..94f27f3 --- /dev/null +++ b/app/legal.py @@ -0,0 +1,19 @@ +"""Legal-surface constants. + +Currently a one-liner: the version of the sign-up acknowledgement text +the user actively accepts at ``/login``. + +Bumping this constant re-prompts every existing user on their next +sign-in, because the new value won't match their stored +``user_acknowledgements.version`` rows. The recorded row is what gives +the acknowledgement its evidentiary weight; the constant is the pointer +that says "the current canonical text is at version N". The text itself +lives in ``app/locales/{en,it}.yaml`` under ``auth.ack``. + +When the solicitor finalises wording, edit the YAML files **and** bump +this number in the same change so the version pointer stays accurate. +""" +from __future__ import annotations + + +ACKNOWLEDGEMENT_VERSION: int = 1 diff --git a/app/locales/en.yaml b/app/locales/en.yaml index d8c9bb4..caef603 100644 --- a/app/locales/en.yaml +++ b/app/locales/en.yaml @@ -118,3 +118,17 @@ footer: meta: description: "Understand markets. Don't gamble on them." + +# TODO(legal): final wording pending solicitor sign-off. Bumping +# ACKNOWLEDGEMENT_VERSION (app/legal.py) re-prompts every existing user +# on next sign-in and writes a fresh row at the new version. +auth: + ack: + heading: "Before you create an account, please confirm:" + items: + - "This service is editorial and educational commentary on public market data. It is not financial, investment, or tax advice and is not a personal recommendation." + - "The operator is not authorised or regulated to provide investment advice. Nothing here is a recommendation to buy, sell, or hold any investment." + - "I make my own investment decisions and am solely responsible for them. For personal advice I will consult a regulated adviser." + checkbox_label: "I have read and accept these statements." + error_required: "Please tick the box to confirm the acknowledgement before continuing." + lang_switch_aria: "Language" diff --git a/app/locales/it.yaml b/app/locales/it.yaml index 51a8417..34f9098 100644 --- a/app/locales/it.yaml +++ b/app/locales/it.yaml @@ -124,3 +124,17 @@ footer: meta: description: "Capisci i mercati. Non scommetterci sopra." + +# TODO(legal): final wording pending solicitor sign-off. Bumping +# ACKNOWLEDGEMENT_VERSION (app/legal.py) re-prompts every existing user +# on next sign-in and writes a fresh row at the new version. +auth: + ack: + heading: "Prima di creare un account, conferma:" + items: + - "Questo servizio è commento editoriale ed educativo su dati di mercato pubblici. Non è consulenza finanziaria, di investimento o fiscale e non è una raccomandazione personalizzata." + - "L'operatore non è autorizzato né regolamentato a fornire consulenza in materia di investimenti. Nulla qui è una raccomandazione di acquisto, vendita o detenzione di alcuno strumento finanziario." + - "Prendo da solo le mie decisioni di investimento e ne sono unico responsabile. Per una consulenza personalizzata mi rivolgerò a un consulente regolamentato." + checkbox_label: "Ho letto e accetto queste dichiarazioni." + error_required: "Spunta la casella per confermare prima di continuare." + lang_switch_aria: "Lingua" diff --git a/app/models.py b/app/models.py index b454f76..f64d2e4 100644 --- a/app/models.py +++ b/app/models.py @@ -246,6 +246,35 @@ class ReviewerVerdict(Base): model: Mapped[str | None] = mapped_column(String(64)) +class UserAcknowledgement(Base): + """Append-only record of a user actively accepting the sign-up + acknowledgement at ``/login``. See app/legal.py for the version + constant and app/locales/{en,it}.yaml::auth.ack for the canonical text. + + One row per (user, version) acceptance. The value of this table is + evidentiary: 'user X accepted the version-N text on date D, in the + language they were shown'. A future wording revision bumps + ACKNOWLEDGEMENT_VERSION; existing users see the prompt again on next + sign-in and a new row is written at the new version.""" + __tablename__ = "user_acknowledgements" + id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ) + version: Mapped[int] = mapped_column(SmallInteger, nullable=False) + # Language the user actually saw — what they accepted is keyed to it. + lang: Mapped[str] = mapped_column(String(8), nullable=False) + accepted_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utcnow, nullable=False, index=True, + ) + + __table_args__ = ( + Index("ix_user_acknowledgements_user_version", "user_id", "version"), + ) + + # Portfolio / PortfolioSnapshot / Position removed in Phase G — # holdings live in the browser, the server stores only the anonymous # ticker universe + public market data. diff --git a/app/routers/auth.py b/app/routers/auth.py index 28a7d4d..aa7b036 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -34,13 +34,38 @@ from app.auth import ( ) from app.config import get_settings from app.db import get_session, utcnow +from app.legal import ACKNOWLEDGEMENT_VERSION from app.logging import get_logger -from app.services.auth_service import AuthError, get_or_create_user, get_user +from app.services.auth_service import ( + AuthError, + get_or_create_user, + get_user, + has_acknowledged_current, + record_acknowledgement, +) from app.services import otp_service, referral_service from app.services.email_service import EmailSendError, send_otp, send_welcome_email +from app.services.locales import ACTIVE_PUBLIC_LANGS, detect_public_lang, get_locale from app.templates_env import templates +_LANG_COOKIE = "rtm.lang" + + +def _resolve_login_lang(request: Request, explicit: str | None) -> str: + """Resolve the language to render /login in. An explicit ?lang= wins + (used by the on-page EN/IT pill); otherwise the same detection + landing.py uses — cookie → Accept-Language → cf-ipcountry → en.""" + if explicit and explicit in ACTIVE_PUBLIC_LANGS: + return explicit + return detect_public_lang( + cookie_lang=request.cookies.get(_LANG_COOKIE), + accept_language=request.headers.get("accept-language"), + cf_country=request.headers.get("cf-ipcountry"), + user_lang=None, + ) + + log = get_logger("auth_router") router = APIRouter(tags=["auth"]) @@ -111,6 +136,7 @@ async def login_page( next: str | None = None, error: str | None = None, ref: str | None = None, + lang: str | None = None, session: AsyncSession = Depends(get_session), ): # If a valid referral code is supplied, surface a small "invited" @@ -121,15 +147,27 @@ async def login_page( await referral_service.lookup_referrer(session, ref_norm) if ref_norm else None ) - return templates.TemplateResponse( + resolved_lang = _resolve_login_lang(request, lang) + response = templates.TemplateResponse( request, "login.html", { "next_path": _safe_next(next), "error": error, "ref": ref_norm if referrer else None, "referrer_present": referrer is not None, + "lang": resolved_lang, + "t": get_locale(resolved_lang), + "ack_version": ACKNOWLEDGEMENT_VERSION, }, ) + # Stamp the language cookie if the user used the on-page pill so + # subsequent visits land on the same translation without re-detecting. + if lang and lang in ACTIVE_PUBLIC_LANGS: + response.set_cookie( + _LANG_COOKIE, lang, + max_age=60 * 60 * 24 * 365, samesite="lax", httponly=False, + ) + return response @router.post("/login") @@ -138,6 +176,9 @@ async def login_submit( email: str = Form(...), next: str | None = Form(default=None), ref: str | None = Form(default=None), + lang: str | None = Form(default=None), + acknowledged: str | None = Form(default=None), + ack_version: int | None = Form(default=None), session: AsyncSession = Depends(get_session), ): s = get_settings() @@ -150,6 +191,27 @@ async def login_submit( if ref_norm else None ) + # Resolve language from the form (what the user actually saw) or + # fall back to detection. This is the language we'll record on the + # acknowledgement row if one gets written. + resolved_lang = _resolve_login_lang(request, lang) + t = get_locale(resolved_lang) + + # Affirmative-acknowledgement check. Browsers only POST the checkbox + # name when ticked, so any truthy value here means accepted. + if not acknowledged: + return templates.TemplateResponse( + request, "login.html", + {"next_path": _safe_next(next), + "error": str(t.auth.ack.error_required), + "email": email, + "ref": ref_norm if referrer else None, + "referrer_present": referrer is not None, + "lang": resolved_lang, "t": t, + "ack_version": ACKNOWLEDGEMENT_VERSION}, + status_code=400, + ) + # Track whether THIS request creates the user row (i.e. a referral # capture window). Cleanest way: probe for existence first. from app.services.auth_service import get_user_by_email @@ -164,7 +226,9 @@ async def login_submit( request, "login.html", {"next_path": _safe_next(next), "error": str(e), "email": email, "ref": ref_norm if referrer else None, - "referrer_present": referrer is not None}, + "referrer_present": referrer is not None, + "lang": resolved_lang, "t": t, + "ack_version": ACKNOWLEDGEMENT_VERSION}, status_code=400, ) @@ -175,6 +239,15 @@ async def login_submit( if was_new and referrer is not None: await referral_service.link_new_user(session, user, referrer) + # Record the acknowledgement at the current version, idempotently. + # If the user has already accepted this version we skip — every + # sign-in needn't write a row, only every version bump does. + if not await has_acknowledged_current(session, user): + await record_acknowledgement(session, user, resolved_lang) + log.info("auth.acknowledgement_recorded", + user_id=user.id, version=ACKNOWLEDGEMENT_VERSION, + lang=resolved_lang) + # Issue OTP only if cooldown allows; if a fresh one was sent in the # last 60s we just reuse the existing one (silently) to avoid # spamming the user's inbox on a refreshed form submit. diff --git a/app/services/auth_service.py b/app/services/auth_service.py index 4791ee8..558b4d2 100644 --- a/app/services/auth_service.py +++ b/app/services/auth_service.py @@ -17,7 +17,8 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.db import utcnow -from app.models import User +from app.legal import ACKNOWLEDGEMENT_VERSION +from app.models import User, UserAcknowledgement class AuthError(Exception): @@ -69,3 +70,35 @@ async def get_or_create_user( await session.commit() await session.refresh(user) return user + + +async def has_acknowledged_current( + session: AsyncSession, user: User, +) -> bool: + """True iff ``user`` has a ``user_acknowledgements`` row at the + currently-canonical ``ACKNOWLEDGEMENT_VERSION``. Composite-index hit + on (user_id, version); fast enough to run on every sign-in.""" + row = (await session.execute( + select(UserAcknowledgement.id) + .where(UserAcknowledgement.user_id == user.id) + .where(UserAcknowledgement.version == ACKNOWLEDGEMENT_VERSION) + .limit(1) + )).scalar_one_or_none() + return row is not None + + +async def record_acknowledgement( + session: AsyncSession, user: User, lang: str, +) -> None: + """Insert one ``user_acknowledgements`` row at the current version, + recording the language the user actually saw. The caller is + expected to gate on ``has_acknowledged_current`` first — this helper + does not de-duplicate so the audit trail can carry repeated + acceptances if a future caller wants them.""" + session.add(UserAcknowledgement( + user_id=user.id, + version=ACKNOWLEDGEMENT_VERSION, + lang=lang, + accepted_at=utcnow(), + )) + await session.commit() diff --git a/app/templates/login.html b/app/templates/login.html index 2cfc899..4e3e149 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -1,5 +1,5 @@ - + @@ -17,40 +17,110 @@
-
{{ BRAND_NAME }}
-
sign in with email
+
+ {{ BRAND_NAME }} + + EN + · + IT + +
+
+ {% if lang == 'it' %}accedi via email{% else %}sign in with email{% endif %} +
{% if referrer_present %}
- You've been invited. + {% if lang == 'it' %}Sei stato invitato.{% else %}You've been invited.{% endif %} + {% if lang == 'it' %} + Quando ti abboni, tu e il tuo amico ricevete entrambi + 50% di sconto per 3 mesi. Iscriviti qui sotto per attivarlo. + {% else %} When you subscribe, you and your friend both get 50% off for 3 months. Sign up below to lock it in. + {% endif %}
{% endif %}

+ {% if lang == 'it' %} + Inserisci la tua email e ti invieremo un codice di 6 cifre. Niente password. + I nuovi visitatori creano un account; chi torna fa l'accesso. + {% else %} Enter your email and we'll send you a 6-digit code. No password. First-time visitors get an account; returning visitors get a sign-in. + {% endif %}

{% if error %}
{{ error }}
{% endif %}
+ + {% if ref %}{% endif %} - + + {# Affirmative un-pre-ticked acknowledgement. The substance is + locale-driven (auth.ack.*); the checkbox is required client-side + AND server-side. Replaces a passive "by continuing you agree…" + paragraph because an active acceptance carries more weight. #} + {% if t %} +
+
{{ t.auth.ack.heading }}
+
    + {% for item in t.auth.ack.items %} +
  • {{ item }}
  • + {% endfor %} +
+ +
+ {% endif %} + +
+ diff --git a/tests/test_signup_acknowledgement.py b/tests/test_signup_acknowledgement.py new file mode 100644 index 0000000..8224ade --- /dev/null +++ b/tests/test_signup_acknowledgement.py @@ -0,0 +1,388 @@ +"""Sign-up acknowledgement: the affirmative checkbox at /login. + +Covers: +- POST /login without the box ticked → 400, form re-rendered with the error. +- New email with box ticked → User row + UserAcknowledgement row at the + current version, in the language the user actually saw. +- Existing user with a current-version ack row → POST succeeds, no duplicate. +- Existing user with only an older-version ack row → new row at current. +- has_acknowledged_current() unit tests. +""" +from __future__ import annotations + +import asyncio + + +def _build(tmp_path): + """Spin up a fresh app + sqlite DB + tables. Returns (TestClient, factory). + + Patches otp_service and email send into no-ops so POST /login can complete + without hitting SMTP. The acknowledgement is captured during POST /login + (before OTP), so /verify never needs to be exercised here. Static files + are mounted because the rejection path re-renders login.html which + references ``url_for('static', ...)``.""" + from pathlib import Path + from fastapi import FastAPI + from fastapi.staticfiles import StaticFiles + 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 + import app.models # noqa: F401 — registers tables + from app.routers import auth as auth_router + + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/ack.db") + factory = async_sessionmaker(engine, expire_on_commit=False) + db_mod._engine = engine + db_mod._session_factory = factory + + async def _create_all(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + asyncio.run(_create_all()) + + app = FastAPI() + app.include_router(auth_router.router) + static_dir = Path(__file__).resolve().parent.parent / "app" / "static" + app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") + return TestClient(app), factory + + +def _patch_otp_email(monkeypatch): + """Stub OTP issuance and email send so POST /login can run end-to-end + without a Redis-backed OTP service or real SMTP.""" + from app.services import otp_service + from app.routers import auth as auth_router + + async def _allowed(*_a, **_kw): + return (True, 0) + + async def _issue(*_a, **_kw): + return "123456" + + async def _send_ok(*_a, **_kw): + return True + + monkeypatch.setattr(otp_service, "can_request_new", _allowed) + monkeypatch.setattr(otp_service, "issue", _issue) + # _issue_and_send_otp lives on the router and wraps the email send; + # easier to stub the whole helper than to thread through email_service. + monkeypatch.setattr(auth_router, "_issue_and_send_otp", _send_ok) + + +async def _count_acks(factory, user_id: int, version: int | None = None) -> int: + from sqlalchemy import select, func + from app.models import UserAcknowledgement + async with factory() as s: + q = select(func.count()).select_from(UserAcknowledgement).where( + UserAcknowledgement.user_id == user_id, + ) + if version is not None: + q = q.where(UserAcknowledgement.version == version) + return (await s.execute(q)).scalar() or 0 + + +# --------------------------------------------------------------------------- +# POST /login validation: missing-checkbox path +# --------------------------------------------------------------------------- + + +def test_post_login_rejects_when_acknowledged_unchecked(tmp_path, monkeypatch): + _patch_otp_email(monkeypatch) + client, _ = _build(tmp_path) + + r = client.post( + "/login", + data={"email": "alice@example.com", "next": "/", "lang": "en"}, + follow_redirects=False, + ) + assert r.status_code == 400 + # The localised error message goes back in the rendered template. + assert "tick the box" in r.text.lower() or "confirm" in r.text.lower() + + +def test_post_login_rejection_preserves_email(tmp_path, monkeypatch): + _patch_otp_email(monkeypatch) + client, _ = _build(tmp_path) + + r = client.post( + "/login", + data={"email": "alice@example.com", "next": "/", "lang": "en"}, + follow_redirects=False, + ) + assert r.status_code == 400 + assert "alice@example.com" in r.text + + +def test_post_login_localises_error_in_italian(tmp_path, monkeypatch): + _patch_otp_email(monkeypatch) + client, _ = _build(tmp_path) + + r = client.post( + "/login", + data={"email": "anna@example.com", "next": "/", "lang": "it"}, + follow_redirects=False, + ) + assert r.status_code == 400 + # IT error: "Spunta la casella per confermare prima di continuare." + assert "spunta la casella" in r.text.lower() or "confermare" in r.text.lower() + + +# --------------------------------------------------------------------------- +# Successful POST /login: writes User + UserAcknowledgement +# --------------------------------------------------------------------------- + + +def test_new_signup_writes_acknowledgement_row(tmp_path, monkeypatch): + _patch_otp_email(monkeypatch) + client, factory = _build(tmp_path) + + r = client.post( + "/login", + data={ + "email": "alice@example.com", + "next": "/", + "lang": "en", + "acknowledged": "on", + "ack_version": "1", + }, + follow_redirects=False, + ) + # 303 → /verify + assert r.status_code == 303 + assert r.headers["location"].startswith("/verify") + + # Find the new user and assert exactly one acknowledgement row at v1. + from app.models import User, UserAcknowledgement + from sqlalchemy import select + + async def _check(): + async with factory() as s: + user = (await s.execute( + select(User).where(User.email == "alice@example.com") + )).scalar_one() + rows = (await s.execute( + select(UserAcknowledgement).where( + UserAcknowledgement.user_id == user.id, + ) + )).scalars().all() + return user, rows + + user, rows = asyncio.run(_check()) + assert len(rows) == 1 + ack = rows[0] + assert ack.version == 1 + assert ack.lang == "en" + assert ack.accepted_at is not None + + +def test_acknowledgement_records_displayed_language(tmp_path, monkeypatch): + _patch_otp_email(monkeypatch) + client, factory = _build(tmp_path) + + r = client.post( + "/login", + data={ + "email": "anna@example.it", + "next": "/", + "lang": "it", + "acknowledged": "on", + "ack_version": "1", + }, + follow_redirects=False, + ) + assert r.status_code == 303 + + from app.models import User, UserAcknowledgement + from sqlalchemy import select + + async def _check(): + async with factory() as s: + user = (await s.execute( + select(User).where(User.email == "anna@example.it") + )).scalar_one() + ack = (await s.execute( + select(UserAcknowledgement).where( + UserAcknowledgement.user_id == user.id, + ) + )).scalar_one() + return ack.lang + + assert asyncio.run(_check()) == "it" + + +# --------------------------------------------------------------------------- +# Idempotency: existing user already at current version → no dup row +# --------------------------------------------------------------------------- + + +def test_existing_user_current_version_no_duplicate(tmp_path, monkeypatch): + _patch_otp_email(monkeypatch) + client, factory = _build(tmp_path) + + # Pre-seed: User + one acknowledgement at the current version. + async def _seed(): + from app.models import User, UserAcknowledgement + from app.legal import ACKNOWLEDGEMENT_VERSION + from app.db import utcnow + + async with factory() as s: + u = User(email="repeat@example.com", tier="free", + settings_json={}, created_at=utcnow()) + s.add(u) + await s.commit() + await s.refresh(u) + s.add(UserAcknowledgement( + user_id=u.id, + version=ACKNOWLEDGEMENT_VERSION, + lang="en", + accepted_at=utcnow(), + )) + await s.commit() + return u.id + + user_id = asyncio.run(_seed()) + before = asyncio.run(_count_acks(factory, user_id)) + assert before == 1 + + r = client.post( + "/login", + data={ + "email": "repeat@example.com", + "next": "/", + "lang": "en", + "acknowledged": "on", + "ack_version": "1", + }, + follow_redirects=False, + ) + assert r.status_code == 303 + + after = asyncio.run(_count_acks(factory, user_id)) + assert after == 1, "must not write a duplicate row when user already at current version" + + +# --------------------------------------------------------------------------- +# Version bump: existing user only at older version → new current-version row +# --------------------------------------------------------------------------- + + +def test_existing_user_older_version_writes_new_current_row(tmp_path, monkeypatch): + _patch_otp_email(monkeypatch) + client, factory = _build(tmp_path) + + # Pre-seed a user with an OLD-version acknowledgement (version=0). + # The current version constant is 1 → this user is "stale" and should + # be prompted again. + async def _seed(): + from app.models import User, UserAcknowledgement + from app.db import utcnow + + async with factory() as s: + u = User(email="bump@example.com", tier="free", + settings_json={}, created_at=utcnow()) + s.add(u) + await s.commit() + await s.refresh(u) + s.add(UserAcknowledgement( + user_id=u.id, version=0, lang="en", accepted_at=utcnow(), + )) + await s.commit() + return u.id + + user_id = asyncio.run(_seed()) + + r = client.post( + "/login", + data={ + "email": "bump@example.com", + "next": "/", + "lang": "en", + "acknowledged": "on", + "ack_version": "1", + }, + follow_redirects=False, + ) + assert r.status_code == 303 + + # Total rows: 1 old + 1 new = 2. Current-version rows: exactly 1. + total = asyncio.run(_count_acks(factory, user_id)) + current = asyncio.run(_count_acks(factory, user_id, version=1)) + assert total == 2 + assert current == 1 + + +# --------------------------------------------------------------------------- +# has_acknowledged_current() — unit-ish, no HTTP +# --------------------------------------------------------------------------- + + +def test_has_acknowledged_current_no_row(tmp_path): + _, factory = _build(tmp_path) + + async def _go(): + from app.models import User + from app.services.auth_service import has_acknowledged_current + from app.db import utcnow + + async with factory() as s: + u = User(email="empty@example.com", tier="free", + settings_json={}, created_at=utcnow()) + s.add(u) + await s.commit() + await s.refresh(u) + return await has_acknowledged_current(s, u) + + assert asyncio.run(_go()) is False + + +def test_has_acknowledged_current_only_old(tmp_path): + _, factory = _build(tmp_path) + + async def _go(): + from app.models import User, UserAcknowledgement + from app.services.auth_service import has_acknowledged_current + from app.db import utcnow + + async with factory() as s: + u = User(email="oldonly@example.com", tier="free", + settings_json={}, created_at=utcnow()) + s.add(u) + await s.commit() + await s.refresh(u) + s.add(UserAcknowledgement( + user_id=u.id, version=0, lang="en", accepted_at=utcnow(), + )) + await s.commit() + return await has_acknowledged_current(s, u) + + assert asyncio.run(_go()) is False + + +def test_has_acknowledged_current_at_current(tmp_path): + _, factory = _build(tmp_path) + + async def _go(): + from app.models import User, UserAcknowledgement + from app.services.auth_service import has_acknowledged_current + from app.legal import ACKNOWLEDGEMENT_VERSION + from app.db import utcnow + + async with factory() as s: + u = User(email="atcurrent@example.com", tier="free", + settings_json={}, created_at=utcnow()) + s.add(u) + await s.commit() + await s.refresh(u) + s.add(UserAcknowledgement( + user_id=u.id, + version=ACKNOWLEDGEMENT_VERSION, + lang="en", + accepted_at=utcnow(), + )) + await s.commit() + return await has_acknowledged_current(s, u) + + assert asyncio.run(_go()) is True