feat: notify devices when Following changes (Closes #1313)
CI / lint (pull_request) Successful in 3m45s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 6m8s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-23 17:16:28 +00:00
1 parent 187b0decae
commit 0efd44fd90
13 files changed
+491 -7

No files matched your search

@@ -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,
+37
View File
@@ -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'}}};
+104 -4
View File
@@ -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}
+24
View File
@@ -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
+1
View File
@@ -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)