114 exception handlers neither logged nor re-raised. The defect was never that
they are silent — plenty of them should be — it is that their silence said
nothing, so nobody could tell a decision from an oversight. Each one has now been
read. 44 got a line; the other 70 carry `# silent-ok: <why>`.
What the silent ones had been hiding, in the cases that got a line:
- A failed ffprobe sent every HDR file down the 8-bit pipeline, so playback
degraded to a software transcode with nothing saying why; a failed audio probe
collapsed a multi-audio file to one track and the player showed no language
picker; a failed first-PTS probe left subtitles seconds out of sync.
- A subtitle sidecar that existed but would not parse made a PARTIAL track read as
complete, permanently — the viewer loses every cue past the cached window.
- An unreadable crop/trim/cut-list in an edit spec was dropped, so the clip came
out uncropped, untrimmed, or shorter than the one the user built.
- A dropped `max_height` downloaded at best quality against an explicit cap.
- A failed videos.list enrich made every candidate undroppable-from-judgement and
therefore dropped: a working search rendered as "no results".
- An unusable password hash answered as "wrong password", locking the owner out
and telling them they mistyped; a rotated TOKEN_ENCRYPTION_KEY turned every
stored token into "not connected".
- A stored admin setting that would not coerce fell back to the env default while
the settings page went on showing the saved value.
- A one-off InnerTube config failure pinned the whole process to the hard-coded
fallback key, and searches return nothing when that goes stale.
- The HLS sweep reported 0 swept — like a clean run — when it could not list its
own root, leaving every crashed session's segments on disk forever.
Four of them are counted rather than logged per item (dropped cues, dropped cut
list segments, unreclaimed GC files) and reported once per call, because the
alternative is one line per cue over a whole subtitle track.
ruff gains S110 and BLE001. Every blind `except Exception` that remains carries
`# noqa: BLE001 — <why>`, which is the point: catching everything is sometimes
right, and always worth a sentence. plex/sync.py's three best-effort backfill
catches now keep the traceback — a blind catch over a batch that fetches AND
writes could be Plex, the database or our own bug, and `%s` of the exception told
them apart badly. main.py's boot-time log-level read likewise.
logs.event's `code` is positional-only now: `event("x", code=...)` was a TypeError,
and `code` is exactly the word a caller reaches for when the thing that failed has
one of its own.
tests/test_swallowed_exceptions.py is the wider rule ruff cannot see: a handler
that neither logs nor re-raises must say why. Both cases mutation-checked — a
marker removed and a log line removed each fail it, naming the handler.
231 lines
9.7 KiB
Python
231 lines
9.7 KiB
Python
"""Pytest fixtures for the DB-backed integration lane (R6 S3b).
|
|
|
|
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
|
|
|
|
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
|
|
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"
|
|
|
|
|
|
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 SIFTLODE_TEST_REQUIRE_DB=1 but the environment can't
|
|
actually run the full suite. 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(
|
|
"SIFTLODE_TEST_REQUIRE_DB=1 but no Postgres is reachable at the test DATABASE_URL — the "
|
|
"DB-backed lane would silently skip. Fix the DB wiring or unset the flag."
|
|
)
|
|
if shutil.which("ffmpeg") is None:
|
|
raise pytest.UsageError(
|
|
"SIFTLODE_TEST_REQUIRE_DB=1 but ffmpeg is missing — the thumbnail/poster tests would skip."
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def db(_schema):
|
|
"""A real session against the test Postgres, on a clean (empty) database. The code under test
|
|
may commit freely."""
|
|
_truncate_all()
|
|
session = SessionLocal()
|
|
try:
|
|
yield session
|
|
finally:
|
|
session.rollback()
|
|
session.close()
|
|
|
|
|
|
@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 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)
|
|
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]
|
|
|
|
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
|
|
|
|
|
|
@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()
|