"""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="

x

"): """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"