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", "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_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_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, } @pytest.mark.anyio async def test_assigned_deadline_snapshot_is_pagination_complete(monkeypatch): pages = { 1: {"items": [{"id": 1}], "has_more": True}, 2: {"items": [{"id": 2}], "has_more": False}, } async def work_page(stream, page, limit): assert stream == "issue" assert limit == 50 return pages[page] monkeypatch.setattr(gitea_proxy, "work_page", work_page) assert await gitea_proxy.assigned_issue_snapshot() == { "items": [{"id": 1}, {"id": 2}], "complete": True, } @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