diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 268bd229..823ae94d 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -18,10 +18,12 @@ import shutil import pytest 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 import app.models # noqa: F401 — register every model on Base.metadata +import app.state as app_state from app import security from app.auth import current_user from app.config import settings @@ -29,6 +31,9 @@ from app.db import Base, SessionLocal, engine, get_db from app.main import app from app.models import User +# Sentinel for "this dependency had no override before the fixture set one" (see override_dep). +_MISSING = object() + # The compose test lane sets this so a would-be silent skip (no DB, or no ffmpeg) becomes a hard error # — the gate must not print green while the DB-backed / ffmpeg tests were quietly not run. _REQUIRE_FULL = os.getenv("SIFTLODE_TEST_REQUIRE_DB") == "1" @@ -124,28 +129,62 @@ def make_user(db): @pytest.fixture -def client(db): +def override_dep(): + """Set app.dependency_overrides for the duration of a test and restore the PRIOR value per key on + teardown — instead of a blanket `app.dependency_overrides.clear()`, which wipes every override and + stops fixtures composing (one fixture's teardown clobbering another's).""" + saved: dict = {} + + def _set(dep, override): + if dep not in saved: # remember the pre-fixture value the FIRST time we touch this key + saved[dep] = app.dependency_overrides.get(dep, _MISSING) + app.dependency_overrides[dep] = override + + yield _set + for dep, prev in saved.items(): + if prev is _MISSING: + app.dependency_overrides.pop(dep, None) + else: + app.dependency_overrides[dep] = prev + + +@pytest.fixture +def _configured(monkeypatch): + """The setup gate 503s /auth and /api until the wizard has run; the request-issuing fixtures below + are all post-setup. Baked into client/as_user so no individual test has to remember it.""" + monkeypatch.setattr(app_state, "_configured_cache", True) + + +@pytest.fixture +def client(db, override_dep, _configured): """A TestClient wired to the test DB that goes through the REAL auth/session stack — no `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).""" - app.dependency_overrides[get_db] = lambda: db - try: - yield TestClient(app) - finally: - app.dependency_overrides.clear() + override_dep(get_db, lambda: db) + return TestClient(app) @pytest.fixture -def as_user(db): - """Factory -> a TestClient already authenticated as a given User (via a `current_user` override), - for tests that need an authed request but aren't exercising the login flow itself.""" - def _as(user: User) -> TestClient: - app.dependency_overrides[get_db] = lambda: db - app.dependency_overrides[current_user] = lambda: user - return TestClient(app) +def as_user(db, override_dep, _configured): + """Factory -> a TestClient authenticated as a given User (bypassing login), for tests that need an + authed request but aren't exercising the login flow itself. Multiple users in ONE test are isolated: + the shared `current_user` override resolves the caller from an `x-test-user` header, and each + returned client tags its own requests with it — so `as_user(a)` and `as_user(b)` don't collide on + the process-global override the way a plain `lambda: user` would.""" + registry: dict[int, User] = {} - try: - yield _as - finally: - app.dependency_overrides.clear() + def _resolver(request: Request) -> User: + raw = request.headers.get("x-test-user") + if raw is None or (uid := int(raw)) not in registry: + raise HTTPException(status_code=401, detail="Not authenticated") + return registry[uid] + + override_dep(get_db, lambda: db) + override_dep(current_user, _resolver) + + def _as(user: User) -> TestClient: + registry[user.id] = user + return TestClient(app, headers={"x-test-user": str(user.id)}) + + return _as diff --git a/backend/tests/test_auth_session.py b/backend/tests/test_auth_session.py index 57a80fc0..0b05464f 100644 --- a/backend/tests/test_auth_session.py +++ b/backend/tests/test_auth_session.py @@ -11,20 +11,14 @@ Note: `/api/me` is the bootstrap probe — it returns 200 `{authenticated: False """ from datetime import timedelta -import pytest from fastapi.testclient import TestClient -import app.state as state from app.auth import ACTIVE_ACCOUNT_HEADER from app.main import app _PW = "correct horse battery" - -@pytest.fixture(autouse=True) -def _configured(monkeypatch): - # The setup gate 503s /auth and /api until the wizard has run; every test here is post-setup. - monkeypatch.setattr(state, "_configured_cache", True) +# The `client`/`as_user` fixtures bake in the post-setup `_configured` state, so no per-test patch here. def _login(client, email, password): diff --git a/backend/tests/test_feed_keyset.py b/backend/tests/test_feed_keyset.py index e44e5a01..44b62d4b 100644 --- a/backend/tests/test_feed_keyset.py +++ b/backend/tests/test_feed_keyset.py @@ -15,7 +15,6 @@ from datetime import datetime, timezone import pytest -import app.state as state from app.models import Channel, Subscription, User, Video # Every sort feedquery.sort_keys defines except "relevance" (which needs a search query to rank). @@ -74,8 +73,7 @@ def _page(client, sort, cursor): return resp.json() -def test_feed_surfaces_the_whole_seed(db, as_user, monkeypatch): - monkeypatch.setattr(state, "_configured_cache", True) # the request gate 503s an unconfigured app +def test_feed_surfaces_the_whole_seed(db, as_user): user, ids = _seed(db) body = as_user(user).get("/api/feed?limit=50").json() assert {it["id"] for it in body["items"]} == set(ids) @@ -83,13 +81,15 @@ def test_feed_surfaces_the_whole_seed(db, as_user, monkeypatch): @pytest.mark.parametrize("sort", _SORTS) -def test_cursor_walk_matches_the_unpaged_order(db, as_user, monkeypatch, sort): - monkeypatch.setattr(state, "_configured_cache", True) - user, _ = _seed(db) +def test_cursor_walk_matches_the_unpaged_order(db, as_user, sort): + user, ids = _seed(db) client = as_user(user) # Ground truth: the full order in one query (no cursor). full = [it["id"] for it in client.get(f"/api/feed?sort={sort}&limit=50").json()["items"]] + # Guard against a vacuous pass: if seeding ever stops surfacing the videos, `walked == full` would + # be [] == [] for every sort and never exercise the cursor. All 7 rows must be present. + assert len(full) == len(ids), f"{sort}: seed did not surface all rows — the walk would be vacuous" # Walk it two at a time via the cursor and reassemble. walked: list[str] = []