From 0efd44fd90d7b97c080f7b2ea24d95f6760955d6 Mon Sep 17 00:00:00 2001 From: timmy Date: Sun, 23 Aug 2026 17:16:28 +0000 Subject: [PATCH] feat: notify devices when Following changes (Closes #1313) --- README.md | 10 +- frontend/dashboard.js | 2 + frontend/index.html | 2 + frontend/push-notifications.js | 50 +++++++- frontend/service-worker.js | 18 +++ src/main.py | 44 +++++++ src/push_notifications.py | 130 +++++++++++++++++++++ src/push_subscription_store.py | 66 +++++++++++ tests/e2e/test_mobile_following_release.py | 6 + tests/test_push_frontend.py | 37 ++++++ tests/test_push_notifications.py | 108 ++++++++++++++++- tests/test_service_worker.py | 24 ++++ tests/test_start_day_reminders.py | 1 + 13 files changed, 491 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 17d0e8c..42e11b6 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,13 @@ final item completes the Following phase. Failed or unconfirmed Gitea mutations and current review position unchanged. Following counts never influence the recommended Work queue. Set `STACKCHAIN_FOLLOWING_DB` to override `.stackchain-state/following.sqlite3`. +Each device can explicitly opt in to **Notify me when Following changes**. Stackchain polls that +channel independently, validates the device session immediately before delivery, and checkpoints +the exact unseen revision set so unchanged work stays silent across workers. Lock-screen payloads +contain only a bounded count and the `#/my-work/following` route—never repository names, titles, +bodies, or comments. Disabling Following alerts leaves Updates, deadlines, and start-day reminders +unchanged. + Completed delegated issues remain in the mobile **Filed** queue until their latest outcome is acknowledged. The mobile queue sheet begins with **Prepare Today**, a live briefing that totals Agenda, Attention, Updates, Filed, and unseen Following activity, then opens the highest-priority non-empty review queue. Following refreshes only when @@ -398,7 +405,8 @@ export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8' # 09:00 in the device's local timezone without opening the dashboard. Bursts send # three individual alerts followed by one private digest that opens Updates. A later # comment on an already-delivered thread triggers a fresh alert when Gitea advances -# that thread's updated_at revision; unchanged and older snapshots remain silent. +# that thread's updated_at revision; unchanged and older snapshots remain silent. Opt-in +# Following alerts use generic copy and deep-link to the changed-first Following review. # Browser push services must resolve exclusively to public IP addresses. Stackchain # validates endpoints at enrollment and again before delivery, rejects redirects, # and removes legacy subscriptions that resolve to private or reserved networks. diff --git a/frontend/dashboard.js b/frontend/dashboard.js index e6df655..875a91d 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -8310,6 +8310,8 @@ startDayControl:qs('#push-start-day'), startDayStatus:qs('#push-start-day-status'), startDayHour:qs('#push-start-day-hour'), + followingControl:qs('#push-following'), + followingStatus:qs('#push-following-status'), deadlineSnooze:qs('#deadline-snooze'), deadlineSnoozeStatus:qs('#deadline-snooze-status'), deadlineSnoozeReview:qs('#review-snoozed-deadlines'), diff --git a/frontend/index.html b/frontend/index.html index 88f3bd6..390a4a5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -221,6 +221,8 @@ + + diff --git a/frontend/push-notifications.js b/frontend/push-notifications.js index 8f760b2..6024d23 100644 --- a/frontend/push-notifications.js +++ b/frontend/push-notifications.js @@ -4,6 +4,7 @@ })(typeof self !== 'undefined' ? self : this, function createPushNotifications({ control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays, startDayControl, startDayStatus, startDayHour, + followingControl, followingStatus, deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview, onReviewDeadlines, notification, serviceWorker, fetchJson, }) { @@ -129,12 +130,15 @@ if (testControl) testControl.hidden = true; if (deadlineControl) deadlineControl.checked = false; if (startDayControl) startDayControl.checked = false; + if (followingControl) followingControl.checked = false; configuration.subscribed = false; configuration.deadline_enabled = false; configuration.start_day_enabled = false; + configuration.following_enabled = false; pendingIntent = null; status.textContent = 'New update notifications are off for this device.'; if (deadlineStatus) deadlineStatus.textContent = 'Deadline reminders are off for this device.'; + if (followingStatus) followingStatus.textContent = 'Following change alerts are off for this device.'; } async function ensureSubscription() { @@ -266,13 +270,45 @@ } } + async function changeFollowing() { + followingControl.disabled = true; + try { + const registration = await serviceWorker.ready; + let subscription = await registration.pushManager.getSubscription(); + if (followingControl.checked) pendingIntent = 'following'; + if (followingControl.checked && !subscription) subscription = await ensureSubscription(); + if (followingControl.checked && !subscription) { + followingControl.checked = false; + followingStatus.textContent = status.textContent; + return false; + } + await fetchJson('api/v1/push-subscription/following', { + method:'PUT', + headers:{'Content-Type':'application/json'}, + body:JSON.stringify({enabled:followingControl.checked}), + }); + configuration.following_enabled = followingControl.checked; + pendingIntent = null; + followingStatus.textContent = followingControl.checked + ? 'Following change alerts enabled for this device.' + : 'Following change alerts are off for this device.'; + return true; + } catch (_error) { + followingControl.checked = !followingControl.checked; + followingStatus.textContent = 'Could not change Following alerts. Check your connection and try again.'; + return false; + } finally { + followingControl.disabled = false; + } + } + async function enableDeadline() { deadlineControl.checked = true; return changeDeadline(); } async function recoverPermission(intent = null) { - if (!pendingIntent && ['updates', 'deadline', 'start-day'].includes(intent)) pendingIntent = intent; + if (!pendingIntent && ['updates', 'deadline', 'start-day', 'following'].includes(intent)) pendingIntent = intent; if (!pendingIntent || notification.permission !== 'granted') return false; if (recoveryPromise) return recoveryPromise; recoveryPromise = (async () => { @@ -284,6 +320,10 @@ startDayControl.checked = true; return changeStartDay(); } + if (pendingIntent === 'following') { + followingControl.checked = true; + return changeFollowing(); + } return Boolean(await enable()); })(); try { @@ -299,18 +339,21 @@ testControl?.addEventListener('click', testDelivery); deadlineControl?.addEventListener('change', changeDeadline); startDayControl?.addEventListener('change', changeStartDay); + followingControl?.addEventListener('change', changeFollowing); deadlineSnoozeReview?.addEventListener('click', reviewSnoozedDeadlines); configuration = await fetchJson('api/v1/push-subscription'); if (!configuration.available) { control.disabled = true; if (deadlineControl) deadlineControl.disabled = true; if (startDayControl) startDayControl.disabled = true; + if (followingControl) followingControl.disabled = true; status.textContent = 'New update notifications are not available on this server.'; return; } control.checked = Boolean(configuration.subscribed); if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled); if (startDayControl) startDayControl.checked = Boolean(configuration.start_day_enabled); + if (followingControl) followingControl.checked = Boolean(configuration.following_enabled); if (deadlineHour) deadlineHour.value = String(configuration.reminder_hour ?? 9); if (deadlineDays) deadlineDays.value = String(configuration.reminder_days ?? 2); if (startDayHour) startDayHour.value = String(configuration.start_day_reminder_hour ?? 9); @@ -321,8 +364,11 @@ if (startDayStatus) startDayStatus.textContent = configuration.start_day_enabled ? `Start-day reminder enabled for ${formattedHour(configuration.start_day_reminder_hour)} local time.` : 'Start-day reminders are off for this device.'; + if (followingStatus) followingStatus.textContent = configuration.following_enabled + ? 'Following change alerts enabled for this device.' + : 'Following change alerts are off for this device.'; renderDeadlineSnooze(); } - return {init, change, changeDeadline, changeStartDay, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission}; + return {init, change, changeDeadline, changeStartDay, changeFollowing, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission}; }); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 363b1eb..905f9c8 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -649,6 +649,7 @@ self.addEventListener('push', event => { const updateCount = Number(payload.update_count); const unreadCount = typeof payload.unread_count === 'number' ? payload.unread_count : NaN; const deadlineCount = Number(payload.deadline_count); + const followingCount = Number(payload.following_count); const planDate = String(payload.plan_date || ''); if ( route === '#/my-work/start-day' @@ -685,6 +686,23 @@ self.addEventListener('push', event => { )); return; } + if ( + route === '#/my-work/following' + && /^stackchain-following-[0-9a-f]{16}$/.test(tag) + && Number.isSafeInteger(followingCount) + && followingCount > 0 + && followingCount <= 50 + ) { + event.waitUntil(self.registration.showNotification( + followingCount + ' watched item' + (followingCount === 1 ? '' : 's') + ' changed', + { + body: 'Open Following to review the latest activity.', + tag, + data: {route}, + } + )); + return; + } if ( route === '#/my-work/updates' && tag === 'stackchain-update-digest' diff --git a/src/main.py b/src/main.py index 68902de..12409d1 100644 --- a/src/main.py +++ b/src/main.py @@ -55,6 +55,7 @@ from src.passkey_store import PasskeyStore from src.push_notifications import ( PushConfiguration, dispatch_deadline_reminders, + dispatch_following_changes, dispatch_start_day_reminders, dispatch_unread_updates, send_web_push, @@ -131,6 +132,10 @@ async def _start_day_plan_snapshot() -> dict: return await asyncio.to_thread(_today_store().get_start_day_plan, login) +async def _following_push_snapshot() -> dict: + return await get_following(Response()) + + async def _push_poll_loop() -> None: interval = max(5.0, float(os.getenv("STACKCHAIN_PUSH_POLL_SECONDS", "30"))) deadline_interval = max( @@ -173,6 +178,17 @@ async def _push_poll_loop() -> None: max_concurrency=max_concurrency, ) + async def dispatch_following() -> None: + await dispatch_following_changes( + _push_subscription_store, + _push_configuration(), + _following_push_snapshot, + session_statuses=dashboard_auth.managed_session_statuses, + send_timeout_seconds=send_timeout, + lease_seconds=lease_seconds, + max_concurrency=max_concurrency, + ) + async def dispatch_start_day() -> None: await dispatch_start_day_reminders( _push_subscription_store, @@ -186,6 +202,7 @@ async def _push_poll_loop() -> None: channel_tasks = ( asyncio.create_task(_push_channel_loop(dispatch_unread, interval=interval)), + asyncio.create_task(_push_channel_loop(dispatch_following, interval=interval)), asyncio.create_task( _push_channel_loop(dispatch_deadlines, interval=deadline_interval) ), @@ -474,6 +491,10 @@ class StartDayReminderPayload(BaseModel): return value +class FollowingNotificationPayload(BaseModel): + enabled: bool + + StepUpAction = Literal[ "merge_pull", "submit_pull_review", @@ -2322,6 +2343,9 @@ async def push_status(request: Request): start_day_preferences = await asyncio.to_thread( _push_subscription_store.start_day_preferences, device_id ) + following_preferences = await asyncio.to_thread( + _push_subscription_store.following_preferences, device_id + ) delivery_health = await asyncio.to_thread( _push_subscription_store.delivery_health, device_id ) @@ -2337,6 +2361,7 @@ async def push_status(request: Request): "start_day_enabled": start_day_preferences["enabled"], "start_day_timezone": start_day_preferences["timezone"], "start_day_reminder_hour": start_day_preferences["reminder_hour"], + "following_enabled": following_preferences["enabled"], "delivery_health": delivery_health, } @@ -2485,6 +2510,25 @@ async def update_start_day_reminders(payload: StartDayReminderPayload, request: } +@app.put("/api/v1/push-subscription/following") +async def update_following_notifications( + payload: FollowingNotificationPayload, request: Request +): + device_id = await dashboard_auth.session_management_id( + request.state.dashboard_session + ) + if payload.enabled and not await asyncio.to_thread( + _push_subscription_store.is_subscribed, device_id + ): + raise HTTPException(status_code=409, detail="Enable device notifications first") + await asyncio.to_thread( + _push_subscription_store.set_following_preferences, + device_id, + enabled=payload.enabled, + ) + return {"following_enabled": payload.enabled} + + @app.patch("/api/v1/push-subscription/deadlines/snooze") async def snooze_deadline_reminder(request: Request): device_id = await dashboard_auth.session_management_id( diff --git a/src/push_notifications.py b/src/push_notifications.py index c8ebabf..dcda660 100644 --- a/src/push_notifications.py +++ b/src/push_notifications.py @@ -1,4 +1,5 @@ import asyncio +import hashlib import json import secrets import time @@ -99,6 +100,135 @@ async def send_web_push( ) +async def dispatch_following_changes( + store: PushSubscriptionStore, + configuration: PushConfiguration, + following: Callable[[], Awaitable[dict]], + send: Callable[[dict, str], Awaitable[None]] | None = None, + *, + session_statuses: Callable[[list[str]], Awaitable[dict[str, str]]] | None = None, + lease_seconds: float = 60.0, + send_timeout_seconds: float = 10.0, + max_concurrency: int = 8, +) -> int: + """Notify opted-in devices once for each privacy-safe Following change set.""" + if not configuration.enabled: + return 0 + owner = secrets.token_urlsafe(18) + acquired = await asyncio.to_thread( + store.acquire_dispatch_lease, + owner, + channel="following", + now=time.time(), + lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0), + ) + if not acquired: + return 0 + try: + devices = await asyncio.to_thread(store.following_notification_devices) + if not devices: + return 0 + snapshot = await following() + if not isinstance(snapshot, dict) or snapshot.get("complete") is False: + return 0 + changed = [] + for item in snapshot.get("items", []): + if not isinstance(item, dict) or item.get("has_unseen_change") is not True: + continue + repository = item.get("repository") + kind = item.get("kind") + number = item.get("number") + updated_at = item.get("updated_at") + if ( + isinstance(repository, str) + and kind in {"issue", "pull"} + and isinstance(number, int) + and not isinstance(number, bool) + and number > 0 + and isinstance(updated_at, str) + and updated_at + ): + changed.append((repository.lower(), kind, number, updated_at)) + changed.sort() + fingerprint = hashlib.sha256( + json.dumps(changed, separators=(",", ":")).encode() + ).hexdigest() + if not changed: + await asyncio.gather(*( + asyncio.to_thread(store.mark_following_delivered, device.session_id, fingerprint) + for device in devices + )) + return 0 + pending = [device for device in devices if device.delivered_fingerprint != fingerprint] + if not pending: + return 0 + if session_statuses is not None: + try: + statuses = await session_statuses([device.session_id for device in pending]) + except Exception: + return 0 + for device in pending: + if statuses.get(device.session_id) != "active": + await asyncio.to_thread(store.delete_session, device.session_id) + pending = [ + device for device in pending if statuses.get(device.session_id) == "active" + ] + count = min(len(changed), 50) + payload = json.dumps({ + "title": f"{count} watched item{'s' if count != 1 else ''} changed", + "body": "Open Following to review the latest activity.", + "route": "#/my-work/following", + "tag": f"stackchain-following-{fingerprint[:16]}", + "following_count": count, + }, separators=(",", ":")) + semaphore = asyncio.Semaphore(max(1, max_concurrency)) + + async def dispatch_device(device) -> int: + async with semaphore: + still_owner = await asyncio.to_thread( + store.acquire_dispatch_lease, + owner, + channel="following", + now=time.time(), + lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0), + ) + if not still_owner: + return 0 + try: + operation = ( + send(device.subscription, payload) + if send is not None + else send_web_push(device.subscription, payload, configuration) + ) + await asyncio.wait_for(operation, timeout=send_timeout_seconds) + except Exception as error: + status = getattr(getattr(error, "response", None), "status_code", None) + if isinstance(error, UnsafePushEndpoint) or status in {404, 410}: + await asyncio.to_thread(store.delete_session, device.session_id) + else: + await asyncio.to_thread( + store.mark_delivery_failed, + device.session_id, + "following", + _delivery_failure_reason(error), + ) + return 0 + await asyncio.to_thread( + store.mark_delivery_succeeded, device.session_id, "following" + ) + await asyncio.to_thread( + store.mark_following_delivered, device.session_id, fingerprint + ) + return 1 + + results = await asyncio.gather( + *(dispatch_device(device) for device in pending), return_exceptions=True + ) + return sum(result for result in results if isinstance(result, int)) + finally: + await asyncio.to_thread(store.release_dispatch_lease, owner, channel="following") + + async def dispatch_unread_updates( store: PushSubscriptionStore, configuration: PushConfiguration, diff --git a/src/push_subscription_store.py b/src/push_subscription_store.py index 162595a..2de8479 100644 --- a/src/push_subscription_store.py +++ b/src/push_subscription_store.py @@ -52,6 +52,13 @@ class StartDayReminderDevice: delivered_plan_date: str | None +@dataclass(frozen=True) +class FollowingNotificationDevice: + session_id: str + subscription: dict + delivered_fingerprint: str | None + + class DisabledPushSubscriptionStore: """No-persistence store used when Web Push is not configured.""" @@ -81,6 +88,12 @@ class DisabledPushSubscriptionStore: def start_day_reminder_devices(self) -> list[StartDayReminderDevice]: return [] + def following_preferences(self, session_id: str) -> dict: + return {"enabled": False} + + def following_notification_devices(self) -> list[FollowingNotificationDevice]: + return [] + def claim_unseen(self, thread_revisions) -> list[PushDelivery]: return [] @@ -117,6 +130,12 @@ class DisabledPushSubscriptionStore: def mark_start_day_reminder_delivered(self, *args, **kwargs) -> None: return None + def set_following_preferences(self, *args, **kwargs) -> None: + return None + + def mark_following_delivered(self, *args, **kwargs) -> None: + return None + def reconcile_unread(self, *args, **kwargs) -> None: return None @@ -236,6 +255,13 @@ class PushSubscriptionStore: FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id) ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS push_following_preferences ( + session_id TEXT PRIMARY KEY, + enabled INTEGER NOT NULL DEFAULT 0, + delivered_fingerprint TEXT, + FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id) + ON DELETE CASCADE + ); """ ) delivery_columns = { @@ -569,6 +595,46 @@ class PushSubscriptionStore: (plan_date, session_id), ) + def set_following_preferences(self, session_id: str, *, enabled: bool) -> None: + with self._connect() as connection: + connection.execute( + """INSERT INTO push_following_preferences(session_id, enabled) + VALUES (?, ?) + ON CONFLICT(session_id) DO UPDATE SET enabled = excluded.enabled""", + (session_id, int(enabled)), + ) + + def following_preferences(self, session_id: str) -> dict: + with self._connect() as connection: + row = connection.execute( + "SELECT enabled FROM push_following_preferences WHERE session_id = ?", + (session_id,), + ).fetchone() + return {"enabled": bool(row[0]) if row else False} + + def following_notification_devices(self) -> list[FollowingNotificationDevice]: + with self._connect() as connection: + rows = connection.execute( + """SELECT s.session_id, s.subscription_json, p.delivered_fingerprint + FROM push_subscriptions s + JOIN push_following_preferences p ON p.session_id = s.session_id + WHERE p.enabled = 1 ORDER BY s.session_id""" + ).fetchall() + return [ + FollowingNotificationDevice( + row[0], self._open_subscription(row[0], row[1]), row[2] + ) + for row in rows + ] + + def mark_following_delivered(self, session_id: str, fingerprint: str) -> None: + with self._connect() as connection: + connection.execute( + """UPDATE push_following_preferences SET delivered_fingerprint = ? + WHERE session_id = ? AND enabled = 1""", + (fingerprint, session_id), + ) + def claim_unseen( self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]] ) -> list[PushDelivery]: diff --git a/tests/e2e/test_mobile_following_release.py b/tests/e2e/test_mobile_following_release.py index 7d56246..8ae7a3f 100644 --- a/tests/e2e/test_mobile_following_release.py +++ b/tests/e2e/test_mobile_following_release.py @@ -29,6 +29,12 @@ def test_following_queue_is_phone_usable_at_narrow_viewport(viewport): row = page.locator('[data-mobile-queue="following"]') expect(row).to_have_count(1) expect(row).to_contain_text("Issues and pull requests you watch") + following_alert = page.locator('label[for="push-following"]') + expect(following_alert).to_have_count(1) + expect(following_alert).to_contain_text("Notify me when Following changes") + page.locator("#work-settings-toggle").click() + expect(following_alert).to_be_visible() + assert following_alert.bounding_box()["height"] >= 44 page.evaluate("""() => { globalThis.fetch = async () => ({ ok:true, diff --git a/tests/test_push_frontend.py b/tests/test_push_frontend.py index 067cc9d..701dd3f 100644 --- a/tests/test_push_frontend.py +++ b/tests/test_push_frontend.py @@ -4,6 +4,8 @@ from pathlib import Path MODULE = Path(__file__).parents[1] / "frontend" / "push-notifications.js" +INDEX = MODULE.parent / "index.html" +DASHBOARD = MODULE.parent / "dashboard.js" def run_scenario(script: str) -> dict: @@ -27,6 +29,11 @@ const startDayControl = { }; const startDayHour = {value:'9', disabled:false}; const startDayStatus = {set textContent(value) { state.startDayText = value; }, get textContent() { return state.startDayText; }}; +const followingControl = { + checked:false, disabled:false, + addEventListener:(_name, callback) => state.followingChange = callback, +}; +const followingStatus = {set textContent(value) { state.followingText = value; }, get textContent() { return state.followingText; }}; const status = {set textContent(value) { state.text = value; }, get textContent() { return state.text; }}; const testControl = {hidden:true, disabled:false, addEventListener:(_name, callback) => state.testDelivery = callback}; const deadlineSnooze = {hidden:true}; @@ -40,6 +47,7 @@ const registration = {pushManager:{ const feature = createPushNotifications({ control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays, startDayControl, startDayStatus, startDayHour, + followingControl, followingStatus, deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview, onReviewDeadlines:() => { state.reviewed = true; }, notification: state.notification = {permission:'default', requestPermission:async () => { state.prompts += 1; state.notification.permission = state.permission || 'granted'; return state.notification.permission; }}, @@ -77,6 +85,35 @@ process.stdout.write(JSON.stringify(state)); assert result["text"] == "New update notifications enabled for this device." +def test_following_alert_toggle_is_opt_in_and_does_not_change_other_channels(): + result = run_scenario(""" +state.server = {available:true,subscribed:true,following_enabled:false,deadline_enabled:true,start_day_enabled:true,public_key:'AQID'}; +state.current = existing; +await feature.init(); +followingControl.checked = true; +await state.followingChange(); +process.stdout.write(JSON.stringify({requests:state.requests, following:followingControl.checked, deadline:deadlineControl.checked, startDay:startDayControl.checked, text:state.followingText})); +""") + + assert result["following"] is True + assert result["deadline"] is True + assert result["startDay"] is True + assert result["requests"][-1][0:2] == ["api/v1/push-subscription/following", "PUT"] + assert json.loads(result["requests"][-1][2]) == {"enabled": True} + assert result["text"] == "Following change alerts enabled for this device." + + +def test_device_settings_render_and_wire_the_following_alert_preference(): + index = INDEX.read_text() + dashboard = DASHBOARD.read_text() + + assert 'for="push-following"' in index + assert 'id="push-following" type="checkbox"' in index + assert 'id="push-following-status" role="status" aria-live="polite"' in index + assert "followingControl:qs('#push-following')" in dashboard + assert "followingStatus:qs('#push-following-status')" in dashboard + + def test_degraded_device_can_run_a_test_notification_and_show_recovery(): result = run_scenario(""" state.server = {available:true,subscribed:true,public_key:'AQID',delivery_health:{unread:{state:'degraded',consecutive_failures:3,reason:'timeout'}}}; diff --git a/tests/test_push_notifications.py b/tests/test_push_notifications.py index 1be7dcf..e17d784 100644 --- a/tests/test_push_notifications.py +++ b/tests/test_push_notifications.py @@ -12,6 +12,7 @@ import requests from src import dashboard_auth, gitea_proxy, main from src.push_notifications import ( PushConfiguration, + dispatch_following_changes, dispatch_unread_updates, send_web_push, ) @@ -41,6 +42,98 @@ def test_dispatch_lease_is_exclusive_recoverable_and_owner_fenced(tmp_path): assert second.release_dispatch_lease("worker-b") is True +def test_following_alert_preferences_are_opt_in_and_checkpoint_each_device(tmp_path): + store = PushSubscriptionStore(tmp_path / "push.sqlite3") + for session_id in ("session-a", "session-b"): + store.upsert(session_id, { + "endpoint": f"https://push.example/{session_id}", + "keys": {"p256dh": "public-key", "auth": "auth-secret"}, + }) + + assert store.following_preferences("session-a") == {"enabled": False} + store.set_following_preferences("session-a", enabled=True) + + devices = store.following_notification_devices() + assert [(device.session_id, device.delivered_fingerprint) for device in devices] == [ + ("session-a", None) + ] + store.mark_following_delivered("session-a", "revision-fingerprint") + assert store.following_notification_devices()[0].delivered_fingerprint == "revision-fingerprint" + assert store.following_preferences("session-b") == {"enabled": False} + + +@pytest.mark.anyio +async def test_following_dispatch_is_private_deduplicated_and_session_bound(tmp_path): + store = PushSubscriptionStore(tmp_path / "push.sqlite3") + for session_id in ("active", "revoked", "disabled"): + store.upsert(session_id, { + "endpoint": f"https://push.example/{session_id}", + "keys": {"p256dh": "public-key", "auth": "auth-secret"}, + }) + store.set_following_preferences("active", enabled=True) + store.set_following_preferences("revoked", enabled=True) + sent = [] + + async def following(): + return {"items": [{ + "repository": "secret/project", "kind": "issue", "number": 42, + "title": "Sensitive acquisition", "updated_at": "2026-08-23T17:00:00Z", + "has_unseen_change": True, + }]} + + async def send(subscription, payload): + sent.append((subscription["endpoint"], json.loads(payload))) + + async def statuses(session_ids): + return {session_id: ("active" if session_id == "active" else "revoked") for session_id in session_ids} + + configuration = PushConfiguration("public", "private", "mailto:ops@example.com") + assert await dispatch_following_changes( + store, configuration, following, send, session_statuses=statuses + ) == 1 + assert sent[0][0].endswith("/active") + assert sent[0][1] == { + "title": "1 watched item changed", + "body": "Open Following to review the latest activity.", + "route": "#/my-work/following", + "tag": sent[0][1]["tag"], + "following_count": 1, + } + assert sent[0][1]["tag"].startswith("stackchain-following-") + assert "secret" not in json.dumps(sent[0][1]).lower() + assert "acquisition" not in json.dumps(sent[0][1]).lower() + assert await dispatch_following_changes( + store, configuration, following, send, session_statuses=statuses + ) == 0 + assert len(sent) == 1 + assert store.subscription_for_session("revoked") is None + + +@pytest.mark.anyio +async def test_authenticated_device_controls_following_alerts_independently(tmp_path, monkeypatch): + store = PushSubscriptionStore(tmp_path / "push.sqlite3") + store.upsert("session-a", { + "endpoint": "https://push.example/session-a", + "keys": {"p256dh": "public-key", "auth": "auth-secret"}, + }) + monkeypatch.setattr(main, "_push_subscription_store", store) + + async def management_id(_session): + return "session-a" + + monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id) + request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object())) + + result = await main.update_following_notifications( + main.FollowingNotificationPayload(enabled=True), request + ) + + assert result == {"following_enabled": True} + assert (await main.push_status(request))["following_enabled"] is True + assert store.deadline_preferences("session-a")["enabled"] is False + assert store.start_day_preferences("session-a")["enabled"] is False + + def test_subscription_store_uses_private_filesystem_permissions(tmp_path): state_dir = tmp_path / "push-state" previous_umask = os.umask(0) @@ -415,6 +508,7 @@ async def test_push_poll_authorizes_delivery_against_managed_session(monkeypatch monkeypatch.setenv("STACKCHAIN_PUSH_MAX_INDIVIDUAL_NOTIFICATIONS", "4") monkeypatch.setattr(main.asyncio, "sleep", no_wait) monkeypatch.setattr(main, "dispatch_unread_updates", stop_after_capture) + monkeypatch.setattr(main, "dispatch_following_changes", hold_dispatch) monkeypatch.setattr(main, "dispatch_deadline_reminders", hold_dispatch) monkeypatch.setattr(main, "dispatch_start_day_reminders", hold_dispatch) @@ -437,8 +531,8 @@ async def test_push_poll_still_dispatches_deadlines_when_unread_dispatch_fails(m async def no_wait(_seconds): nonlocal sleeps sleeps += 1 - if sleeps <= 3: - if sleeps == 3: + if sleeps <= 4: + if sleeps == 4: first_tick.set() await first_tick.wait() else: @@ -454,15 +548,19 @@ async def test_push_poll_still_dispatches_deadlines_when_unread_dispatch_fails(m async def dispatch_start_day(*_args, **_kwargs): calls.append("start-day") + async def dispatch_following(*_args, **_kwargs): + calls.append("following") + monkeypatch.setattr(main.asyncio, "sleep", no_wait) monkeypatch.setattr(main, "dispatch_unread_updates", fail_unread) + monkeypatch.setattr(main, "dispatch_following_changes", dispatch_following) monkeypatch.setattr(main, "dispatch_deadline_reminders", dispatch_deadlines) monkeypatch.setattr(main, "dispatch_start_day_reminders", dispatch_start_day) with pytest.raises(asyncio.CancelledError): await main._push_poll_loop() - assert sorted(calls) == ["deadline", "start-day", "unread"] + assert sorted(calls) == ["deadline", "following", "start-day", "unread"] @pytest.mark.anyio @@ -487,6 +585,7 @@ async def test_push_poll_deadlines_continue_while_unread_dispatch_is_blocked(mon monkeypatch.setattr(main.asyncio, "sleep", no_wait) monkeypatch.setattr(main, "dispatch_unread_updates", blocked_unread) + monkeypatch.setattr(main, "dispatch_following_changes", dispatch_start_day) monkeypatch.setattr(main, "dispatch_deadline_reminders", dispatch_deadlines) monkeypatch.setattr(main, "dispatch_start_day_reminders", dispatch_start_day) @@ -513,7 +612,7 @@ async def test_push_poll_uses_a_lower_independent_deadline_cadence(monkeypatch): await main._push_poll_loop() - assert sorted(intervals) == [30.0, 600.0, 600.0] + assert sorted(intervals) == [30.0, 30.0, 600.0, 600.0] @pytest.mark.anyio @@ -1282,6 +1381,7 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe( "start_day_enabled": False, "start_day_timezone": "UTC", "start_day_reminder_hour": 9, + "following_enabled": False, "delivery_health": {}, } assert await main.subscribe_push(payload, request) == {"subscribed": True} diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 1cfb479..4f589fc 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -1797,3 +1797,27 @@ def test_client_error_navigation_is_not_hidden_by_cached_shell(): assert result["status"] == 401 assert result["body"] == "network" assert result["state"]["puts"] == [] + + +def test_following_push_uses_generic_copy_and_opens_the_following_route(): + result = run_worker_scenario(""" +await dispatchPush({ + title:'leaked title', body:'leaked body', route:'#/my-work/following', + tag:'stackchain-following-0123456789abcdef', following_count:2, +}); +await dispatchNotificationClick( + '#/my-work/following', '', null, 'stackchain-following-0123456789abcdef' +); +console.log(JSON.stringify({notifications:state.notifications,opened:state.opened,closed:state.notificationClosed})); +""") + + assert result["notifications"] == [{ + "title": "2 watched items changed", + "options": { + "body": "Open Following to review the latest activity.", + "tag": "stackchain-following-0123456789abcdef", + "data": {"route": "#/my-work/following"}, + }, + }] + assert result["opened"] == ["https://forge.example/dashboard/#/my-work/following"] + assert result["closed"] is True diff --git a/tests/test_start_day_reminders.py b/tests/test_start_day_reminders.py index 95508c8..d60db48 100644 --- a/tests/test_start_day_reminders.py +++ b/tests/test_start_day_reminders.py @@ -208,6 +208,7 @@ async def test_push_poll_applies_configured_concurrency_to_start_day( monkeypatch.setenv("STACKCHAIN_PUSH_MAX_CONCURRENCY", "3") monkeypatch.setattr(main.asyncio, "sleep", no_wait) monkeypatch.setattr(main, "dispatch_unread_updates", hold_dispatch) + monkeypatch.setattr(main, "dispatch_following_changes", hold_dispatch) monkeypatch.setattr(main, "dispatch_deadline_reminders", hold_dispatch) monkeypatch.setattr(main, "dispatch_start_day_reminders", capture_start_day)