"""Event reminders as notifications (calendar plugin, sdk.push): due reminders are sent once, repeating events remind on each occurrence, cancelled and declined ones stay quiet.""" from __future__ import annotations import uuid from collections.abc import AsyncIterator from datetime import UTC, datetime, timedelta from typing import Any import httpx import pytest import pytest_asyncio from httpx import ASGITransport from sqlalchemy import delete, text from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.pool import NullPool from webmail_plugin_calendar import reminders # type: ignore[import-not-found] from app.config import get_settings from app.identity.passwords import hash_password from app.identity.sessions import create_session from app.main import create_app from app.models import User from app.plugins.sdk import PluginPush CAL = "/api/plugins/calendar" # Far from any real event in the dev database. MON_9 = datetime(2031, 6, 2, 9, 0, tzinfo=UTC) # a Monday @pytest_asyncio.fixture async def ctx( monkeypatch: pytest.MonkeyPatch, ) -> AsyncIterator[tuple[httpx.AsyncClient, uuid.UUID, list[dict[str, Any]], Any]]: sent: list[dict[str, Any]] = [] async def fake_notify(self: PluginPush, user_id: uuid.UUID, **kw: Any) -> int: sent.append({"user_id": user_id, **kw}) return 1 monkeypatch.setattr(PluginPush, "notify", fake_notify) engine = create_async_engine(get_settings().database_url, poolclass=NullPool) sm = async_sessionmaker(engine, expire_on_commit=False) async with sm() as s: user = User( handle=f"rem{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw") ) s.add(user) await s.commit() token = await create_session(user.id, "pytest") client = httpx.AsyncClient( transport=ASGITransport(app=create_app()), base_url="http://testserver", cookies={"session": token, "csrf": "c"}, headers={"X-CSRF": "c"}, ) try: yield client, user.id, sent, sm finally: await client.aclose() async with sm() as s: await s.execute( text("DELETE FROM plg_calendar_calendars WHERE user_id = :u"), {"u": str(user.id)} ) await s.execute(text("DELETE FROM plugin_kv WHERE user_id = :u"), {"u": str(user.id)}) await s.execute(delete(User).where(User.id == user.id)) await s.commit() await engine.dispose() async def _event(client: httpx.AsyncClient, **fields: Any) -> str: cal = (await client.get(f"{CAL}/calendars")).json()[0]["id"] r = await client.post(f"{CAL}/events", json={"calendar_id": cal, **fields}) assert r.status_code == 200, r.text return str(r.json()["id"]) async def test_due_reminders_are_sent_once(ctx) -> None: # type: ignore[no-untyped-def] client, user_id, sent, sm = ctx await _event( client, summary="Dentist", location="Main St 4", dtstart=MON_9.isoformat(), dtend=(MON_9 + timedelta(hours=1)).isoformat(), reminders=[10, 60], ) called_off = await _event( client, summary="Called off", dtstart=MON_9.isoformat(), reminders=[10] ) async with sm() as db: # cancelled by its organizer: never reminds await db.execute( text("UPDATE plg_calendar_events SET status = 'cancelled' WHERE id = :id"), {"id": called_off}, ) await db.commit() async def run(at: datetime) -> int: return await reminders.reminder_job({}, now=at, only_user=user_id) assert await run(MON_9 - timedelta(minutes=61)) == 0 # nothing due yet assert await run(MON_9 - timedelta(minutes=60)) == 1 # the 1-hour reminder assert sent[-1]["title"] == "Dentist" assert sent[-1]["body"] == "In 1 hour, 09:00 · Main St 4" assert sent[-1]["url"] == "/p/calendar?date=2031-06-02" and sent[-1]["sticky"] is True assert await run(MON_9 - timedelta(minutes=59)) == 0 # not again # The worker was down when the 10-minute reminder came due: sent late, once. assert await run(MON_9 - timedelta(minutes=4)) == 1 assert sent[-1]["body"] == "In 10 minutes, 09:00 · Main St 4" assert await run(MON_9 - timedelta(minutes=3)) == 0 assert len(sent) == 2 async def test_repeating_events_remind_on_each_occurrence(ctx) -> None: # type: ignore[no-untyped-def] client, user_id, sent, _ = ctx await _event( client, summary="Weekly sync", dtstart=MON_9.isoformat(), dtend=(MON_9 + timedelta(minutes=30)).isoformat(), rrule="FREQ=WEEKLY;BYDAY=MO", reminders=[15], ) for week in range(3): at = MON_9 + timedelta(weeks=week) - timedelta(minutes=15) assert await reminders.reminder_job({}, now=at, only_user=user_id) == 1 tags = {s["tag"] for s in sent} assert len(sent) == 3 and len(tags) == 3 # one notification per occurrence