import asyncio import json from datetime import datetime, timezone import pytest from src import gitea_proxy from src.push_notifications import PushConfiguration, dispatch_deadline_reminders from src.push_subscription_store import PushSubscriptionStore @pytest.mark.anyio async def test_deadline_reminder_sends_one_private_local_day_digest_and_deduplicates(tmp_path): store = PushSubscriptionStore(tmp_path / "push.sqlite3") store.upsert("device-a", { "endpoint": "https://push.example/device-a", "keys": {"p256dh": "public-key", "auth": "auth-secret"}, }) store.set_deadline_preferences( "device-a", enabled=True, timezone="America/New_York", reminder_hour=9 ) sent = [] async def assigned(): return { "complete": True, "items": [ { "id": 42, "title": "Private launch plan", "repository": {"full_name": "private/repo"}, "due_date": "2026-08-14T12:00:00Z", }, {"id": 43, "due_date": "2026-08-20T12:00:00Z"}, ], } async def send(_subscription, payload): sent.append(json.loads(payload)) config = PushConfiguration("public", "private", "mailto:ops@example.com") now = datetime(2026, 8, 13, 13, 5, tzinfo=timezone.utc) assert await dispatch_deadline_reminders(store, config, assigned, send, now=now) == 1 assert await dispatch_deadline_reminders(store, config, assigned, send, now=now) == 0 assert sent == [{ "title": "1 deadline needs attention", "body": "Open Agenda to review or replan it.", "route": "#/my-work/agenda", "protect_route": "#/my-work/agenda/protect-today", "tag": "stackchain-deadline-digest-2026-08-13", "deadline_count": 1, }] assert "Private launch plan" not in json.dumps(sent) assert "private/repo" not in json.dumps(sent) @pytest.mark.anyio async def test_snoozed_deadline_revalidates_and_sends_once_after_expiry(tmp_path): store = PushSubscriptionStore(tmp_path / "push.sqlite3") store.upsert("device-a", { "endpoint": "https://push.example/device-a", "keys": {"p256dh": "public-key", "auth": "auth-secret"}, }) store.set_deadline_preferences( "device-a", enabled=True, timezone="UTC", reminder_hour=9 ) store.mark_deadline_reminder_delivered("device-a", "2026-08-13") snoozed_at = datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc) store.snooze_deadline_reminder( "device-a", now=snoozed_at.timestamp(), delay_seconds=3_600 ) snapshots = 0 sent = [] async def assigned(): nonlocal snapshots snapshots += 1 return {"complete": True, "items": [{"id": 1, "due_date": "2026-08-14"}]} async def send(_subscription, payload): sent.append(json.loads(payload)) config = PushConfiguration("public", "private", "mailto:ops@example.com") assert await dispatch_deadline_reminders( store, config, assigned, send, now=datetime(2026, 8, 13, 10, 59, tzinfo=timezone.utc), ) == 0 assert snapshots == 0 assert await dispatch_deadline_reminders( store, config, assigned, send, now=datetime(2026, 8, 13, 11, 0, tzinfo=timezone.utc), ) == 1 assert snapshots == 1 assert sent[0]["deadline_count"] == 1 assert sent[0]["tag"] == "stackchain-deadline-digest-2026-08-13" assert store.deadline_reminder_devices()[0].snoozed_until is None assert await dispatch_deadline_reminders( store, config, assigned, send, now=datetime(2026, 8, 13, 11, 1, tzinfo=timezone.utc), ) == 0 assert snapshots == 1 @pytest.mark.anyio async def test_expired_deadline_snooze_clears_when_no_deadlines_remain(tmp_path): store = PushSubscriptionStore(tmp_path / "push.sqlite3") store.upsert("device-a", { "endpoint": "https://push.example/device-a", "keys": {"p256dh": "public-key", "auth": "auth-secret"}, }) store.set_deadline_preferences( "device-a", enabled=True, timezone="UTC", reminder_hour=9 ) store.mark_deadline_reminder_delivered("device-a", "2026-08-13") store.snooze_deadline_reminder( "device-a", now=datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc).timestamp(), ) async def resolved(): return {"complete": True, "items": []} assert await dispatch_deadline_reminders( store, PushConfiguration("public", "private", "mailto:ops@example.com"), resolved, now=datetime(2026, 8, 13, 11, 0, tzinfo=timezone.utc), ) == 0 assert store.deadline_reminder_devices()[0].snoozed_until is None @pytest.mark.anyio async def test_expired_deadline_snooze_clears_when_deadlines_move_beyond_horizon(tmp_path): store = PushSubscriptionStore(tmp_path / "push.sqlite3") store.upsert("device-a", { "endpoint": "https://push.example/device-a", "keys": {"p256dh": "public-key", "auth": "auth-secret"}, }) store.set_deadline_preferences( "device-a", enabled=True, timezone="UTC", reminder_hour=9, reminder_days=2 ) store.mark_deadline_reminder_delivered("device-a", "2026-08-13") store.snooze_deadline_reminder( "device-a", now=datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc).timestamp(), ) async def replanned(): return {"complete": True, "items": [{"id": 1, "due_date": "2026-08-20"}]} assert await dispatch_deadline_reminders( store, PushConfiguration("public", "private", "mailto:ops@example.com"), replanned, now=datetime(2026, 8, 13, 11, 0, tzinfo=timezone.utc), ) == 0 assert store.deadline_reminder_devices()[0].snoozed_until is None @pytest.mark.anyio async def test_deadline_reminder_counts_calendar_days_per_device_timezone(tmp_path): store = PushSubscriptionStore(tmp_path / "push.sqlite3") for device, timezone_name in (("tokyo", "Asia/Tokyo"), ("la", "America/Los_Angeles")): store.upsert(device, { "endpoint": f"https://push.example/{device}", "keys": {"p256dh": "public-key", "auth": "auth-secret"}, }) store.set_deadline_preferences(device, enabled=True, timezone=timezone_name, reminder_hour=0) async def assigned(): return {"complete": True, "items": [ {"id": 1, "due_date": "2026-08-15T23:59:59Z"}, {"id": 2, "due_date": "2026-08-16T23:59:59Z"}, ]} sent = {} async def send(subscription, payload): device = subscription["endpoint"].rsplit("/", 1)[-1] sent[device] = json.loads(payload)["deadline_count"] delivered = await dispatch_deadline_reminders( store, PushConfiguration("public", "private", "mailto:ops@example.com"), assigned, send, now=datetime(2026, 8, 13, 23, 30, tzinfo=timezone.utc), ) assert delivered == 2 assert sent == {"tokyo": 2, "la": 1} @pytest.mark.anyio async def test_deadline_reminder_uses_each_devices_confirmed_horizon(tmp_path): store = PushSubscriptionStore(tmp_path / "push.sqlite3") for device, reminder_days in (("today", 0), ("week", 7)): store.upsert(device, { "endpoint": f"https://push.example/{device}", "keys": {"p256dh": "public-key", "auth": "auth-secret"}, }) store.set_deadline_preferences( device, enabled=True, timezone="UTC", reminder_hour=9, reminder_days=reminder_days, ) async def assigned(): return {"complete": True, "items": [ {"id": 1, "due_date": "2026-08-13"}, {"id": 2, "due_date": "2026-08-15"}, {"id": 3, "due_date": "2026-08-20"}, {"id": 4, "due_date": "2026-08-21"}, ]} sent = {} async def send(subscription, payload): device = subscription["endpoint"].rsplit("/", 1)[-1] sent[device] = json.loads(payload)["deadline_count"] delivered = await dispatch_deadline_reminders( store, PushConfiguration("public", "private", "mailto:ops@example.com"), assigned, send, now=datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc), ) assert delivered == 2 assert sent == {"today": 1, "week": 3} @pytest.mark.anyio async def test_deadline_reminder_fails_closed_for_incomplete_snapshot_and_before_local_hour(tmp_path): store = PushSubscriptionStore(tmp_path / "push.sqlite3") store.upsert("device-a", { "endpoint": "https://push.example/device-a", "keys": {"p256dh": "public-key", "auth": "auth-secret"}, }) store.set_deadline_preferences( "device-a", enabled=True, timezone="America/Los_Angeles", reminder_hour=9 ) sent = [] async def incomplete(): return {"complete": False, "items": [{"id": 42, "due_date": "2026-08-14T12:00:00Z"}]} async def complete(): return {"complete": True, "items": [{"id": 42, "due_date": "2026-08-14T12:00:00Z"}]} async def send(_subscription, payload): sent.append(payload) config = PushConfiguration("public", "private", "mailto:ops@example.com") assert await dispatch_deadline_reminders( store, config, incomplete, send, now=datetime(2026, 8, 13, 18, 0, tzinfo=timezone.utc), ) == 0 assert await dispatch_deadline_reminders( store, config, complete, send, now=datetime(2026, 8, 13, 15, 0, tzinfo=timezone.utc), ) == 0 assert sent == [] @pytest.mark.anyio async def test_deadline_reminder_skips_snapshot_until_a_device_reaches_its_local_hour(tmp_path): store = PushSubscriptionStore(tmp_path / "push.sqlite3") store.upsert("device-a", { "endpoint": "https://push.example/device-a", "keys": {"p256dh": "public-key", "auth": "auth-secret"}, }) store.set_deadline_preferences( "device-a", enabled=True, timezone="America/Los_Angeles", reminder_hour=9 ) snapshot_calls = 0 async def assigned(): nonlocal snapshot_calls snapshot_calls += 1 return {"complete": True, "items": []} delivered = await dispatch_deadline_reminders( store, PushConfiguration("public", "private", "mailto:ops@example.com"), assigned, now=datetime(2026, 8, 13, 15, 0, tzinfo=timezone.utc), ) assert delivered == 0 assert snapshot_calls == 0 @pytest.mark.anyio async def test_deadline_reminder_skips_snapshot_after_every_device_was_delivered_today(tmp_path): store = PushSubscriptionStore(tmp_path / "push.sqlite3") for session_id, timezone_name in ( ("device-a", "UTC"), ("device-b", "America/New_York"), ): store.upsert(session_id, { "endpoint": f"https://push.example/{session_id}", "keys": {"p256dh": "public-key", "auth": "auth-secret"}, }) store.set_deadline_preferences( session_id, enabled=True, timezone=timezone_name, reminder_hour=9 ) store.mark_deadline_reminder_delivered(session_id, "2026-08-13") snapshot_calls = 0 async def assigned(): nonlocal snapshot_calls snapshot_calls += 1 return {"complete": True, "items": []} delivered = await dispatch_deadline_reminders( store, PushConfiguration("public", "private", "mailto:ops@example.com"), assigned, now=datetime(2026, 8, 13, 15, 0, tzinfo=timezone.utc), ) assert delivered == 0 assert snapshot_calls == 0 def test_deadline_preferences_persist_on_the_existing_device_subscription(tmp_path): store = PushSubscriptionStore(tmp_path / "push.sqlite3") store.upsert("device-a", { "endpoint": "https://push.example/device-a", "keys": {"p256dh": "public-key", "auth": "auth-secret"}, }) store.set_deadline_preferences( "device-a", enabled=True, timezone="Europe/London", reminder_hour=8 ) assert store.deadline_preferences("device-a") == { "enabled": True, "timezone": "Europe/London", "reminder_hour": 8, "reminder_days": 2, "snoozed_until": None, } def test_deadline_snooze_is_device_bound_and_requires_enabled_reminders(tmp_path): store = PushSubscriptionStore(tmp_path / "push.sqlite3") for session_id in ("enabled", "disabled"): store.upsert(session_id, { "endpoint": f"https://push.example/{session_id}", "keys": {"p256dh": "public-key", "auth": "auth-secret"}, }) store.set_deadline_preferences( "enabled", enabled=True, timezone="UTC", reminder_hour=9 ) store.set_deadline_preferences( "disabled", enabled=False, timezone="UTC", reminder_hour=9 ) assert store.snooze_deadline_reminder( "enabled", now=1_765_000_000, delay_seconds=3_600 ) is True assert store.snooze_deadline_reminder( "disabled", now=1_765_000_000, delay_seconds=3_600 ) is False assert store.snooze_deadline_reminder( "missing", now=1_765_000_000, delay_seconds=3_600 ) is False devices = {device.session_id: device for device in store.deadline_reminder_devices()} assert devices["enabled"].snoozed_until == 1_765_003_600 assert "disabled" not in devices def test_existing_deadline_preferences_migrate_to_two_day_horizon(tmp_path): database = tmp_path / "push.sqlite3" import sqlite3 with sqlite3.connect(database) as connection: connection.executescript(""" CREATE TABLE push_subscriptions ( session_id TEXT PRIMARY KEY, endpoint TEXT NOT NULL UNIQUE, subscription_json TEXT NOT NULL ); CREATE TABLE push_deadline_preferences ( session_id TEXT PRIMARY KEY, enabled INTEGER NOT NULL DEFAULT 0, timezone TEXT NOT NULL DEFAULT 'UTC', reminder_hour INTEGER NOT NULL DEFAULT 9, delivered_local_day TEXT ); INSERT INTO push_subscriptions VALUES ( 'device-a', 'https://push.example/a', '{"endpoint":"https://push.example/a","keys":{"p256dh":"key","auth":"secret"}}' ); INSERT INTO push_deadline_preferences VALUES ('device-a', 1, 'UTC', 8, NULL); """) store = PushSubscriptionStore(database) assert store.deadline_preferences("device-a")["reminder_days"] == 2 @pytest.mark.anyio async def test_assigned_deadline_snapshot_is_pagination_complete(monkeypatch): pages = { 1: {"items": [{"id": 1}], "total": 2, "has_more": True}, 2: {"items": [{"id": 2}], "total": 2, "has_more": False}, } async def work_page(stream, page, limit): assert stream == "issue" assert limit == 1 return pages[page] monkeypatch.setattr(gitea_proxy, "work_page", work_page) assert await gitea_proxy.assigned_issue_snapshot(limit=1) == { "items": [{"id": 1}, {"id": 2}], "complete": True, } @pytest.mark.anyio async def test_assigned_deadline_snapshot_fetches_remaining_pages_concurrently(monkeypatch): active = 0 peak = 0 remaining_started = asyncio.Event() async def work_page(stream, page, limit): nonlocal active, peak assert stream == "issue" assert limit == 1 if page == 1: return {"items": [{"id": 1}], "total": 5, "has_more": True} active += 1 peak = max(peak, active) if active == 2: remaining_started.set() await asyncio.wait_for(remaining_started.wait(), timeout=0.2) await asyncio.sleep(0) active -= 1 return {"items": [{"id": page}], "total": 5, "has_more": page < 5} monkeypatch.setattr(gitea_proxy, "work_page", work_page) assert await gitea_proxy.assigned_issue_snapshot(limit=1, max_concurrency=2) == { "items": [{"id": 1}, {"id": 2}, {"id": 3}, {"id": 4}, {"id": 5}], "complete": True, } assert peak == 2 @pytest.mark.anyio @pytest.mark.parametrize( "remaining", [ {"items": [{"id": 2}], "total": 3, "has_more": True}, {"items": [{"id": 1}], "total": 2, "has_more": False}, ], ) async def test_assigned_deadline_snapshot_rejects_changed_or_duplicate_pages( monkeypatch, remaining ): async def work_page(_stream, page, _limit): if page == 1: return {"items": [{"id": 1}], "total": 2, "has_more": True} return remaining monkeypatch.setattr(gitea_proxy, "work_page", work_page) with pytest.raises(ValueError, match="incomplete|changed"): await gitea_proxy.assigned_issue_snapshot(limit=1) @pytest.mark.anyio async def test_assigned_deadline_snapshot_enforces_aggregate_deadline(monkeypatch): cancelled = asyncio.Event() async def work_page(_stream, _page, _limit): try: await asyncio.sleep(60) finally: cancelled.set() monkeypatch.setattr(gitea_proxy, "work_page", work_page) with pytest.raises(TimeoutError): await gitea_proxy.assigned_issue_snapshot(deadline_seconds=0.01) assert cancelled.is_set() @pytest.mark.anyio async def test_competing_workers_send_one_deadline_digest(tmp_path): path = tmp_path / "push.sqlite3" first = PushSubscriptionStore(path) second = PushSubscriptionStore(path) first.upsert("device-a", { "endpoint": "https://push.example/device-a", "keys": {"p256dh": "public-key", "auth": "auth-secret"}, }) first.set_deadline_preferences( "device-a", enabled=True, timezone="UTC", reminder_hour=9 ) sending = __import__("asyncio").Event() release = __import__("asyncio").Event() sent = [] async def assigned(): return {"complete": True, "items": [{"id": 1, "due_date": "2026-08-14T00:00:00Z"}]} async def send(_subscription, payload): sent.append(payload) sending.set() await release.wait() config = PushConfiguration("public", "private", "mailto:ops@example.com") now = datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc) active = __import__("asyncio").create_task( dispatch_deadline_reminders(first, config, assigned, send, now=now) ) await sending.wait() competing = await dispatch_deadline_reminders(second, config, assigned, send, now=now) release.set() assert competing == 0 assert await active == 1 assert len(sent) == 1 @pytest.mark.anyio async def test_deadline_reminders_bound_fanout_and_isolate_failed_devices(tmp_path): store = PushSubscriptionStore(tmp_path / "push.sqlite3") for index in range(4): session_id = f"device-{index}" store.upsert(session_id, { "endpoint": f"https://push.example/{session_id}", "keys": {"p256dh": "public-key", "auth": "auth-secret"}, }) store.set_deadline_preferences( session_id, enabled=True, timezone="UTC", reminder_hour=9 ) active = 0 peak = 0 two_active = asyncio.Event() async def assigned(): return {"complete": True, "items": [{"id": 1, "due_date": "2026-08-14T00:00:00Z"}]} async def send(subscription, _payload): nonlocal active, peak active += 1 peak = max(peak, active) if active == 2: two_active.set() await asyncio.wait_for(two_active.wait(), timeout=0.5) active -= 1 if subscription["endpoint"].endswith("device-1"): raise RuntimeError("provider failed") delivered = await dispatch_deadline_reminders( store, PushConfiguration("public", "private", "mailto:ops@example.com"), assigned, send, now=datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc), max_concurrency=2, ) assert delivered == 3 assert peak == 2 @pytest.mark.anyio async def test_deadline_reminders_bulk_authorize_due_devices_and_prune_inactive(tmp_path): store = PushSubscriptionStore(tmp_path / "push.sqlite3") for session_id in ("active-device", "revoked-device"): store.upsert(session_id, { "endpoint": f"https://push.example/{session_id}", "keys": {"p256dh": "public-key", "auth": "auth-secret"}, }) store.set_deadline_preferences( session_id, enabled=True, timezone="UTC", reminder_hour=9 ) authorization_calls = [] sent = [] async def assigned(): return {"complete": True, "items": [{"id": 1, "due_date": "2026-08-14T00:00:00Z"}]} async def session_statuses(management_ids): authorization_calls.append(list(management_ids)) return { management_id: "active" if management_id == "active-device" else "revoked" for management_id in management_ids } async def send(subscription, _payload): sent.append(subscription["endpoint"]) delivered = await dispatch_deadline_reminders( store, PushConfiguration("public", "private", "mailto:ops@example.com"), assigned, send, now=datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc), session_statuses=session_statuses, ) assert delivered == 1 assert authorization_calls == [["active-device", "revoked-device"]] assert sent == ["https://push.example/active-device"] assert store.is_subscribed("revoked-device") is False