feat: notify devices when Following changes (Closes #1313)
This commit is contained in:
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,
|
||||
|
||||
@@ -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'}}};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user