- An ad-hoc request made while its target's run is under way is the run after it: the scheduler dropped a second fire of a running id, so a second Takeout file was never imported and its key stayed queued until a restart. - Un-marking or resetting a video is remembered (video_unmarks): an import of an older YouTube view no longer marks it watched again; un-marks take the user's Takeout lock. - One user's failure in the Shorts job's Takeout pass is theirs: the job keeps its own result, the others are settled, and a failed lookup spends none of the daily share. - The upload reads the user's id before giving the read connection back. - A watched row waits on a discovery video (search or explore) until the catalog keeps it, so a mark never pins it against the cleanup jobs. - The history is decoded a window at a time, a Watch later file over 2 MiB is refused unread, and two uploads at once across all users (takeout_server_busy). - A Watch later list goes in as one presence read and one insert.
342 lines
15 KiB
Python
342 lines
15 KiB
Python
"""Pytest fixtures for the DB-backed integration lane.
|
|
|
|
The bulk of the suite is pure-logic and needs no database. This adds an OPT-IN `db` fixture
|
|
that hands a test a real session against a throwaway Postgres (the app's own engine, bound to
|
|
the test `DATABASE_URL`). When no database is reachable — the default `siftlode check` still
|
|
runs the pure-logic tests with no Postgres — any test that asks for `db` is SKIPPED, so the
|
|
pure-logic lane is unaffected.
|
|
|
|
Schema comes from `alembic upgrade head` — not `create_all` — because the real schema depends on
|
|
things only migrations set up (the `unaccent` extension + the `public.unaccent_simple` text-search
|
|
config the videos.search_vector generated column needs). Building it via migrations also validates
|
|
that the migration chain applies cleanly. Each test then gets a clean database via TRUNCATE so
|
|
tests that COMMIT (the whole point of this lane — transaction-ownership behaviour) don't leak.
|
|
"""
|
|
|
|
import os
|
|
import shutil
|
|
from urllib.parse import quote
|
|
|
|
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, event, text
|
|
from starlette.requests import HTTPConnection
|
|
|
|
import app.tables # every table on Base.metadata
|
|
import app.state as app_state
|
|
from app import apitokens, security
|
|
from app.auth import current_user
|
|
from app.config import settings
|
|
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()
|
|
|
|
# A would-be silent skip (no DB, or no ffmpeg) is a hard error unless the run says otherwise — the
|
|
# suite must not print green while the DB-backed / ffmpeg tests were quietly not run. ON by default:
|
|
# as an opt-in only the compose lane set, a hand-run `pytest` skipped ~60 DB tests
|
|
# and still passed. `SIFTLODE_TEST_REQUIRE_DB=0` is the explicit "pure-logic only" run.
|
|
_REQUIRE_FULL = os.getenv("SIFTLODE_TEST_REQUIRE_DB", "1") != "0"
|
|
|
|
|
|
def _db_reachable() -> bool:
|
|
# A throwaway engine with a bounded connect_timeout so a slow/firewalled DATABASE_URL can't
|
|
# hang the whole suite at import (the app engine has no connect timeout). The compose lane
|
|
# gates on `service_healthy`, but this keeps a manual run honest too.
|
|
probe = create_engine(settings.database_url, connect_args={"connect_timeout": 3})
|
|
try:
|
|
with probe.connect() as conn:
|
|
conn.execute(text("SELECT 1"))
|
|
return True
|
|
# silent-ok / noqa BLE001: the question is literally "can anything reach it", so every
|
|
# failure is the same answer — and this runs at import, before logging is configured.
|
|
except Exception: # noqa: BLE001
|
|
return False
|
|
finally:
|
|
probe.dispose()
|
|
|
|
|
|
_DB_REACHABLE = _db_reachable()
|
|
|
|
# No background log writer in tests: every TestClient would otherwise start one on app startup and
|
|
# write into tables the fixtures truncate between tests. The sink's own tests drive it explicitly.
|
|
settings.log_db_sink = False
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def _schema():
|
|
if not _DB_REACHABLE:
|
|
pytest.skip("no test database (set DATABASE_URL to a reachable Postgres)")
|
|
# Build the real prod schema via migrations (alembic env.py reads settings.database_url, i.e.
|
|
# the test DATABASE_URL). Idempotent — a re-run against an already-migrated DB is a no-op.
|
|
command.upgrade(Config("alembic.ini"), "head")
|
|
yield
|
|
|
|
|
|
def _truncate_all():
|
|
# RESTART IDENTITY so serial PKs are deterministic per test; CASCADE handles FKs. Truncating
|
|
# BEFORE each test (not after) means every test — including the first, which would otherwise see
|
|
# migration-seeded rows — starts from the same empty slate and seeds only what it needs.
|
|
tables = ", ".join(t.name for t in Base.metadata.sorted_tables)
|
|
with engine.begin() as conn:
|
|
conn.execute(text(f"TRUNCATE {tables} RESTART IDENTITY CASCADE"))
|
|
|
|
|
|
def pytest_configure(config):
|
|
"""Fail the whole run — loudly, once — when the full suite is required (the default) but the
|
|
environment can't actually run it. Without this, an unreachable DATABASE_URL or a missing ffmpeg turns
|
|
~60 DB tests + the poster tests into silent skips and the gate still exits green."""
|
|
if not _REQUIRE_FULL:
|
|
return
|
|
if not _DB_REACHABLE:
|
|
raise pytest.UsageError(
|
|
"no Postgres is reachable at the test DATABASE_URL — the DB-backed lane would silently "
|
|
"skip. Fix the DB wiring, or run the pure-logic tests only with SIFTLODE_TEST_REQUIRE_DB=0."
|
|
)
|
|
if shutil.which("ffmpeg") is None:
|
|
raise pytest.UsageError(
|
|
"ffmpeg is missing — the thumbnail/poster tests would skip. Install it, or run the "
|
|
"pure-logic tests only with SIFTLODE_TEST_REQUIRE_DB=0."
|
|
)
|
|
|
|
|
|
def _write_xact(db) -> str | None:
|
|
"""The id of the open transaction if it has written anything, else None (a read never takes one)."""
|
|
return db.scalar(text("SELECT pg_current_xact_id_if_assigned()::text"))
|
|
|
|
|
|
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 write it did not commit — flushed, or still only staged — is
|
|
recorded, and the test fails on it at teardown. Only the ROOT transaction's end clears it: a
|
|
savepoint released inside the handler commits nothing. What the test itself staged or wrote is
|
|
committed before the request starts — it is the test's business, not the handler's, and a
|
|
request then begins with no write open, so any write left at its end is the handler's. A request that raises is rolled back, as production's
|
|
`close()` rolls it back: without that, what a failing handler flushed but never committed stayed
|
|
readable to the rest of the test — the one state production cannot be in (an issue created
|
|
on the tracker before a failed upload was kept only by an explicit commit, and nothing saw it
|
|
missing)."""
|
|
|
|
def dep(conn: HTTPConnection):
|
|
db.commit()
|
|
state = {"dirty": False}
|
|
|
|
def flushed(session, ctx):
|
|
state["dirty"] = True
|
|
|
|
def ended(session, transaction):
|
|
if transaction.parent is None:
|
|
state["dirty"] = False
|
|
|
|
hooks = (("after_flush", flushed), ("after_transaction_end", ended))
|
|
for name, fn in hooks:
|
|
event.listen(db, name, fn)
|
|
try:
|
|
yield db
|
|
staged = db.new or db.deleted or any(db.is_modified(o) for o in db.dirty)
|
|
# Core DML (`db.execute(update(...))`) fires no flush and stages nothing; the database
|
|
# itself says whether a write is open — the request began with none, and a transaction
|
|
# that wrote and was not committed holds an id (see `release_read_connection`). A row
|
|
# lock (`FOR UPDATE`) takes one too: the handler ends what it locked.
|
|
core_dml = _write_xact(db) is not None
|
|
if state["dirty"] or staged or core_dml:
|
|
unfinished.append(f"{conn.scope.get('method', 'WS')} {conn.url.path}")
|
|
except Exception:
|
|
db.rollback()
|
|
raise
|
|
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. `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)
|
|
session.info["unfinished"] = unfinished # the requests recorded so far (the check's own test)
|
|
try:
|
|
yield session
|
|
finally:
|
|
session.rollback()
|
|
session.close()
|
|
assert unfinished == [], "a handler left a write uncommitted"
|
|
|
|
|
|
@pytest.fixture
|
|
def make_user(db):
|
|
"""Factory: create + commit a User in the test DB. Defaults produce a login-ready account (email
|
|
verified, active, with a known password); override any of it via kwargs (e.g. `role="admin"`,
|
|
`is_suspended=True`, `password=None` for a Google-only account). Returns the User."""
|
|
seq = {"n": 0}
|
|
|
|
def _make(email: str | None = None, *, password: str | None = "correct horse battery", **kw):
|
|
seq["n"] += 1
|
|
kw.setdefault("email_verified", True)
|
|
user = User(
|
|
email=email or f"user{seq['n']}@example.com",
|
|
password_hash=security.hash_password(password) if password else None,
|
|
**kw,
|
|
)
|
|
db.add(user)
|
|
db.commit()
|
|
return user
|
|
|
|
return _make
|
|
|
|
|
|
@pytest.fixture
|
|
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 one-account session 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, db.info["request"])
|
|
return TestClient(app)
|
|
|
|
|
|
@pytest.fixture
|
|
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] = {}
|
|
|
|
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]
|
|
|
|
def _session_or_token(request: Request) -> User | None:
|
|
# The Files routes' session half (`apitokens.session_caller`): a request carrying a token is
|
|
# left to the real token check, exactly as in production.
|
|
if apitokens.bearer_token(request) is not None:
|
|
return None
|
|
return _resolver(request)
|
|
|
|
override_dep(get_db, db.info["request"])
|
|
override_dep(current_user, _resolver)
|
|
override_dep(apitokens.session_caller, _session_or_token)
|
|
|
|
def _as(user: User) -> TestClient:
|
|
registry[user.id] = user
|
|
return TestClient(app, headers={"x-test-user": str(user.id)})
|
|
|
|
return _as
|
|
|
|
|
|
@pytest.fixture
|
|
def upload_headers():
|
|
"""Build the headers the upload routes take (the file is the raw body).
|
|
|
|
Shared, because two test modules drive that route and a second copy of this is exactly the drift
|
|
that lets the wire shape change in one place only. The body IS the file now; the names ride as
|
|
percent-encoded headers (`quote` mirrors the client's `encodeURIComponent`, since headers are
|
|
latin-1 and a Hungarian filename would not survive raw)."""
|
|
|
|
def _headers(
|
|
filename: str, display_name: str | None = None, folder: int | str | None = None
|
|
) -> dict[str, str]:
|
|
headers = {
|
|
"content-type": "application/octet-stream",
|
|
"x-upload-filename": quote(filename),
|
|
}
|
|
if display_name is not None:
|
|
headers["x-upload-display-name"] = quote(display_name)
|
|
if folder is not None:
|
|
headers["x-upload-folder"] = str(folder)
|
|
return headers
|
|
|
|
return _headers
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_rate_limiters():
|
|
"""Every RateLimiter is a module-level singleton keyed by client IP, and TestClient always
|
|
presents the same host — so without this the whole suite's login/register attempts pool into one
|
|
300s window and, past the cap, some unrelated test starts failing with a 429 depending on order.
|
|
Cleared before each test; a test that wants to exercise throttling can still exhaust one itself.
|
|
|
|
Sweeps every loaded `app.*` module rather than a hand-kept list, so a limiter added in a new
|
|
module is covered the day it lands instead of silently pooling until something fails."""
|
|
import sys
|
|
|
|
from app.ratelimit import RateLimiter
|
|
|
|
for name, mod in list(sys.modules.items()):
|
|
if name != "app" and not name.startswith("app."):
|
|
continue
|
|
for obj in vars(mod).values():
|
|
if isinstance(obj, RateLimiter):
|
|
obj.reset()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_log_erasures():
|
|
"""`logs._erased` is module-level and remembers an erased account for an hour of monotonic time
|
|
— longer than any suite run — while the `db` fixture hands serial ids back out from 1 via
|
|
TRUNCATE … RESTART IDENTITY. So a test that purges user 1 would otherwise make a LATER test's
|
|
rows for user 1 store with `user_id = None`, failing somewhere nowhere near the cause and only
|
|
in some file orders. Same reasoning as the limiter sweep above, different global."""
|
|
from app import logs
|
|
|
|
logs._erased.clear()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_ad_hoc_queue():
|
|
"""The scheduler's ad-hoc queue is module-level too: a request a test left pending would read as
|
|
a later test's user's import still queued (ids restart from 1)."""
|
|
from app import scheduler
|
|
|
|
with scheduler._queued_guard:
|
|
scheduler._queued.clear()
|
|
scheduler._pending.clear()
|
|
scheduler._running.clear()
|