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 alembic.config import Config
from fastapi import HTTPException, Request from fastapi import HTTPException, Request
from fastapi.testclient import TestClient 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.models # noqa: F401 — register every model on Base.metadata
import app.state as app_state 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 @pytest.fixture
def db(_schema): def db(_schema):
"""A real session against the test Postgres, on a clean (empty) database. The code under test """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() _truncate_all()
session = SessionLocal() session = SessionLocal()
unfinished: list[str] = []
session.info["request"] = _request_dep(session, unfinished)
try: try:
yield session yield session
finally: finally:
session.rollback() session.rollback()
session.close() session.close()
assert unfinished == [], "a handler flushed a write and never committed it"
@pytest.fixture @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. `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 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).""" 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) return TestClient(app)
@@ -187,7 +228,7 @@ def as_user(db, override_dep, _configured):
raise HTTPException(status_code=401, detail="Not authenticated") raise HTTPException(status_code=401, detail="Not authenticated")
return registry[uid] return registry[uid]
override_dep(get_db, lambda: db) override_dep(get_db, db.info["request"])
override_dep(current_user, _resolver) override_dep(current_user, _resolver)
def _as(user: User) -> TestClient: def _as(user: User) -> TestClient:
+4 -4
View File
@@ -40,7 +40,7 @@ def test_channel_detail_returns_normal_shorts_live_counts(db, monkeypatch, overr
# The request gate 503s an unconfigured instance; flip the process-level flag (auto-restored). # The request gate 503s an unconfigured instance; flip the process-level flag (auto-restored).
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
override_dep(get_db, lambda: db) override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: me) override_dep(current_user, lambda: me)
resp = TestClient(app).get(f"/api/channels/{ch.id}") resp = TestClient(app).get(f"/api/channels/{ch.id}")
@@ -65,7 +65,7 @@ def test_channel_detail_surfaces_subscription_hidden_flag(db, monkeypatch, hidde
db.commit() db.commit()
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
override_dep(get_db, lambda: db) override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: me) override_dep(current_user, lambda: me)
resp = TestClient(app).get(f"/api/channels/{ch.id}") resp = TestClient(app).get(f"/api/channels/{ch.id}")
@@ -81,7 +81,7 @@ def test_channel_detail_hidden_defaults_false_when_not_subscribed(db, monkeypatc
db.commit() db.commit()
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
override_dep(get_db, lambda: db) override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: me) override_dep(current_user, lambda: me)
resp = TestClient(app).get(f"/api/channels/{ch.id}") resp = TestClient(app).get(f"/api/channels/{ch.id}")
@@ -111,7 +111,7 @@ def _patch_deep_requested(db, monkeypatch, override_dep, user):
# background.add_task runs after the response in TestClient; record what would be scheduled. # background.add_task runs after the response in TestClient; record what would be scheduled.
monkeypatch.setattr(channels_route, "backfill_channel_now", lambda *a, **k: calls.append(a)) monkeypatch.setattr(channels_route, "backfill_channel_now", lambda *a, **k: calls.append(a))
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
override_dep(get_db, lambda: db) override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: user) override_dep(current_user, lambda: user)
resp = TestClient(app).patch("/api/channels/UC_wire", json={"deep_requested": True}) resp = TestClient(app).patch("/api/channels/UC_wire", json={"deep_requested": True})
return resp, calls return resp, calls
+1 -1
View File
@@ -43,7 +43,7 @@ def test_channel_detail_returns_only_this_users_tag_ids(db, monkeypatch, overrid
# The request gate 503s an unconfigured instance; flip the process-level flag (auto-restored). # The request gate 503s an unconfigured instance; flip the process-level flag (auto-restored).
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
override_dep(get_db, lambda: db) override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: me) override_dep(current_user, lambda: me)
client = TestClient(app) # no context-manager: skip lifespan (scheduler/HLS sweep) client = TestClient(app) # no context-manager: skip lifespan (scheduler/HLS sweep)
resp = client.get(f"/api/channels/{ch.id}") resp = client.get(f"/api/channels/{ch.id}")
+1 -1
View File
@@ -19,7 +19,7 @@ def _get(db, user, monkeypatch, override_dep, path):
monkeypatch.setattr( monkeypatch.setattr(
state, "_configured_cache", True state, "_configured_cache", True
) # the request gate 503s an unconfigured app ) # the request gate 503s an unconfigured app
override_dep(get_db, lambda: db) override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: user) override_dep(current_user, lambda: user)
client = TestClient(app) # no context manager: skip lifespan (scheduler/HLS sweep) client = TestClient(app) # no context manager: skip lifespan (scheduler/HLS sweep)
return client.get(path) return client.get(path)
+1 -1
View File
@@ -143,7 +143,7 @@ def _library_item(db, rating_key: str, *, playable: str = "remux"):
def _client(db, user, override_dep): def _client(db, user, override_dep):
override_dep(get_db, lambda: db) override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: user) override_dep(current_user, lambda: user)
return TestClient(app) return TestClient(app)
@@ -21,3 +21,17 @@ def test_no_test_writes_dependency_overrides_directly():
if "dependency_overrides" in line if "dependency_overrides" in line
] ]
assert offenders == [], "use the override_dep fixture: " + ", ".join(offenders) assert offenders == [], "use the override_dep fixture: " + ", ".join(offenders)
def test_a_request_gets_the_session_that_checks_its_commit():
"""`get_db` is overridden with `db.info["request"]` — the test session plus the check that the
handler committed what it flushed (conftest `_request_dep`). A bare `lambda: db` hands the route
the same session without the check, and a handler that forgot its commit passes again."""
offenders = [
f"{p.name}:{n}"
for p in sorted(TESTS.rglob("*.py"))
if p.name != Path(__file__).name
for n, line in enumerate(p.read_text(encoding="utf-8").splitlines(), 1)
if "override_dep(get_db," in line and 'override_dep(get_db, db.info["request"])' not in line
]
assert offenders == [], 'override get_db with db.info["request"]: ' + ", ".join(offenders)
+1 -1
View File
@@ -52,7 +52,7 @@ def _clear(db, user, it, monkeypatch, override_dep) -> list[tuple]:
"push_state_to_plex", "push_state_to_plex",
lambda user_id, item_id, rating_key, action, *rest: pushes.append((rating_key, action)), lambda user_id, item_id, rating_key, action, *rest: pushes.append((rating_key, action)),
) )
override_dep(get_db, lambda: db) override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: user) override_dep(current_user, lambda: user)
resp = TestClient(app).post(f"/api/plex/item/{it.rating_key}/state", json={"status": "new"}) resp = TestClient(app).post(f"/api/plex/item/{it.rating_key}/state", json={"status": "new"})
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
+1 -1
View File
@@ -233,7 +233,7 @@ def _seed(db):
def _get(db, user, monkeypatch, override_dep, path): def _get(db, user, monkeypatch, override_dep, path):
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
override_dep(get_db, lambda: db) override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: user) override_dep(current_user, lambda: user)
return TestClient(app).get(path) return TestClient(app).get(path)
@@ -60,7 +60,7 @@ def _seed(db):
def _get(db, user, monkeypatch, override_dep, path): def _get(db, user, monkeypatch, override_dep, path):
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
override_dep(get_db, lambda: db) override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: user) override_dep(current_user, lambda: user)
return TestClient(app).get(path) return TestClient(app).get(path)
@@ -148,7 +148,7 @@ def test_create_collection_rejects_duplicate_name(db, monkeypatch, override_dep)
user = User(email="dup@example.com") user = User(email="dup@example.com")
db.add(user) db.add(user)
db.commit() db.commit()
override_dep(get_db, lambda: db) override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: user) override_dep(current_user, lambda: user)
override_dep(admin_user, lambda: user) override_dep(admin_user, lambda: user)
r = TestClient(app).post( r = TestClient(app).post(
@@ -273,7 +273,7 @@ def _admin_client(db, user, monkeypatch, override_dep, stub, resync=_resync_ok):
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
monkeypatch.setattr(coll_routes, "PlexClient", lambda db: stub) monkeypatch.setattr(coll_routes, "PlexClient", lambda db: stub)
monkeypatch.setattr(coll_routes.plex_sync, "resync_collection", resync) monkeypatch.setattr(coll_routes.plex_sync, "resync_collection", resync)
override_dep(get_db, lambda: db) override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: user) override_dep(current_user, lambda: user)
override_dep(admin_user, lambda: user) override_dep(admin_user, lambda: user)
return TestClient(app) return TestClient(app)
+1 -1
View File
@@ -193,7 +193,7 @@ def test_enrich_people_batch_persists_detail(db):
def _get(db, user, monkeypatch, override_dep, path): def _get(db, user, monkeypatch, override_dep, path):
monkeypatch.setattr(state, "_configured_cache", True) # pass the request gate monkeypatch.setattr(state, "_configured_cache", True) # pass the request gate
override_dep(get_db, lambda: db) override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: user) override_dep(current_user, lambda: user)
return TestClient(app).get(path) return TestClient(app).get(path)
+1 -1
View File
@@ -122,7 +122,7 @@ def _seed(db):
def _get(db, user, monkeypatch, override_dep, path): def _get(db, user, monkeypatch, override_dep, path):
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
override_dep(get_db, lambda: db) override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: user) override_dep(current_user, lambda: user)
client = TestClient(app) # no context manager: skip lifespan client = TestClient(app) # no context manager: skip lifespan
return client.get(path) return client.get(path)
+2 -2
View File
@@ -33,7 +33,7 @@ def test_facets_returns_studio_vocabulary(db, monkeypatch, override_dep):
db.add(user) db.add(user)
db.commit() db.commit()
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
override_dep(get_db, lambda: db) override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: user) override_dep(current_user, lambda: user)
resp = TestClient(app).get("/api/plex/facets?scope=movie") resp = TestClient(app).get("/api/plex/facets?scope=movie")
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
@@ -58,7 +58,7 @@ def test_facets_survives_json_null_genres(db, monkeypatch, override_dep):
db.execute(text("UPDATE plex_shows SET genres = 'null'::jsonb WHERE rating_key = 's1'")) db.execute(text("UPDATE plex_shows SET genres = 'null'::jsonb WHERE rating_key = 's1'"))
db.commit() db.commit()
override_dep(get_db, lambda: db) override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: user) override_dep(current_user, lambda: user)
client = TestClient(app) # no context-manager: skip lifespan (scheduler/HLS sweep) client = TestClient(app) # no context-manager: skip lifespan (scheduler/HLS sweep)
resp = client.get("/api/plex/facets?scope=show") resp = client.get("/api/plex/facets?scope=show")
@@ -102,7 +102,7 @@ def _seed(db):
def _get(db, user, monkeypatch, override_dep, path): def _get(db, user, monkeypatch, override_dep, path):
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
override_dep(get_db, lambda: db) override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: user) override_dep(current_user, lambda: user)
return TestClient(app).get(path) return TestClient(app).get(path)
+1 -1
View File
@@ -43,7 +43,7 @@ def _seed(db):
def _get(db, user, monkeypatch, override_dep, path): def _get(db, user, monkeypatch, override_dep, path):
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
override_dep(get_db, lambda: db) override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: user) override_dep(current_user, lambda: user)
return TestClient(app).get(path) return TestClient(app).get(path)
+1 -1
View File
@@ -60,7 +60,7 @@ def _get(db, user, monkeypatch, override_dep, path):
monkeypatch.setattr( monkeypatch.setattr(
state, "_configured_cache", True state, "_configured_cache", True
) # the request gate 503s an unconfigured app ) # the request gate 503s an unconfigured app
override_dep(get_db, lambda: db) override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: user) override_dep(current_user, lambda: user)
client = TestClient(app) # no context manager: skip lifespan client = TestClient(app) # no context manager: skip lifespan
return client.get(path) return client.get(path)
+1 -1
View File
@@ -113,7 +113,7 @@ def _seed(db):
def _get(db, user, monkeypatch, override_dep, path): def _get(db, user, monkeypatch, override_dep, path):
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
override_dep(get_db, lambda: db) override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: user) override_dep(current_user, lambda: user)
return TestClient(app).get(path) return TestClient(app).get(path)
+1 -1
View File
@@ -27,7 +27,7 @@ NAME = "Robert De Niro"
def _get(db, user, monkeypatch, override_dep, path): def _get(db, user, monkeypatch, override_dep, path):
monkeypatch.setattr(state, "_configured_cache", True) # pass the request gate monkeypatch.setattr(state, "_configured_cache", True) # pass the request gate
override_dep(get_db, lambda: db) override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: user) override_dep(current_user, lambda: user)
return TestClient(app).get(path) # no lifespan (skip scheduler/HLS sweep) return TestClient(app).get(path) # no lifespan (skip scheduler/HLS sweep)
+1 -1
View File
@@ -170,7 +170,7 @@ def test_enrich_person_noop_without_tmdb_id(db, monkeypatch):
def _get(db, user, monkeypatch, override_dep, path): def _get(db, user, monkeypatch, override_dep, path):
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
override_dep(get_db, lambda: db) override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: user) override_dep(current_user, lambda: user)
return TestClient(app).get(path) return TestClient(app).get(path)
+1 -1
View File
@@ -349,7 +349,7 @@ def _seed_classes(db):
def _client(db, user, monkeypatch, override_dep): def _client(db, user, monkeypatch, override_dep):
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
override_dep(get_db, lambda: db) override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: user) override_dep(current_user, lambda: user)
return TestClient(app) return TestClient(app)
+1 -1
View File
@@ -67,7 +67,7 @@ def _post(db, user, monkeypatch, override_dep, rating_key, *, captured):
) )
monkeypatch.setattr(plex_stream, "start_session", fake_start) monkeypatch.setattr(plex_stream, "start_session", fake_start)
override_dep(get_db, lambda: db) override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: user) override_dep(current_user, lambda: user)
return TestClient(app).post(f"/api/plex/stream/{rating_key}/session") return TestClient(app).post(f"/api/plex/stream/{rating_key}/session")
+1 -1
View File
@@ -100,7 +100,7 @@ def _request(db, user, monkeypatch, override_dep, method, path, json=None):
monkeypatch.setattr( monkeypatch.setattr(
state, "_configured_cache", True state, "_configured_cache", True
) # the request gate 503s an unconfigured app ) # the request gate 503s an unconfigured app
override_dep(get_db, lambda: db) override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: user) override_dep(current_user, lambda: user)
client = TestClient(app) # no context manager: skip lifespan (scheduler/HLS sweep) client = TestClient(app) # no context manager: skip lifespan (scheduler/HLS sweep)
return client.request(method, path, json=json) return client.request(method, path, json=json)
+1 -1
View File
@@ -28,7 +28,7 @@ def test_a_suspended_account_cannot_drive_the_subtitle_extractor(
db, make_user, override_dep, monkeypatch, tmp_path db, make_user, override_dep, monkeypatch, tmp_path
): ):
user = make_user(is_suspended=True) user = make_user(is_suspended=True)
override_dep(get_db, lambda: db) override_dep(get_db, db.info["request"])
monkeypatch.setattr(app_state, "_configured_cache", True) monkeypatch.setattr(app_state, "_configured_cache", True)
# A validly SIGNED cookie for that account — which is all the old gate looked at. # A validly SIGNED cookie for that account — which is all the old gate looked at.
for mod in (auth, playback): for mod in (auth, playback):
+1 -1
View File
@@ -15,7 +15,7 @@ def _client(db, user, monkeypatch, override_dep):
monkeypatch.setattr( monkeypatch.setattr(
state, "_configured_cache", True state, "_configured_cache", True
) # the request gate 503s an unconfigured app ) # the request gate 503s an unconfigured app
override_dep(get_db, lambda: db) override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: user) # require_human resolves through this override_dep(current_user, lambda: user) # require_human resolves through this
return TestClient(app) return TestClient(app)
+1 -1
View File
@@ -145,7 +145,7 @@ def test_search_youtube_route_returns_422_when_ingest_fails(db, monkeypatch, ove
monkeypatch.setattr(search_mod, "_ingest_candidates", _boom) monkeypatch.setattr(search_mod, "_ingest_candidates", _boom)
override_dep(get_db, lambda: db) override_dep(get_db, db.info["request"])
override_dep(require_human, lambda: user) override_dep(require_human, lambda: user)
resp = TestClient(app).get("/api/search/youtube?q=test+term") resp = TestClient(app).get("/api/search/youtube?q=test+term")