Files
FabledCurator/tests/conftest.py
T
bvandeusenandClaude Opus 5 274f7ffe21
CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 3s
extension / lint (push) Successful in 21s
CI and images / frontend-build (push) Successful in 21s
CI and images / backend-lint-and-test (push) Successful in 31s
CI and images / integration (push) Successful in 2m10s
CI and images / sign-extension (push) Successful in 3s
CI and images / build-agent (push) Successful in 6s
CI and images / build-web (push) Successful in 1m51s
CI and images / smoke-web (push) Successful in 57s
CI and images / promote (push) Skipped
ci: the tests gate the publish — ci.yml folds into build.yml
Operator, 2026-09-23: *"tighten the gate so :dev can't publish on red tests"*,
then *"I don't want failing builds to publish anywhere going forward."*

Run 7348 is the worked example. The backend unit lane went red on `2f8f0bc`
and `build-web` pushed `:dev` in the same minute, because the lanes and the
build were SEPARATE WORKFLOWS on the same push trigger. Neither could see the
other's verdict. `:dev` was a "it built" signal, never a "it passed" one, and
nothing about that was visible from either run.

Two workflows cannot express the gate. A `needs:` edge only exists inside one
graph. So `ci.yml`'s five lanes move into `build.yml` and `ci.yml` is deleted;
`sign-extension`, `build-web` and `build-agent` now need all five.

Nothing here is a new mechanism — it is the same edge that has gated `promote`
since milestone 362 step 4, and it keeps that step's hardest-won property:
**not running is not the same as passing.** `needs` treats a SKIPPED
dependency as unsatisfied, so a lane that silently skips itself blocks the
publish exactly as a failing one does. Run 5290 is why that is worth stating.

Scope, said plainly rather than implied:

- Gated: every image tag (`:dev`, `:latest`, `:c-<sha>`), the weekly base
  refresh, and the `ext-<version>` signed-XPI release asset — `sign-extension`
  publishes too, so it is gated with the rest.
- Not gated, deliberately: `extension.yml` publishes nothing, and
  `release.yml` runs on a `v*` tag, generates notes rather than an artifact,
  and its commit already went through main's gated build.
- `pull_request` (Renovate bumps into `dev`) comes across with the lanes. Its
  runs are the lanes and nothing else, via an `if:` on each publishing job
  rather than an inference from the `needs` chain.

The cost, accepted knowingly: this workflow queues per branch and never
cancels, so on two pushes in quick succession the second's lint feedback waits
out the first's build. A slower red beats a fast red that ships.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
2026-09-23 11:15:42 -04:00

195 lines
7.1 KiB
Python

"""Shared pytest fixtures.
The async db fixture provides an AsyncSession bound to a transaction that
gets rolled back after each test. CI provisions a real Postgres + pgvector
(see .forgejo/workflows/build.yml), so tests exercise the actual schema and
migration code paths.
"""
import os
# Audit 2026-06-02: CredentialCrypto now refuses to auto-generate a
# Fernet key without explicit opt-in (production safety against silent
# key regeneration on partial restore). The test environment never has
# a pre-seeded key file, so set the bootstrap flag here before any
# create_app() / CredentialCrypto() import path fires.
os.environ.setdefault("CURATOR_BOOTSTRAP_NEW_KEY", "1")
import pytest # noqa: E402
import pytest_asyncio # noqa: E402
from sqlalchemy import create_engine # noqa: E402
from sqlalchemy.ext.asyncio import ( # noqa: E402
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import sessionmaker # noqa: E402
from backend.app import create_app # noqa: E402
from backend.app.models import Base # noqa: E402
def _async_database_url() -> str:
user = os.environ.get("DB_USER", "fabledcurator")
password = os.environ["DB_PASSWORD"]
host = os.environ.get("DB_HOST", "postgres")
port = os.environ.get("DB_PORT", "5432")
name = os.environ.get("DB_NAME", "fabledcurator_test")
return f"postgresql+asyncpg://{user}:{password}@{host}:{port}/{name}"
def _sync_database_url() -> str:
user = os.environ.get("DB_USER", "fabledcurator")
password = os.environ["DB_PASSWORD"]
host = os.environ.get("DB_HOST", "postgres")
port = os.environ.get("DB_PORT", "5432")
name = os.environ.get("DB_NAME", "fabledcurator_test")
return f"postgresql+psycopg://{user}:{password}@{host}:{port}/{name}"
@pytest_asyncio.fixture
async def engine():
e = create_async_engine(_async_database_url(), future=True)
yield e
await e.dispose()
@pytest_asyncio.fixture
async def db(engine) -> AsyncSession:
Session = async_sessionmaker(engine, expire_on_commit=False)
async with Session() as session:
await session.begin()
try:
yield session
finally:
await session.rollback()
@pytest.fixture
def sync_engine():
e = create_engine(_sync_database_url(), future=True)
yield e
e.dispose()
@pytest.fixture
def db_sync(sync_engine):
"""Synchronous Session bound to a savepoint — used by Importer tests
(the Importer is sync-only by design)."""
SyncSession = sessionmaker(sync_engine, expire_on_commit=False)
with SyncSession() as session:
session.begin()
try:
yield session
finally:
session.rollback()
@pytest_asyncio.fixture
async def app():
return create_app()
@pytest_asyncio.fixture
async def client(app):
async with app.test_client() as c:
yield c
# Migration-seeded baseline (singleton config like import_settings /
# ml_settings), captured lazily at the FIRST integration test's setup —
# when the CI integration job has just run `alembic upgrade head` so the DB
# is pristine. Cached module-wide. Never populated in the DB-less unit job
# because only integration-marked tests trigger the connection.
_SEED_SNAPSHOT: dict[str, list[dict]] | None = None
@pytest.fixture(scope="session")
def _truncate_engine():
"""Session-scoped sync engine reused by the per-test DB-reset teardown.
Creating a fresh engine + Postgres connection for EVERY test's teardown was
the integration suite's dominant cost — `--durations` showed the 15 slowest
operations in both long shards were all ~1.5-2s teardowns, i.e. the
connect+SCRAM handshake, not test logic. A single pooled connection reused
across teardowns cuts that to the TRUNCATE itself. `pool_pre_ping` guards
against Postgres reaping the idle connection mid-suite (a stale-connection
failure would be a nasty flaky bounce; the ping is sub-millisecond).
`create_engine` is lazy — it opens no connection until first `.begin()` —
so the no-DB unit job instantiates this object but never connects.
"""
eng = create_engine(_sync_database_url(), future=True, pool_pre_ping=True)
yield eng
eng.dispose()
@pytest.fixture(autouse=True)
def _reset_db_after_integration(request, _truncate_engine):
"""Integration tests exercise app/celery code that commits on its own
connection, which the rollback fixtures above can't undo — so data
would leak between tests. Capture the seeded baseline at the first
integration test, then after each integration-marked test truncate
every model table and restore that baseline. Touches the DB ONLY for
integration-marked tests, so the no-DB fast unit job is untouched.
Uses the session-scoped `_truncate_engine` (pooled, reused) rather than
building a fresh engine per test — see that fixture's docstring.
"""
global _SEED_SNAPSHOT
is_integration = request.node.get_closest_marker("integration") is not None
if is_integration and _SEED_SNAPSHOT is None:
snap: dict[str, list[dict]] = {}
with _truncate_engine.connect() as conn:
for t in Base.metadata.sorted_tables:
rows = [
dict(m)
for m in conn.execute(t.select()).mappings().all()
]
if rows:
snap[t.name] = rows
_SEED_SNAPSHOT = snap
yield
if not is_integration:
return
tables = ", ".join(t.name for t in Base.metadata.sorted_tables)
if not tables:
return
with _truncate_engine.begin() as conn:
conn.exec_driver_sql(
f"TRUNCATE {tables} RESTART IDENTITY CASCADE"
)
for t in Base.metadata.sorted_tables:
rows = (_SEED_SNAPSHOT or {}).get(t.name)
if rows:
conn.execute(t.insert(), rows)
# Restored rows carry their explicit ids while RESTART
# IDENTITY reset the sequence to 1, so the next ORM insert
# would collide on the PK (bitten by migration 0075's seeded
# system tags: every Tag insert failed with pk_tag id=1).
# Resync any serial id sequence past the restored rows.
if "id" in t.c:
conn.exec_driver_sql(
f"SELECT setval(pg_get_serial_sequence('{t.name}', 'id'), "
f"(SELECT COALESCE(MAX(id), 1) FROM {t.name})) "
f"WHERE pg_get_serial_sequence('{t.name}', 'id') IS NOT NULL"
)
@pytest_asyncio.fixture(autouse=True)
async def _dispose_app_engine(request):
"""The app's shared async engine (extensions.get_engine) is a process
singleton, but pytest-asyncio gives each test a fresh event loop;
asyncpg connections are loop-bound, so reuse across tests raises
'attached to a different loop'. Dispose after each integration test so
the next test rebuilds the engine on its own loop.
"""
yield
if request.node.get_closest_marker("integration") is None:
return
from backend.app import extensions
await extensions.dispose_engine()