test+fix: make the suite run cleanly in the test container

Five fixes uncovered by actually running the suite in docker-compose.test.yml:

1. (real prod bug) PATCH /api/settings/digest mutated principal.user which
   require_token had loaded in a now-closed session — the commit on the
   handler's session persisted nothing. Re-fetch the user via the active
   session before writing.

2. Portable PK type. SQLite only auto-fills `INTEGER PRIMARY KEY`; plain
   BIGINT requires explicit values. Define a `_PK` alias of
   `BigInteger().with_variant(Integer(), "sqlite")` and use it for all 10
   autoincrement primary keys in app/models.py. No prod-schema change
   (MariaDB still gets BIGINT).

3. job_lifecycle's MariaDB GET_LOCK / RELEASE_LOCK is now gated behind
   `dialect.name == "mysql"`, so the test SQLite engine doesn't trip on
   the missing function. Single-process test runs can't race themselves.

4. tests/test_news_window.py seeded Headline rows without `fingerprint`,
   which is NOT NULL — added an `fp-{title}` value per row.

5. tests/test_email_digest_job.py now also patches `llm_configured` to
   True so the job doesn't short-circuit on the missing API key.

6. (test container hygiene) Drop `COPY tests ./tests` from the test stage
   in the Dockerfile — .dockerignore excludes `tests/` (correct: prod
   image must not bake tests), and docker-compose.test.yml bind-mounts
   ./tests at run time anyway.

Suite now: 198 passed, 5 skipped, 1 pre-existing failure
(test_default_groups_present — Phase G dropped the "pie" group from
config/default.toml but the assertion wasn't updated; unrelated to this
branch).
This commit is contained in:
Giorgio Gilestro 2026-05-26 00:11:18 +02:00
parent 80e2ec53ac
commit a113a7f3ce
6 changed files with 51 additions and 25 deletions

View file

@ -23,17 +23,21 @@ async def job_lifecycle(name: str) -> AsyncIterator[tuple[AsyncSession, JobRun]]
handles the bookkeeping.
A MariaDB GET_LOCK(name, 0) is acquired to prevent concurrent runs of the
same job across processes. If the lock is busy, we skip the run."""
same job across processes. If the lock is busy, we skip the run.
The lock dance is MariaDB-specific; on SQLite (used in tests) it's a
no-op, since the single-process test runner can't race itself."""
factory = get_session_factory()
async with factory() as session:
# Try lock; skip if held.
got = (await session.execute(
text("SELECT GET_LOCK(:n, 0)"), {"n": f"cassandra_{name}"}
)).scalar()
if not got:
log.warning("job.skipped_locked", name=name)
yield session, JobRun(name=name, started_at=utcnow(), status="skipped")
return
bind = session.get_bind()
use_lock = bind is not None and bind.dialect.name == "mysql"
if use_lock:
got = (await session.execute(
text("SELECT GET_LOCK(:n, 0)"), {"n": f"cassandra_{name}"}
)).scalar()
if not got:
log.warning("job.skipped_locked", name=name)
yield session, JobRun(name=name, started_at=utcnow(), status="skipped")
return
run = JobRun(name=name, started_at=utcnow(), status="running")
session.add(run)
await session.commit()
@ -53,6 +57,7 @@ async def job_lifecycle(name: str) -> AsyncIterator[tuple[AsyncSession, JobRun]]
log.error("job.failed", name=name, error=str(e))
raise
finally:
await session.execute(text("SELECT RELEASE_LOCK(:n)"),
{"n": f"cassandra_{name}"})
await session.commit()
if use_lock:
await session.execute(text("SELECT RELEASE_LOCK(:n)"),
{"n": f"cassandra_{name}"})
await session.commit()