feat: notify mobile operators of unread updates (Closes #549)
This commit is contained in:
1 parent
83702e75b0
commit
6e90e3fa9e
15 files changed
+702
-7
No files matched your search
@@ -45,7 +45,7 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
|
||||
def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
first = build_frontend(FRONTEND)
|
||||
|
||||
assert set(first.feature_bundles) == {"issue-capture", "pull-workflow"}
|
||||
assert set(first.feature_bundles) == {"issue-capture", "pull-workflow", "push-notifications"}
|
||||
capture = first.feature_bundles["issue-capture"]
|
||||
pull_workflow = first.feature_bundles["pull-workflow"]
|
||||
assert b"function createIssueCapture" not in first.runtime_bytes
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODULE = Path(__file__).parents[1] / "frontend" / "push-notifications.js"
|
||||
|
||||
|
||||
def run_scenario(script: str) -> dict:
|
||||
harness = r"""
|
||||
const createPushNotifications = require(__MODULE__);
|
||||
const state = { prompts:0, requests:[], subscriptions:[], text:'' };
|
||||
const control = {
|
||||
checked:false, disabled:false,
|
||||
addEventListener:(_name, callback) => state.change = callback,
|
||||
};
|
||||
const status = {set textContent(value) { state.text = value; }, get textContent() { return state.text; }};
|
||||
const existing = {endpoint:'https://push.example/device', toJSON() { return {endpoint:this.endpoint, keys:{p256dh:'key',auth:'auth'}}; }};
|
||||
const registration = {pushManager:{
|
||||
getSubscription: async () => state.current || null,
|
||||
subscribe: async options => { state.subscriptions.push(options); state.current=existing; return existing; },
|
||||
}};
|
||||
const feature = createPushNotifications({
|
||||
control, status,
|
||||
notification: {permission:'default', requestPermission:async () => { state.prompts += 1; return state.permission || 'granted'; }},
|
||||
serviceWorker: {ready:Promise.resolve(registration)},
|
||||
fetchJson: async (url, options={}) => { state.requests.push([url,options.method || 'GET',options.body || '']); return state.server || {available:true,subscribed:false,public_key:'AQID'}; },
|
||||
});
|
||||
(async () => { __SCENARIO__ })().catch(error => { console.error(error); process.exit(1); });
|
||||
""".replace("__MODULE__", json.dumps(str(MODULE))).replace("__SCENARIO__", script)
|
||||
completed = subprocess.run(
|
||||
["node", "-e", harness], capture_output=True, check=True, text=True
|
||||
)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_initialization_reports_availability_without_prompting_for_permission():
|
||||
result = run_scenario("await feature.init(); process.stdout.write(JSON.stringify(state));")
|
||||
|
||||
assert result["prompts"] == 0
|
||||
assert result["requests"] == [["api/v1/push-subscription", "GET", ""]]
|
||||
assert result["text"] == "New update notifications are off for this device."
|
||||
|
||||
|
||||
def test_user_gesture_subscribes_device_and_reports_enabled_state():
|
||||
result = run_scenario("""
|
||||
await feature.init();
|
||||
control.checked = true;
|
||||
await state.change();
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
""")
|
||||
|
||||
assert result["prompts"] == 1
|
||||
assert result["subscriptions"][0]["userVisibleOnly"] is True
|
||||
assert result["requests"][-1][0:2] == ["api/v1/push-subscription", "PUT"]
|
||||
assert json.loads(result["requests"][-1][2])["endpoint"] == "https://push.example/device"
|
||||
assert result["text"] == "New update notifications enabled for this device."
|
||||
|
||||
|
||||
def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller():
|
||||
root = MODULE.parents[1]
|
||||
html = (root / "frontend" / "index.html").read_text()
|
||||
dashboard = (root / "frontend" / "dashboard.js").read_text()
|
||||
worker = (root / "frontend" / "service-worker.js").read_text()
|
||||
css = (root / "frontend" / "dashboard.css").read_text()
|
||||
requirements = (root / "requirements.txt").read_text()
|
||||
readme = (root / "README.md").read_text()
|
||||
|
||||
assert 'id="push-updates"' in html
|
||||
assert 'id="push-update-status"' in html
|
||||
assert '<script src="static/push-notifications.js"></script>' in html
|
||||
assert "createPushNotifications({" in dashboard
|
||||
assert "BASE + 'static/push-notifications.js'" in worker
|
||||
assert ".push-update-control" in css and "min-height:44px" in css
|
||||
assert "pywebpush==" in requirements
|
||||
assert "STACKCHAIN_VAPID_PUBLIC_KEY" in readme
|
||||
assert "STACKCHAIN_VAPID_PRIVATE_KEY" in readme
|
||||
@@ -0,0 +1,166 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.push_notifications import PushConfiguration, dispatch_unread_updates
|
||||
from src.push_subscription_store import PushSubscriptionStore
|
||||
|
||||
|
||||
def test_subscription_store_delivers_each_unread_thread_once_per_device(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
subscription = {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
}
|
||||
|
||||
store.upsert("session-a", subscription)
|
||||
|
||||
first = store.claim_unseen({41, 42})
|
||||
store.mark_delivered("session-a", first[0].thread_ids)
|
||||
second = store.claim_unseen({41, 42})
|
||||
|
||||
assert [(item.session_id, item.thread_ids) for item in first] == [
|
||||
("session-a", (41, 42))
|
||||
]
|
||||
assert second == []
|
||||
|
||||
|
||||
def test_replacing_subscription_resets_delivery_cursor_and_revocation_removes_device(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
original = {
|
||||
"endpoint": "https://push.example/original",
|
||||
"keys": {"p256dh": "old-key", "auth": "old-auth"},
|
||||
}
|
||||
replacement = {
|
||||
"endpoint": "https://push.example/replacement",
|
||||
"keys": {"p256dh": "new-key", "auth": "new-auth"},
|
||||
}
|
||||
store.upsert("session-a", original)
|
||||
store.claim_unseen({9})
|
||||
|
||||
store.upsert("session-a", replacement)
|
||||
delivery = store.claim_unseen({9})
|
||||
store.delete_session("session-a")
|
||||
|
||||
assert delivery[0].subscription == replacement
|
||||
assert delivery[0].thread_ids == (9,)
|
||||
assert store.claim_unseen({10}) == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dispatch_sends_one_privacy_safe_deep_link_per_new_thread(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("session-a", {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
sent = []
|
||||
|
||||
async def unread():
|
||||
return {"items": [
|
||||
{"notification_id": 42, "repository": "private/repo", "title": "Secret title"},
|
||||
]}
|
||||
|
||||
async def send(subscription, payload):
|
||||
sent.append((subscription, json.loads(payload)))
|
||||
|
||||
config = PushConfiguration("public-vapid", "private-vapid", "mailto:ops@example.com")
|
||||
assert await dispatch_unread_updates(store, config, unread, send) == 1
|
||||
assert await dispatch_unread_updates(store, config, unread, send) == 0
|
||||
assert sent[0][1] == {
|
||||
"title": "New work update",
|
||||
"body": "Tap to review it in Stackchain.",
|
||||
"route": "#/my-work/update/42",
|
||||
"tag": "stackchain-update-42",
|
||||
}
|
||||
assert "private/repo" not in json.dumps(sent)
|
||||
assert "Secret title" not in json.dumps(sent)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dispatch_removes_an_expired_push_endpoint(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("session-a", {
|
||||
"endpoint": "https://push.example/expired",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
|
||||
class Expired(Exception):
|
||||
response = SimpleNamespace(status_code=410)
|
||||
|
||||
async def unread():
|
||||
return {"items": [{"notification_id": 8}]}
|
||||
|
||||
async def send(_subscription, _payload):
|
||||
raise Expired()
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
assert await dispatch_unread_updates(store, config, unread, send) == 0
|
||||
assert store.is_subscribed("session-a") is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(tmp_path, monkeypatch):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
monkeypatch.setattr(main, "_push_subscription_store", store)
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_push_configuration",
|
||||
lambda: PushConfiguration("public-vapid", "private-vapid", "mailto:ops@example.com"),
|
||||
)
|
||||
async def management_id(_session):
|
||||
return "session-a"
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
||||
|
||||
async def no_unread():
|
||||
return {"items": []}
|
||||
|
||||
monkeypatch.setattr(main, "notifications", no_unread)
|
||||
request = SimpleNamespace(state=SimpleNamespace(
|
||||
dashboard_session=SimpleNamespace(session_id="session-a")
|
||||
))
|
||||
payload = main.PushSubscriptionPayload(
|
||||
endpoint="https://push.example/device-a",
|
||||
keys={"p256dh": "public-key", "auth": "auth-secret"},
|
||||
)
|
||||
|
||||
assert await main.push_status(request) == {
|
||||
"available": True, "subscribed": False, "public_key": "public-vapid"
|
||||
}
|
||||
assert await main.subscribe_push(payload, request) == {"subscribed": True}
|
||||
assert (await main.push_status(request))["subscribed"] is True
|
||||
assert await main.unsubscribe_push(request) == {"subscribed": False}
|
||||
assert (await main.push_status(request))["subscribed"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_subscription_fails_closed_when_existing_unread_baseline_is_unavailable(tmp_path, monkeypatch):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
monkeypatch.setattr(main, "_push_subscription_store", store)
|
||||
monkeypatch.setattr(
|
||||
main, "_push_configuration",
|
||||
lambda: PushConfiguration("public", "private", "mailto:ops@example.com"),
|
||||
)
|
||||
|
||||
async def management_id(_session):
|
||||
return "device-a"
|
||||
|
||||
async def unavailable():
|
||||
raise RuntimeError("Gitea unavailable")
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
||||
monkeypatch.setattr(main, "notifications", unavailable)
|
||||
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
|
||||
payload = main.PushSubscriptionPayload(
|
||||
endpoint="https://push.example/device-a",
|
||||
keys={"p256dh": "public-key", "auth": "auth-secret"},
|
||||
)
|
||||
|
||||
with pytest.raises(main.HTTPException) as raised:
|
||||
await main.subscribe_push(payload, request)
|
||||
|
||||
assert raised.value.status_code == 503
|
||||
assert store.is_subscribed("device-a") is False
|
||||
@@ -112,6 +112,14 @@ async function dispatchNotificationClick(route) {{
|
||||
}});
|
||||
if (pending) await pending;
|
||||
}}
|
||||
async function dispatchPush(payload) {{
|
||||
let pending;
|
||||
listeners.push({{
|
||||
data: {{json: () => payload}},
|
||||
waitUntil: promise => {{ pending = promise; }},
|
||||
}});
|
||||
if (pending) await pending;
|
||||
}}
|
||||
(async () => {{
|
||||
{scenario}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
@@ -373,6 +381,33 @@ def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_rou
|
||||
assert result["notificationClosed"] is True
|
||||
|
||||
|
||||
def test_inbound_push_renders_generic_update_and_tap_opens_existing_workflow():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
await dispatchPush({
|
||||
title:'New work update', body:'Tap to review it in Stackchain.',
|
||||
tag:'stackchain-update-42', route:'#/my-work/update/42',
|
||||
repository:'must-not-render',
|
||||
});
|
||||
await dispatchNotificationClick('#/my-work/update/42');
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["notifications"] == [{
|
||||
"title": "New work update",
|
||||
"options": {
|
||||
"body": "Tap to review it in Stackchain.",
|
||||
"tag": "stackchain-update-42",
|
||||
"data": {"route": "#/my-work/update/42"},
|
||||
},
|
||||
}]
|
||||
assert result["opened"] == [
|
||||
"https://forge.example/dashboard/#/my-work/update/42"
|
||||
]
|
||||
assert "must-not-render" not in json.dumps(result["notifications"])
|
||||
|
||||
|
||||
def test_background_mutations_obtain_session_bound_csrf_proof():
|
||||
source = WORKER.read_text()
|
||||
|
||||
@@ -470,6 +505,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/mobile-search-viewport.js",
|
||||
"/dashboard/static/mobile-composer-viewport.js",
|
||||
"/dashboard/static/mention-composer.js",
|
||||
"/dashboard/static/push-notifications.js",
|
||||
"/dashboard/static/background-issue-sync.js",
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user