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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user