CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 1s
CI and images / backend-lint-and-test (push) Successful in 31s
CI and images / frontend-build (push) Successful in 26s
CI and images / extension-test (push) Successful in 20s
CI and images / integration (push) Failing after 2m22s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / build-agent (push) Skipped
pytest 9.2.0 (released 2026-10-27, changelog #10644) changed how monkeypatch.setattr restores an attribute the object does not hold in its own __dict__: undo now deletes it instead of assigning the old value back. Celery's conf serves every key through __getattr__, so undo called delattr, the delete raised AttributeError, and pytest skipped the rest of that test's undos. That made 53 teardown errors across six files (cleanup API, task-run signals, tasks admin, backup, library audit, the soft-limit salvage test), and could leak patches into later tests. CI installs pytest unpinned, so the first run after the release picked it up: the weekly base refresh of main, 2026-11-01, and every run since. The gate held, so nothing was published. tests/conftest.py gains `celery_conf(**values)`: it records each key's value through conf[key], writes with conf.update(), and restores the saved values after the test. The six files use it. Task #6864. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
218 lines
7.8 KiB
Python
218 lines
7.8 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.fixture
|
|
def celery_conf():
|
|
"""Set Celery config keys for one test: `celery_conf(task_always_eager=True)`.
|
|
The values they had before the test come back after it.
|
|
|
|
Use this, not `monkeypatch.setattr(celery.conf, ...)`. Celery serves config
|
|
keys through `__getattr__`, so monkeypatch sees no attribute to restore and
|
|
deletes the key on undo instead; the delete raises, and every later undo of
|
|
that test is skipped, leaking its patches into the tests after it.
|
|
"""
|
|
from backend.app.celery_app import celery
|
|
|
|
saved: dict[str, object] = {}
|
|
|
|
def _set(**values: object) -> None:
|
|
for key in values:
|
|
saved.setdefault(key, celery.conf[key])
|
|
celery.conf.update(values)
|
|
|
|
yield _set
|
|
celery.conf.update(saved)
|
|
|
|
|
|
@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()
|