test: a handler that flushes a write and never commits it fails its test

Helpers flush and handlers commit — but every test request shared the test's session, so a
follow-up GET or an assertion read the flushed write and the suite stayed green over a handler
whose commit was missing (production's get_db closes the session: the write is rolled back behind
a 200). The db fixture now hands out `db.info["request"]`: the same session, recording each request
that ends normally with a flushed, uncommitted write, and failing the test on it at teardown. All
28 get_db overrides use it (a convention test keeps it so); the full suite passes under it, and
dropping the commit from add_item turns a playlist test red.
This commit is contained in:
2026-09-19 19:19:30 +02:00
parent 955c8c3445
commit 884060891e
24 changed files with 87 additions and 32 deletions
+45 -4
View File
@@ -21,7 +21,8 @@ from alembic import command
from alembic.config import Config
from fastapi import HTTPException, Request
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, text
from sqlalchemy import create_engine, event, text
from starlette.requests import HTTPConnection
import app.models # noqa: F401 — register every model on Base.metadata
import app.state as app_state
@@ -100,17 +101,57 @@ def pytest_configure(config):
)
def _request_dep(db, unfinished: list[str]):
"""`get_db` for a test request: the test's own session, plus the check a shared session hides.
A route helper flushes and its HANDLER commits (CLAUDE.md). Production's `get_db` closes the
session after the request, which rolls back anything flushed but not committed — yet here every
request shares the test's session, so a follow-up GET or an assertion reads the flushed write
and the suite stays green over a handler that forgot to commit (deep review, debt burn). So a
request that ends normally with a flushed, uncommitted write is recorded, and the test fails on
it at teardown. The test's own staged rows are flushed before the request starts — they are the
test's business, not the handler's. A request that raises is exempt: its writes are meant to
roll back."""
def dep(conn: HTTPConnection):
db.flush()
state = {"dirty": False}
def flushed(session, ctx):
state["dirty"] = True
def ended(session):
state["dirty"] = False
hooks = (("after_flush", flushed), ("after_commit", ended), ("after_rollback", ended))
for name, fn in hooks:
event.listen(db, name, fn)
try:
yield db
if state["dirty"]:
unfinished.append(f"{conn.scope.get('method', 'WS')} {conn.url.path}")
finally:
for name, fn in hooks:
event.remove(db, name, fn)
return dep
@pytest.fixture
def db(_schema):
"""A real session against the test Postgres, on a clean (empty) database. The code under test
may commit freely."""
may commit freely. `db.info["request"]` is the `get_db` override for a request against it — the
one every test client uses — and a handler that left a write uncommitted fails the test here."""
_truncate_all()
session = SessionLocal()
unfinished: list[str] = []
session.info["request"] = _request_dep(session, unfinished)
try:
yield session
finally:
session.rollback()
session.close()
assert unfinished == [], "a handler flushed a write and never committed it"
@pytest.fixture
@@ -168,7 +209,7 @@ def client(db, override_dep, _configured):
`current_user` override — so login, the wallet gate and epoch revoke are exercised end to end.
Starlette's SessionMiddleware round-trips the signed cookie, so a login in one call authenticates
the next on this same instance. No lifespan (the scheduler / HLS sweep stay off)."""
override_dep(get_db, lambda: db)
override_dep(get_db, db.info["request"])
return TestClient(app)
@@ -187,7 +228,7 @@ def as_user(db, override_dep, _configured):
raise HTTPException(status_code=401, detail="Not authenticated")
return registry[uid]
override_dep(get_db, lambda: db)
override_dep(get_db, db.info["request"])
override_dep(current_user, _resolver)
def _as(user: User) -> TestClient: