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

@ -29,9 +29,16 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base, utcnow
# Portable autoincrement primary-key type. SQLite only treats `INTEGER
# PRIMARY KEY` as a ROWID alias (the bit that auto-fills); plain BIGINT
# requires explicit values, which breaks our async tests. `with_variant`
# emits INTEGER on SQLite and keeps BIGINT everywhere else.
_PK = BigInteger().with_variant(Integer(), "sqlite")
class Quote(Base):
__tablename__ = "quotes"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
symbol: Mapped[str] = mapped_column(String(128), nullable=False)
source: Mapped[str] = mapped_column(String(32), nullable=False)
label: Mapped[str] = mapped_column(String(128), default="")
@ -62,7 +69,7 @@ class QuoteDaily(Base):
class Headline(Base):
__tablename__ = "headlines"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
source: Mapped[str] = mapped_column(String(64), nullable=False)
category: Mapped[str] = mapped_column(String(32), nullable=False)
title: Mapped[str] = mapped_column(String(512), nullable=False)
@ -100,7 +107,7 @@ class Feed(Base):
class StrategicLog(Base):
__tablename__ = "strategic_logs"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
generated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
model: Mapped[str] = mapped_column(String(64), nullable=False)
anchor_date: Mapped[str | None] = mapped_column(String(16))
@ -117,7 +124,7 @@ class IndicatorSummary(Base):
"""Short AI-generated read for one indicator group, regenerated hourly.
The latest row per group_name is what the dashboard renders."""
__tablename__ = "indicator_summaries"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
group_name: Mapped[str] = mapped_column(String(64), nullable=False)
generated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
model: Mapped[str] = mapped_column(String(64), nullable=False)
@ -135,7 +142,7 @@ class IndicatorSummary(Base):
class AICall(Base):
"""Cost ledger for OpenRouter calls. Feeds the monthly cap check."""
__tablename__ = "ai_calls"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
called_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
model: Mapped[str] = mapped_column(String(64), nullable=False)
prompt_tokens: Mapped[int | None] = mapped_column(Integer)
@ -224,7 +231,7 @@ class Referral(Base):
user makes their first paid subscription Phase D.3 fills them in
via the Paddle webhook."""
__tablename__ = "referrals"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
referrer_user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
)
@ -246,7 +253,7 @@ class EmailOTP(Base):
sent in the email; we store an argon2 hash, expiry, attempt count, and
a used_at timestamp so a single code can't be reused or brute-forced."""
__tablename__ = "email_otps"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
email: Mapped[str] = mapped_column(String(255), nullable=False)
code_hash: Mapped[str] = mapped_column(String(255), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
@ -267,7 +274,7 @@ class InstrumentMap(Base):
Multiple rows can share a shortName (e.g. SHEL on LSE in GBX vs
SHEL on NYSE in USD); the resolver picks the right one per user."""
__tablename__ = "instrument_map"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
t212_ticker: Mapped[str] = mapped_column(String(64), nullable=False)
t212_shortname: Mapped[str] = mapped_column(String(32), nullable=False)
yahoo_ticker: Mapped[str | None] = mapped_column(String(32))
@ -309,7 +316,7 @@ class TickerUniverse(Base):
class JobRun(Base):
"""One row per scheduled-job invocation; powers /api/health + the ops footer."""
__tablename__ = "job_runs"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(64), nullable=False)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
@ -326,7 +333,7 @@ class EmailSend(Base):
Settings page."""
__tablename__ = "email_sends"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
id: Mapped[int] = mapped_column(_PK, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
)