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:
@@ -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:
|
||||
|
||||
@@ -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).
|
||||
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)
|
||||
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()
|
||||
|
||||
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)
|
||||
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()
|
||||
|
||||
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)
|
||||
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.
|
||||
monkeypatch.setattr(channels_route, "backfill_channel_now", lambda *a, **k: calls.append(a))
|
||||
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)
|
||||
resp = TestClient(app).patch("/api/channels/UC_wire", json={"deep_requested": True})
|
||||
return resp, calls
|
||||
|
||||
@@ -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).
|
||||
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)
|
||||
client = TestClient(app) # no context-manager: skip lifespan (scheduler/HLS sweep)
|
||||
resp = client.get(f"/api/channels/{ch.id}")
|
||||
|
||||
@@ -19,7 +19,7 @@ def _get(db, user, monkeypatch, override_dep, path):
|
||||
monkeypatch.setattr(
|
||||
state, "_configured_cache", True
|
||||
) # 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)
|
||||
client = TestClient(app) # no context manager: skip lifespan (scheduler/HLS sweep)
|
||||
return client.get(path)
|
||||
|
||||
@@ -143,7 +143,7 @@ def _library_item(db, rating_key: str, *, playable: str = "remux"):
|
||||
|
||||
|
||||
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)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@@ -21,3 +21,17 @@ def test_no_test_writes_dependency_overrides_directly():
|
||||
if "dependency_overrides" in line
|
||||
]
|
||||
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)
|
||||
|
||||
@@ -52,7 +52,7 @@ def _clear(db, user, it, monkeypatch, override_dep) -> list[tuple]:
|
||||
"push_state_to_plex",
|
||||
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)
|
||||
resp = TestClient(app).post(f"/api/plex/item/{it.rating_key}/state", json={"status": "new"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
@@ -233,7 +233,7 @@ def _seed(db):
|
||||
|
||||
def _get(db, user, monkeypatch, override_dep, path):
|
||||
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)
|
||||
return TestClient(app).get(path)
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ def _seed(db):
|
||||
|
||||
def _get(db, user, monkeypatch, override_dep, path):
|
||||
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)
|
||||
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")
|
||||
db.add(user)
|
||||
db.commit()
|
||||
override_dep(get_db, lambda: db)
|
||||
override_dep(get_db, db.info["request"])
|
||||
override_dep(current_user, lambda: user)
|
||||
override_dep(admin_user, lambda: user)
|
||||
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(coll_routes, "PlexClient", lambda db: stub)
|
||||
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(admin_user, lambda: user)
|
||||
return TestClient(app)
|
||||
|
||||
@@ -193,7 +193,7 @@ def test_enrich_people_batch_persists_detail(db):
|
||||
|
||||
def _get(db, user, monkeypatch, override_dep, path):
|
||||
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)
|
||||
return TestClient(app).get(path)
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ def _seed(db):
|
||||
|
||||
def _get(db, user, monkeypatch, override_dep, path):
|
||||
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)
|
||||
client = TestClient(app) # no context manager: skip lifespan
|
||||
return client.get(path)
|
||||
|
||||
@@ -33,7 +33,7 @@ def test_facets_returns_studio_vocabulary(db, monkeypatch, override_dep):
|
||||
db.add(user)
|
||||
db.commit()
|
||||
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)
|
||||
resp = TestClient(app).get("/api/plex/facets?scope=movie")
|
||||
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.commit()
|
||||
|
||||
override_dep(get_db, lambda: db)
|
||||
override_dep(get_db, db.info["request"])
|
||||
override_dep(current_user, lambda: user)
|
||||
client = TestClient(app) # no context-manager: skip lifespan (scheduler/HLS sweep)
|
||||
resp = client.get("/api/plex/facets?scope=show")
|
||||
|
||||
@@ -102,7 +102,7 @@ def _seed(db):
|
||||
|
||||
def _get(db, user, monkeypatch, override_dep, path):
|
||||
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)
|
||||
return TestClient(app).get(path)
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ def _seed(db):
|
||||
|
||||
def _get(db, user, monkeypatch, override_dep, path):
|
||||
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)
|
||||
return TestClient(app).get(path)
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ def _get(db, user, monkeypatch, override_dep, path):
|
||||
monkeypatch.setattr(
|
||||
state, "_configured_cache", True
|
||||
) # 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)
|
||||
client = TestClient(app) # no context manager: skip lifespan
|
||||
return client.get(path)
|
||||
|
||||
@@ -113,7 +113,7 @@ def _seed(db):
|
||||
|
||||
def _get(db, user, monkeypatch, override_dep, path):
|
||||
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)
|
||||
return TestClient(app).get(path)
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ NAME = "Robert De Niro"
|
||||
|
||||
def _get(db, user, monkeypatch, override_dep, path):
|
||||
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)
|
||||
return TestClient(app).get(path) # no lifespan (skip scheduler/HLS sweep)
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ def test_enrich_person_noop_without_tmdb_id(db, monkeypatch):
|
||||
|
||||
def _get(db, user, monkeypatch, override_dep, path):
|
||||
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)
|
||||
return TestClient(app).get(path)
|
||||
|
||||
|
||||
@@ -349,7 +349,7 @@ def _seed_classes(db):
|
||||
|
||||
def _client(db, user, monkeypatch, override_dep):
|
||||
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)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ def _post(db, user, monkeypatch, override_dep, rating_key, *, captured):
|
||||
)
|
||||
|
||||
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)
|
||||
return TestClient(app).post(f"/api/plex/stream/{rating_key}/session")
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ def _request(db, user, monkeypatch, override_dep, method, path, json=None):
|
||||
monkeypatch.setattr(
|
||||
state, "_configured_cache", True
|
||||
) # 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)
|
||||
client = TestClient(app) # no context manager: skip lifespan (scheduler/HLS sweep)
|
||||
return client.request(method, path, json=json)
|
||||
|
||||
@@ -28,7 +28,7 @@ def test_a_suspended_account_cannot_drive_the_subtitle_extractor(
|
||||
db, make_user, override_dep, monkeypatch, tmp_path
|
||||
):
|
||||
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)
|
||||
# A validly SIGNED cookie for that account — which is all the old gate looked at.
|
||||
for mod in (auth, playback):
|
||||
|
||||
@@ -15,7 +15,7 @@ def _client(db, user, monkeypatch, override_dep):
|
||||
monkeypatch.setattr(
|
||||
state, "_configured_cache", True
|
||||
) # 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
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ def test_search_youtube_route_returns_422_when_ingest_fails(db, monkeypatch, ove
|
||||
|
||||
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)
|
||||
resp = TestClient(app).get("/api/search/youtube?q=test+term")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user