feat: send mobile deadline reminders (Closes #709)
This commit is contained in:
1 parent
4209834d6d
commit
5e6328a8d0
12 files changed
+540
-5
No files matched your search
@@ -0,0 +1,162 @@
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from src import gitea_proxy
|
||||
from src.push_notifications import PushConfiguration, dispatch_deadline_reminders
|
||||
from src.push_subscription_store import PushSubscriptionStore
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deadline_reminder_sends_one_private_local_day_digest_and_deduplicates(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("device-a", {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_deadline_preferences(
|
||||
"device-a", enabled=True, timezone="America/New_York", reminder_hour=9
|
||||
)
|
||||
sent = []
|
||||
|
||||
async def assigned():
|
||||
return {
|
||||
"complete": True,
|
||||
"items": [
|
||||
{
|
||||
"id": 42,
|
||||
"title": "Private launch plan",
|
||||
"repository": {"full_name": "private/repo"},
|
||||
"due_date": "2026-08-14T12:00:00Z",
|
||||
},
|
||||
{"id": 43, "due_date": "2026-08-20T12:00:00Z"},
|
||||
],
|
||||
}
|
||||
|
||||
async def send(_subscription, payload):
|
||||
sent.append(json.loads(payload))
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
now = datetime(2026, 8, 13, 13, 5, tzinfo=timezone.utc)
|
||||
|
||||
assert await dispatch_deadline_reminders(store, config, assigned, send, now=now) == 1
|
||||
assert await dispatch_deadline_reminders(store, config, assigned, send, now=now) == 0
|
||||
assert sent == [{
|
||||
"title": "1 deadline needs attention",
|
||||
"body": "Open Agenda to review or replan it.",
|
||||
"route": "#/my-work/agenda",
|
||||
"tag": "stackchain-deadline-digest-2026-08-13",
|
||||
"deadline_count": 1,
|
||||
}]
|
||||
assert "Private launch plan" not in json.dumps(sent)
|
||||
assert "private/repo" not in json.dumps(sent)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deadline_reminder_fails_closed_for_incomplete_snapshot_and_before_local_hour(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("device-a", {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_deadline_preferences(
|
||||
"device-a", enabled=True, timezone="America/Los_Angeles", reminder_hour=9
|
||||
)
|
||||
sent = []
|
||||
|
||||
async def incomplete():
|
||||
return {"complete": False, "items": [{"id": 42, "due_date": "2026-08-14T12:00:00Z"}]}
|
||||
|
||||
async def complete():
|
||||
return {"complete": True, "items": [{"id": 42, "due_date": "2026-08-14T12:00:00Z"}]}
|
||||
|
||||
async def send(_subscription, payload):
|
||||
sent.append(payload)
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
assert await dispatch_deadline_reminders(
|
||||
store, config, incomplete, send,
|
||||
now=datetime(2026, 8, 13, 18, 0, tzinfo=timezone.utc),
|
||||
) == 0
|
||||
assert await dispatch_deadline_reminders(
|
||||
store, config, complete, send,
|
||||
now=datetime(2026, 8, 13, 15, 0, tzinfo=timezone.utc),
|
||||
) == 0
|
||||
assert sent == []
|
||||
|
||||
|
||||
def test_deadline_preferences_persist_on_the_existing_device_subscription(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("device-a", {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
|
||||
store.set_deadline_preferences(
|
||||
"device-a", enabled=True, timezone="Europe/London", reminder_hour=8
|
||||
)
|
||||
|
||||
assert store.deadline_preferences("device-a") == {
|
||||
"enabled": True,
|
||||
"timezone": "Europe/London",
|
||||
"reminder_hour": 8,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_deadline_snapshot_is_pagination_complete(monkeypatch):
|
||||
pages = {
|
||||
1: {"items": [{"id": 1}], "has_more": True},
|
||||
2: {"items": [{"id": 2}], "has_more": False},
|
||||
}
|
||||
|
||||
async def work_page(stream, page, limit):
|
||||
assert stream == "issue"
|
||||
assert limit == 50
|
||||
return pages[page]
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", work_page)
|
||||
|
||||
assert await gitea_proxy.assigned_issue_snapshot() == {
|
||||
"items": [{"id": 1}, {"id": 2}],
|
||||
"complete": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_competing_workers_send_one_deadline_digest(tmp_path):
|
||||
path = tmp_path / "push.sqlite3"
|
||||
first = PushSubscriptionStore(path)
|
||||
second = PushSubscriptionStore(path)
|
||||
first.upsert("device-a", {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
first.set_deadline_preferences(
|
||||
"device-a", enabled=True, timezone="UTC", reminder_hour=9
|
||||
)
|
||||
sending = __import__("asyncio").Event()
|
||||
release = __import__("asyncio").Event()
|
||||
sent = []
|
||||
|
||||
async def assigned():
|
||||
return {"complete": True, "items": [{"id": 1, "due_date": "2026-08-14T00:00:00Z"}]}
|
||||
|
||||
async def send(_subscription, payload):
|
||||
sent.append(payload)
|
||||
sending.set()
|
||||
await release.wait()
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
now = datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc)
|
||||
active = __import__("asyncio").create_task(
|
||||
dispatch_deadline_reminders(first, config, assigned, send, now=now)
|
||||
)
|
||||
await sending.wait()
|
||||
competing = await dispatch_deadline_reminders(second, config, assigned, send, now=now)
|
||||
release.set()
|
||||
|
||||
assert competing == 0
|
||||
assert await active == 1
|
||||
assert len(sent) == 1
|
||||
@@ -14,6 +14,11 @@ 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 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:{
|
||||
@@ -21,7 +26,7 @@ const registration = {pushManager:{
|
||||
subscribe: async options => { state.subscriptions.push(options); state.current=existing; return existing; },
|
||||
}};
|
||||
const feature = createPushNotifications({
|
||||
control, status,
|
||||
control, status, deadlineControl, deadlineStatus,
|
||||
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'}; },
|
||||
@@ -57,6 +62,26 @@ process.stdout.write(JSON.stringify(state));
|
||||
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 9:00 local time."
|
||||
|
||||
|
||||
def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller():
|
||||
root = MODULE.parents[1]
|
||||
html = (root / "frontend" / "index.html").read_text()
|
||||
@@ -68,6 +93,8 @@ def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller():
|
||||
|
||||
assert 'id="push-updates"' in html
|
||||
assert 'id="push-update-status"' in html
|
||||
assert 'id="push-deadlines"' 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
|
||||
|
||||
@@ -819,7 +819,12 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
|
||||
)
|
||||
|
||||
assert await main.push_status(request) == {
|
||||
"available": True, "subscribed": False, "public_key": "public-vapid"
|
||||
"available": True,
|
||||
"subscribed": False,
|
||||
"public_key": "public-vapid",
|
||||
"deadline_enabled": False,
|
||||
"timezone": "UTC",
|
||||
"reminder_hour": 9,
|
||||
}
|
||||
assert await main.subscribe_push(payload, request) == {"subscribed": True}
|
||||
assert (await main.push_status(request))["subscribed"] is True
|
||||
@@ -827,6 +832,32 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
|
||||
assert (await main.push_status(request))["subscribed"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_device_can_enable_deadline_reminders(tmp_path, monkeypatch):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("session-a", {
|
||||
"endpoint": "https://push.example/device-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()))
|
||||
payload = main.DeadlineReminderPayload(
|
||||
enabled=True, timezone="America/New_York", reminder_hour=9
|
||||
)
|
||||
|
||||
assert await main.update_deadline_reminders(payload, request) == {
|
||||
"deadline_enabled": True,
|
||||
"timezone": "America/New_York",
|
||||
"reminder_hour": 9,
|
||||
}
|
||||
assert store.deadline_preferences("session-a")["enabled"] is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_subscription_rejects_an_unsafe_endpoint_before_persistence(tmp_path, monkeypatch):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
|
||||
@@ -459,6 +459,32 @@ def test_update_digest_push_opens_unread_inbox_without_item_actions_or_private_c
|
||||
assert "must-not-render" not in json.dumps(result["notifications"])
|
||||
|
||||
|
||||
def test_deadline_digest_push_opens_agenda_without_rendering_private_copy():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
await dispatchPush({
|
||||
title:'must-not-render', body:'private details must-not-render',
|
||||
tag:'stackchain-deadline-digest-2026-08-13', route:'#/my-work/agenda', deadline_count:3,
|
||||
});
|
||||
await dispatchNotificationClick('#/my-work/agenda');
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["notifications"] == [{
|
||||
"title": "3 deadlines need attention",
|
||||
"options": {
|
||||
"body": "Open Agenda to review or replan them.",
|
||||
"tag": "stackchain-deadline-digest-2026-08-13",
|
||||
"data": {"route": "#/my-work/agenda"},
|
||||
},
|
||||
}]
|
||||
assert result["opened"] == [
|
||||
"https://forge.example/dashboard/#/my-work/agenda"
|
||||
]
|
||||
assert "must-not-render" not in json.dumps(result["notifications"])
|
||||
|
||||
|
||||
def test_push_mark_read_action_confirms_authenticated_mutation_without_opening_app():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
|
||||
Reference in new issue
Block a user