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).
99 lines
4 KiB
Python
99 lines
4 KiB
Python
"""Recipient selection + idempotency for the digest job."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from datetime import datetime, timezone, timedelta
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
|
|
def _bootstrap(tmp_path):
|
|
"""Spin up an in-memory DB with three users: a paid opt-in, a paid
|
|
opt-out, a free opt-in."""
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
|
|
from app import db as db_mod
|
|
from app.db import Base
|
|
from app.models import User
|
|
|
|
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/dj.db")
|
|
factory = async_sessionmaker(engine, expire_on_commit=False)
|
|
db_mod._engine = engine
|
|
db_mod._session_factory = factory
|
|
|
|
async def _seed():
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
async with factory() as s:
|
|
s.add(User(id=1, email="paid_in@x", tier="paid", email_digest_opt_in=True))
|
|
s.add(User(id=2, email="paid_out@x", tier="paid", email_digest_opt_in=False))
|
|
s.add(User(id=3, email="free_in@x", tier="free", email_digest_opt_in=True))
|
|
await s.commit()
|
|
|
|
asyncio.run(_seed())
|
|
return factory
|
|
|
|
|
|
def _patch_today(weekday: int):
|
|
"""Return a datetime whose weekday() == `weekday` (0=Mon, 6=Sun)."""
|
|
base = datetime(2026, 5, 25, 6, 30, tzinfo=timezone.utc) # Monday
|
|
return base + timedelta(days=(weekday - base.weekday()) % 7)
|
|
|
|
|
|
def _stub_generate(content="<p>x</p>"):
|
|
"""Stub out the LLM call so the test never hits the network. Use a
|
|
SimpleNamespace so we don't have to know the real result class name."""
|
|
from types import SimpleNamespace
|
|
async def _fake(_client, messages, **kwargs):
|
|
return SimpleNamespace(
|
|
content=content, model="stub",
|
|
prompt_tokens=10, completion_tokens=10, cost_usd=0.0,
|
|
)
|
|
return _fake
|
|
|
|
|
|
def test_daily_run_only_paid_opt_in(tmp_path):
|
|
_bootstrap(tmp_path)
|
|
from app.jobs import email_digest_job
|
|
with patch("app.jobs.email_digest_job._now",
|
|
return_value=_patch_today(0)), \
|
|
patch("app.jobs.email_digest_job.send_email",
|
|
new=AsyncMock()) as send_mock, \
|
|
patch("app.jobs.email_digest_job.llm_configured", return_value=True), \
|
|
patch("app.jobs.email_digest_job.call_llm",
|
|
new=AsyncMock(side_effect=_stub_generate())):
|
|
asyncio.run(email_digest_job.run())
|
|
addresses_sent = {call.kwargs.get("to") for call in send_mock.await_args_list}
|
|
assert addresses_sent == {"paid_in@x"}
|
|
|
|
|
|
def test_weekly_run_includes_free_and_paid_opt_in(tmp_path):
|
|
_bootstrap(tmp_path)
|
|
from app.jobs import email_digest_job
|
|
with patch("app.jobs.email_digest_job._now",
|
|
return_value=_patch_today(6)), \
|
|
patch("app.jobs.email_digest_job.send_email",
|
|
new=AsyncMock()) as send_mock, \
|
|
patch("app.jobs.email_digest_job.llm_configured", return_value=True), \
|
|
patch("app.jobs.email_digest_job.call_llm",
|
|
new=AsyncMock(side_effect=_stub_generate())):
|
|
asyncio.run(email_digest_job.run())
|
|
addresses_sent = {call.kwargs.get("to") for call in send_mock.await_args_list}
|
|
assert addresses_sent == {"paid_in@x", "free_in@x"}
|
|
|
|
|
|
def test_second_run_same_day_is_idempotent(tmp_path):
|
|
_bootstrap(tmp_path)
|
|
from app.jobs import email_digest_job
|
|
with patch("app.jobs.email_digest_job._now",
|
|
return_value=_patch_today(0)), \
|
|
patch("app.jobs.email_digest_job.send_email",
|
|
new=AsyncMock()) as send_mock, \
|
|
patch("app.jobs.email_digest_job.llm_configured", return_value=True), \
|
|
patch("app.jobs.email_digest_job.call_llm",
|
|
new=AsyncMock(side_effect=_stub_generate())):
|
|
asyncio.run(email_digest_job.run())
|
|
first_count = len(send_mock.await_args_list)
|
|
asyncio.run(email_digest_job.run())
|
|
second_count = len(send_mock.await_args_list)
|
|
assert first_count > 0
|
|
assert second_count == first_count, "second run should not re-send"
|