digest: daily/weekly job w/ EmailSend idempotency

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-05-25 23:13:24 +02:00
parent 0a476bed22
commit 2462882006
2 changed files with 296 additions and 0 deletions

View file

@ -0,0 +1,96 @@
"""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.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.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.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"