test(infra): fail-loud DB/ffmpeg lane + client/as_user/make_user fixtures (E1 S2, TEST-4)

The DB-backed lane and the two ffmpeg tests skipped SILENTLY when the DB was
unreachable or ffmpeg was absent, so `siftlode check` could print green while
~60 tests never ran. Make the compose lane authoritative:
- SIFTLODE_TEST_REQUIRE_DB=1 in docker-compose.test.yml; conftest.pytest_configure
  turns a would-be skip (no Postgres, or no ffmpeg) into a hard UsageError.
- install ffmpeg in Dockerfile.test so the thumbnail/poster tests actually run.
- `-rs` in pytest.ini so skip reasons are always visible.

Add the shared TestClient fixtures the risk-core suites build on: `make_user`
(create + commit a login-ready User), `client` (real auth/session stack, no
current_user override — for login/wallet/epoch), and `as_user` (a client
pre-authenticated as a given user, for tests that aren't exercising login).
This commit is contained in:
2026-08-18 01:23:06 +02:00
parent 0653203d81
commit 15a59f4695
4 changed files with 93 additions and 3 deletions
+6
View File
@@ -8,5 +8,11 @@
# docker run --rm -v "<repo>/backend:/app" -w /app siftlode-test:local python -m pytest # docker run --rm -v "<repo>/backend:/app" -w /app siftlode-test:local python -m pytest
FROM python:3.12-slim FROM python:3.12-slim
WORKDIR /app WORKDIR /app
# ffmpeg so the thumbnail/poster tests (needs_ffmpeg) actually run in the gate instead of skipping
# silently — the runtime image already ships ffmpeg, so the test image should too (see conftest's
# SIFTLODE_TEST_REQUIRE_DB, which now also refuses to run if ffmpeg is missing).
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt requirements-dev.txt ./ COPY requirements.txt requirements-dev.txt ./
RUN pip install --no-cache-dir -r requirements.txt -r requirements-dev.txt RUN pip install --no-cache-dir -r requirements.txt -r requirements-dev.txt
+3
View File
@@ -29,6 +29,9 @@ services:
condition: service_healthy condition: service_healthy
environment: environment:
DATABASE_URL: postgresql+psycopg://siftlode:siftlode@testdb:5432/siftlode_test DATABASE_URL: postgresql+psycopg://siftlode:siftlode@testdb:5432/siftlode_test
# This lane HAS a Postgres + ffmpeg, so the DB/ffmpeg tests must actually run here: conftest turns
# a would-be silent skip into a hard failure (SIFTLODE_TEST_REQUIRE_DB).
SIFTLODE_TEST_REQUIRE_DB: "1"
volumes: volumes:
- ./:/app - ./:/app
working_dir: /app working_dir: /app
+4 -2
View File
@@ -1,6 +1,8 @@
[pytest] [pytest]
# Run from backend/ (the image's /app). `pythonpath = .` puts the app package on sys.path so tests # Run from backend/ (the image's /app). `pythonpath = .` puts the app package on sys.path so tests
# import it as `app.*`, matching runtime. The suite is pure-logic only (no DB/network) — see tests/. # import it as `app.*`, matching runtime. Most of the suite is pure-logic; the DB-backed lane opts in
# via the `db` fixture and runs in the compose test image. `-rs` reports skip REASONS so a silently
# skipped lane is visible (the compose lane sets SIFTLODE_TEST_REQUIRE_DB=1 to make skips a hard error).
testpaths = tests testpaths = tests
pythonpath = . pythonpath = .
addopts = -q addopts = -q -rs
+80 -1
View File
@@ -12,14 +12,26 @@ config the videos.search_vector generated column needs). Building it via migrati
that the migration chain applies cleanly. Each test then gets a clean database via TRUNCATE so 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. tests that COMMIT (the whole point of this lane — transaction-ownership behaviour) don't leak.
""" """
import os
import shutil
import pytest import pytest
from alembic import command from alembic import command
from alembic.config import Config from alembic.config import Config
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, text from sqlalchemy import create_engine, text
import app.models # noqa: F401 — register every model on Base.metadata import app.models # noqa: F401 — register every model on Base.metadata
from app import security
from app.auth import current_user
from app.config import settings from app.config import settings
from app.db import Base, SessionLocal, engine from app.db import Base, SessionLocal, engine, get_db
from app.main import app
from app.models import User
# 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: def _db_reachable() -> bool:
@@ -59,6 +71,23 @@ def _truncate_all():
conn.execute(text(f"TRUNCATE {tables} RESTART IDENTITY CASCADE")) 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 @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
@@ -70,3 +99,53 @@ def db(_schema):
finally: finally:
session.rollback() session.rollback()
session.close() 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 client(db):
"""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()
@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)
try:
yield _as
finally:
app.dependency_overrides.clear()