"""Планировщик напоминаний: дедуп через журнал (ТЗ 3.21, 0.89).
Главное свойство, которое здесь проверяется, — «одно напоминание на один повод»
переживает и повторный тик, и перезапуск API. Тик 15-минутный, а правила дневные,
поэтому без журнала одна и та же просрочка приходила бы сотню раз.
Отправка наружу подменена: проверяем решение планировщика, а не сеть.
"""
from datetime import date, datetime, timedelta
from typing import Any
from zoneinfo import ZoneInfo
import pytest
from sqlalchemy import select
from app.config import get_settings
from app.models import AppSetting, PushDelivery, PushSubscription, Task, User
from app.services import scheduler
from tests.conftest import _test_session_factory # type: ignore[attr-defined]
# Моменты задаём прямо в поясе напоминаний: планировщик переводит «сейчас» в него
# сам, и тест, написанный в UTC, проверял бы не то время суток
TZ = ZoneInfo(get_settings().reminder_timezone)
# 11:00 — внутри окна сводки (09:00–14:00) и вне тихих часов по умолчанию
MORNING = datetime(2026, 10, 10, 11, 0, tzinfo=TZ)
TODAY = date(2026, 10, 10)
@pytest.fixture(autouse=True)
def test_db(monkeypatch: Any) -> None:
"""Планировщик открывает свою сессию — направляем её в тестовую БД."""
monkeypatch.setattr(scheduler, "get_session_factory", lambda: _test_session_factory)
@pytest.fixture(autouse=True)
def vapid() -> Any:
"""Без ключей VAPID итерация выходит сразу — тестам нужен включённый push."""
settings = get_settings()
old = (settings.vapid_public_key, settings.vapid_private_key)
settings.vapid_public_key = "pub-key"
settings.vapid_private_key = "priv-key"
yield settings
settings.vapid_public_key, settings.vapid_private_key = old
@pytest.fixture
def sent(monkeypatch: Any) -> list[tuple[str, str]]:
"""Отправленное: (user_id, payload). Настоящий webpush в тестах не зовётся."""
calls: list[tuple[str, str]] = []
def record(user_id: str, payload: str, **kwargs: Any) -> None:
calls.append((user_id, payload))
monkeypatch.setattr(scheduler.push, "send_to_user", record)
return calls
def _seed(db: Any, **task_fields: Any) -> Task:
db.add(User(id="1", email="a@b.c", locale="ru"))
task = Task(user_id="1", title="Проверить отчёт", **task_fields)
db.add(task)
db.add(PushSubscription(user_id="1", endpoint="https://push/1", p256dh="p", auth="a"))
db.commit()
return task
def _db() -> Any:
return _test_session_factory()
def _kinds(sent: list[tuple[str, str]]) -> list[str]:
import json
return [json.loads(payload)["kind"] for _, payload in sent]
def test_iteration_sends_overdue_and_writes_journal(sent: list[tuple[str, str]]) -> None:
db = _db()
try:
_seed(db, deadline_date=TODAY - timedelta(days=1))
finally:
db.close()
scheduler.run_iteration(MORNING)
assert _kinds(sent) == ["task.overdue", "summary"]
db = _db()
try:
assert [row.kind for row in db.scalars(select(PushDelivery))] == [
"task.overdue",
"summary",
]
finally:
db.close()
def test_second_pass_sends_nothing(sent: list[tuple[str, str]]) -> None:
"""Тот же тик ещё раз: повод тот же — молчим."""
db = _db()
try:
_seed(db, deadline_date=TODAY)
finally:
db.close()
scheduler.run_iteration(MORNING)
assert _kinds(sent) == ["task.due_today", "summary"]
sent.clear()
scheduler.run_iteration(MORNING + timedelta(minutes=15))
assert sent == []
def test_restart_of_api_does_not_duplicate(sent: list[tuple[str, str]]) -> None:
"""Перезапуск контейнера — новая сессия, а журнал тот же: дублей быть не должно."""
db = _db()
try:
_seed(db, deadline_date=TODAY - timedelta(days=3))
finally:
db.close()
scheduler.run_iteration(MORNING)
sent.clear()
db = _db() # «новый процесс»: свежая сессия, тот же журнал в БД
try:
assert db.scalars(select(PushDelivery)).all() != []
finally:
db.close()
scheduler.run_iteration(MORNING + timedelta(hours=1))
assert sent == []
def test_quiet_hours_skip_and_leave_no_trace(sent: list[tuple[str, str]]) -> None:
"""В тихом окне не отправляем и **не пишем в журнал**: условие дневное, и
следующий тик после окна обязан напомнить сам."""
db = _db()
try:
_seed(db, deadline_date=TODAY)
db.add(AppSetting(user_id="1", key="quiet_hours", value="22:00-09:00"))
db.commit()
finally:
db.close()
scheduler.run_iteration(datetime(2026, 10, 10, 7, 30, tzinfo=TZ)) # внутри окна
assert sent == []
db = _db()
try:
assert db.scalars(select(PushDelivery)).all() == []
finally:
db.close()
# после окна напоминание приходит само — молчание не съело повод
scheduler.run_iteration(MORNING)
assert "task.due_today" in _kinds(sent)
def test_summary_is_silent_when_there_is_nothing_to_say(sent: list[tuple[str, str]]) -> None:
"""Сводка без новостей — молчание, и в журнал она тоже не идёт."""
db = _db()
try:
_seed(db, deadline_date=TODAY + timedelta(days=10))
finally:
db.close()
scheduler.run_iteration(MORNING)
assert sent == []
db = _db()
try:
assert db.scalars(select(PushDelivery)).all() == []
finally:
db.close()
def test_two_recurring_tasks_remind_twice(sent: list[tuple[str, str]]) -> None:
"""Две регулярные на сегодня — два «Пора», а не одно.
Ключ дедупа у ритма включает id задачи (0.91). Без него уникальность
(пользователь, вид, ключ) съедала второе напоминание, и владелец узнавал
ровно об одной задаче из двух, сколько бы их ни совпало.
"""
db = _db()
try:
db.add(User(id="1", email="a@b.c", locale="ru"))
db.add(PushSubscription(user_id="1", endpoint="https://push/1", p256dh="p", auth="a"))
for title in ("Зарядка", "Дневник"):
task = Task(
user_id="1",
title=title,
task_type="recurring",
recur_kind="interval",
recur_interval_days=1,
)
# Якорь интервала — дата создания: ставим вчера, чтобы сегодня
# правило повторения назначило именно TODAY
task.created_at = datetime(2026, 10, 9, 12, 0, tzinfo=TZ)
db.add(task)
db.commit()
finally:
db.close()
scheduler.run_iteration(MORNING)
assert _kinds(sent) == ["task.rhythm", "task.rhythm"]
db = _db()
try:
keys = {row.ref_key for row in db.scalars(select(PushDelivery))}
finally:
db.close()
assert len(keys) == 2
def test_summary_payload_has_no_task_in_tag(sent: list[tuple[str, str]]) -> None:
"""У сводки задачи нет: тег «summary:None» в шторке выглядел бы поломкой."""
import json
db = _db()
try:
_seed(db, deadline_date=TODAY)
finally:
db.close()
scheduler.run_iteration(MORNING)
summary = [json.loads(p) for _, p in sent if json.loads(p)["kind"] == "summary"][0]
assert summary["tag"] == "summary"
assert summary["url"] == "/"
def test_user_without_devices_is_skipped(sent: list[tuple[str, str]]) -> None:
db = _db()
try:
db.add(User(id="1", email="a@b.c", locale="ru"))
db.add(Task(user_id="1", title="Просрочено", deadline_date=TODAY - timedelta(days=1)))
db.commit()
finally:
db.close()
scheduler.run_iteration(MORNING)
assert sent == []
def test_push_off_stops_the_scheduler(sent: list[tuple[str, str]], vapid: Any) -> None:
"""Нет ключей VAPID — push выключен целиком, итерация не делает ничего."""
db = _db()
try:
_seed(db, deadline_date=TODAY)
finally:
db.close()
vapid.vapid_public_key = ""
vapid.vapid_private_key = ""
scheduler.run_iteration(MORNING)
assert sent == []