stackchain-dashboard/tests/test_push_frontend.py
timmy 741d545199
All checks were successful
CI / lint (pull_request) Successful in 1m26s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
feat: configure deadline reminder time in device setup (Closes #715)
2026-08-13 06:57:39 +00:00

153 lines
6.8 KiB
Python

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 deadlineControl = {
checked:false, disabled:false,
addEventListener:(_name, callback) => state.deadlineChange = callback,
};
const deadlineHour = {value:'9', disabled:false, addEventListener:(_name, callback) => state.deadlineHourChange = callback};
const deadlineStatus = {set textContent(value) { state.deadlineText = value; }, get textContent() { return state.deadlineText; }};
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, deadlineControl, deadlineStatus, deadlineHour,
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_deadline_opt_in_reuses_subscription_and_sends_local_timezone_without_second_prompt():
result = run_scenario("""
state.current = existing;
state.server = {available:true,subscribed:true,deadline_enabled:false,public_key:'AQID'};
await feature.init();
deadlineControl.checked = true;
await state.deadlineChange();
process.stdout.write(JSON.stringify(state));
""")
assert result["prompts"] == 0
assert result["subscriptions"] == []
assert result["requests"][-1][0:2] == ["api/v1/push-subscription/deadlines", "PUT"]
body = json.loads(result["requests"][-1][2])
assert body["enabled"] is True
assert body["reminder_hour"] == 9
assert isinstance(body["timezone"], str) and body["timezone"]
assert result["deadlineText"] == "Deadline reminders enabled for 09:00 local time."
def test_deadline_setup_subscribes_once_and_persists_selected_local_hour():
result = run_scenario("""
state.server = {available:true,subscribed:false,deadline_enabled:false,reminder_hour:9,public_key:'AQID'};
await feature.init();
deadlineHour.value = '8';
await feature.enableDeadline();
process.stdout.write(JSON.stringify(state));
""")
assert result["prompts"] == 1
assert len(result["subscriptions"]) == 1
assert [request[0] for request in result["requests"][-2:]] == [
"api/v1/push-subscription", "api/v1/push-subscription/deadlines",
]
body = json.loads(result["requests"][-1][2])
assert body["enabled"] is True
assert body["reminder_hour"] == 8
assert result["deadlineText"] == "Deadline reminders enabled for 08:00 local time."
def test_deadline_setup_restores_confirmed_hour_and_denial_stays_incomplete():
restored = run_scenario("""
state.server = {available:true,subscribed:true,deadline_enabled:true,reminder_hour:17,public_key:'AQID'};
state.current = existing;
await feature.init();
process.stdout.write(JSON.stringify({hour:deadlineHour.value, checked:deadlineControl.checked, readiness:feature.deadlineReadiness()}));
""")
assert restored == {
"hour": "17", "checked": True,
"readiness": {"state": "complete", "detail": "Deadline reminders enabled for 17:00 local time."},
}
denied = run_scenario("""
state.server = {available:true,subscribed:false,deadline_enabled:false,reminder_hour:9,public_key:'AQID'};
state.permission = 'denied';
await feature.init();
deadlineHour.value = '8';
await feature.enableDeadline();
process.stdout.write(JSON.stringify({checked:deadlineControl.checked, readiness:feature.deadlineReadiness(), requests:state.requests}));
""")
assert denied["checked"] is False
assert denied["readiness"]["state"] == "incomplete"
assert "blocked" in denied["readiness"]["detail"].lower()
assert [request[0] for request in denied["requests"]] == ["api/v1/push-subscription"]
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 'id="push-deadlines"' in html
assert 'id="push-deadline-hour"' in html
assert 'id="push-deadline-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