"""PUT /api/me/preferences merges the keys it is sent into the stored ones, DB-lane. A client saves its preferences a key at a time, and several at once on the boot that moves this browser's copies to the account. Each merge must start from the row as stored when it runs, not as the request first read it — two merges over the same earlier read keep only the last one's keys. """ from fastapi import Depends, Request from sqlalchemy import event, update from sqlalchemy.engine import Engine from sqlalchemy.orm import Session from app.auth import current_user from app.db import SessionLocal, get_db from app.models import User _PW = "correct horse battery" def test_a_save_keeps_a_key_another_save_stored_after_this_one_read_the_account( client, db, make_user, override_dep ): user = make_user(email="prefs@example.com", password=_PW) assert ( client.post("/auth/password-login", json={"email": "prefs@example.com", "password": _PW}) ).status_code == 200 def read_then_another_save(request: Request, session: Session = Depends(get_db)) -> User: # The account as this request reads it, then a second save committing in the meantime. caller = current_user(request, session) with SessionLocal() as other: other.execute( update(User) .where(User.id == caller.id) .values(preferences={"seenVersion": "1.2.3"}) ) other.commit() return caller override_dep(current_user, read_then_another_save) r = client.put("/api/me/preferences", json={"startOnHome": False}) assert r.status_code == 200 db.expire_all() assert db.get(User, user.id).preferences == {"seenVersion": "1.2.3", "startOnHome": False} def test_the_merge_reads_the_account_under_a_lock_that_orders_merges_and_nothing_else( client, make_user ): # The re-read above is half of it: two merges that both read before either commits would still # keep only the last, unless the read takes a lock the other merge waits for. FOR NO KEY UPDATE # is that lock, and the weakest one: a plain FOR UPDATE would also wait behind any transaction # that only added a row referring to the account (a playlist pull, then minutes of YouTube). make_user(email="prefs-lock@example.com", password=_PW) assert ( client.post( "/auth/password-login", json={"email": "prefs-lock@example.com", "password": _PW} ) ).status_code == 200 sent: list[str] = [] def record(_conn, _cursor, statement, *_rest): sent.append(statement) event.listen(Engine, "before_cursor_execute", record) try: assert client.put("/api/me/preferences", json={"startOnHome": False}).status_code == 200 finally: event.remove(Engine, "before_cursor_execute", record) locks = [s for s in sent if "FROM users" in s and " FOR " in s] assert locks, sent assert all(s.rstrip().endswith("FOR NO KEY UPDATE") for s in locks), locks