Newer
Older
gnexus-tasks / backend / tests / test_push_scheduler.py
"""Планировщик напоминаний: дедуп через журнал (ТЗ 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 == []