feat: schedule mobile notification quiet hours (Closes #1392)
This commit is contained in:
1 parent
2acd9431e0
commit
cc74b7b8db
9 files changed
+487
-28
No files matched your search
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("rendered mobile quiet-hours checks run only in the browser gate", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FRONTEND = ROOT / "frontend"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [
|
||||
{"width": 320, "height": 568},
|
||||
{"width": 390, "height": 844},
|
||||
])
|
||||
def test_notification_quiet_hours_are_phone_usable_without_horizontal_scroll(viewport):
|
||||
html = re.sub(r'<script src="static/[^"]+"></script>', "", (FRONTEND / "index.html").read_text())
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport=viewport)
|
||||
page.set_content(html)
|
||||
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.locator("#work-settings-toggle").click()
|
||||
|
||||
toggle = page.locator('label[for="push-quiet-hours"]')
|
||||
start = page.locator("#push-quiet-start")
|
||||
end = page.locator("#push-quiet-end")
|
||||
expect(toggle).to_be_visible()
|
||||
expect(start).to_be_visible()
|
||||
expect(end).to_be_visible()
|
||||
expect(start).to_have_value("22:00")
|
||||
expect(end).to_have_value("07:00")
|
||||
for control in (toggle, start, end):
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
browser.close()
|
||||
@@ -34,6 +34,10 @@ const followingControl = {
|
||||
addEventListener:(_name, callback) => state.followingChange = callback,
|
||||
};
|
||||
const followingStatus = {set textContent(value) { state.followingText = value; }, get textContent() { return state.followingText; }};
|
||||
const quietControl = {checked:false, disabled:false, addEventListener:(_name, callback) => state.quietChange = callback};
|
||||
const quietStart = {value:'22:00', disabled:false, addEventListener:(_name, callback) => state.quietStartChange = callback};
|
||||
const quietEnd = {value:'07:00', disabled:false, addEventListener:(_name, callback) => state.quietEndChange = callback};
|
||||
const quietStatus = {set textContent(value) { state.quietText = value; }, get textContent() { return state.quietText; }};
|
||||
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};
|
||||
@@ -48,6 +52,7 @@ const feature = createPushNotifications({
|
||||
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
|
||||
startDayControl, startDayStatus, startDayHour,
|
||||
followingControl, followingStatus,
|
||||
quietControl, quietStart, quietEnd, quietStatus,
|
||||
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; }},
|
||||
@@ -114,6 +119,41 @@ def test_device_settings_render_and_wire_the_following_alert_preference():
|
||||
assert "followingStatus:qs('#push-following-status')" in dashboard
|
||||
|
||||
|
||||
def test_quiet_hours_are_restored_and_saved_as_one_local_schedule():
|
||||
result = run_scenario("""
|
||||
state.server = {available:true,subscribed:true,quiet_hours_enabled:true,quiet_hours_start:'21:30',quiet_hours_end:'06:45',quiet_hours_timezone:'America/New_York',public_key:'AQID'};
|
||||
state.current = existing;
|
||||
await feature.init();
|
||||
quietStart.value = '22:15';
|
||||
quietEnd.value = '07:30';
|
||||
await state.quietStartChange();
|
||||
process.stdout.write(JSON.stringify({requests:state.requests, checked:quietControl.checked, start:quietStart.value, end:quietEnd.value, text:state.quietText}));
|
||||
""")
|
||||
|
||||
assert result["checked"] is True
|
||||
assert result["requests"][-1][0:2] == ["api/v1/push-subscription/quiet-hours", "PUT"]
|
||||
body = json.loads(result["requests"][-1][2])
|
||||
assert body["enabled"] is True
|
||||
assert body["start"] == "22:15"
|
||||
assert body["end"] == "07:30"
|
||||
assert isinstance(body["timezone"], str) and body["timezone"]
|
||||
assert result["text"] == "Routine alerts paused from 22:15 to 07:30 local time."
|
||||
|
||||
|
||||
def test_device_settings_render_and_wire_mobile_quiet_hours():
|
||||
index = INDEX.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
|
||||
assert 'id="push-quiet-hours" type="checkbox"' in index
|
||||
assert 'id="push-quiet-start" type="time"' in index
|
||||
assert 'id="push-quiet-end" type="time"' in index
|
||||
assert 'id="push-quiet-status" role="status" aria-live="polite"' in index
|
||||
module = MODULE.read_text()
|
||||
assert "querySelector('#push-quiet-hours')" in module
|
||||
assert "querySelector('#push-quiet-start')" in module
|
||||
assert "querySelector('#push-quiet-end')" in module
|
||||
|
||||
|
||||
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'}}};
|
||||
|
||||
@@ -3,6 +3,7 @@ import asyncio
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
@@ -62,6 +63,67 @@ def test_following_alert_preferences_are_opt_in_and_checkpoint_each_device(tmp_p
|
||||
assert store.following_preferences("session-b") == {"enabled": False}
|
||||
|
||||
|
||||
def test_quiet_hours_hold_unread_revisions_then_mark_one_catch_up_delivery(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("session-a", {
|
||||
"endpoint": "https://push.example/session-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_quiet_hours(
|
||||
"session-a", enabled=True, start="22:00", end="07:00", timezone="UTC"
|
||||
)
|
||||
|
||||
inside = datetime(2026, 8, 25, 23, 0, tzinfo=timezone.utc).timestamp()
|
||||
after = datetime(2026, 8, 26, 7, 1, tzinfo=timezone.utc).timestamp()
|
||||
|
||||
assert store.quiet_hours("session-a") == {
|
||||
"enabled": True, "start": "22:00", "end": "07:00", "timezone": "UTC"
|
||||
}
|
||||
assert store.claim_unseen({42: "r1"}, now=inside) == []
|
||||
delivery = store.claim_unseen({42: "r1"}, now=after)
|
||||
assert len(delivery) == 1
|
||||
assert delivery[0].thread_revisions == ((42, "r1"),)
|
||||
assert delivery[0].catch_up is True
|
||||
store.mark_delivered("session-a", delivery[0].thread_revisions)
|
||||
assert store.claim_unseen({42: "r1"}, now=after) == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unread_dispatch_sends_one_catch_up_digest_after_quiet_hours(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("session-a", {
|
||||
"endpoint": "https://push.example/session-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_quiet_hours(
|
||||
"session-a", enabled=True, start="22:00", end="07:00", timezone="UTC"
|
||||
)
|
||||
inside = datetime(2026, 8, 25, 23, 0, tzinfo=timezone.utc).timestamp()
|
||||
after = datetime(2026, 8, 26, 7, 1, tzinfo=timezone.utc).timestamp()
|
||||
assert store.claim_unseen({41: "r1", 42: "r1"}, now=inside) == []
|
||||
sent = []
|
||||
|
||||
async def unread():
|
||||
return {"items": [
|
||||
{"id": 41, "updated_at": "r1"},
|
||||
{"id": 42, "updated_at": "r1"},
|
||||
]}
|
||||
|
||||
async def send(_subscription, payload):
|
||||
sent.append(json.loads(payload))
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
assert await dispatch_unread_updates(store, config, unread, send, now=after) == 1
|
||||
assert sent == [{
|
||||
"title": "2 updates while alerts were paused",
|
||||
"body": "Open Updates to catch up in Stackchain.",
|
||||
"route": "#/my-work/updates",
|
||||
"tag": "stackchain-update-catch-up",
|
||||
"update_count": 2,
|
||||
"unread_count": 2,
|
||||
}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_following_dispatch_is_private_deduplicated_and_session_bound(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
@@ -109,6 +171,43 @@ async def test_following_dispatch_is_private_deduplicated_and_session_bound(tmp_
|
||||
assert store.subscription_for_session("revoked") is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_following_changes_wait_for_quiet_hours_and_resume_as_catch_up(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("session-a", {
|
||||
"endpoint": "https://push.example/session-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_following_preferences("session-a", enabled=True)
|
||||
store.set_quiet_hours(
|
||||
"session-a", enabled=True, start="22:00", end="07:00", timezone="UTC"
|
||||
)
|
||||
inside = datetime(2026, 8, 25, 23, 0, tzinfo=timezone.utc).timestamp()
|
||||
after = datetime(2026, 8, 26, 7, 1, tzinfo=timezone.utc).timestamp()
|
||||
sent = []
|
||||
|
||||
async def following():
|
||||
return {"items": [{
|
||||
"repository": "private/project", "kind": "issue", "number": 42,
|
||||
"title": "Sensitive", "updated_at": "2026-08-25T23:00:00Z",
|
||||
"has_unseen_change": True,
|
||||
}]}
|
||||
|
||||
async def send(_subscription, payload):
|
||||
sent.append(json.loads(payload))
|
||||
|
||||
configuration = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
assert await dispatch_following_changes(
|
||||
store, configuration, following, send, now=inside
|
||||
) == 0
|
||||
assert sent == []
|
||||
assert await dispatch_following_changes(
|
||||
store, configuration, following, send, now=after
|
||||
) == 1
|
||||
assert sent[0]["title"] == "1 watched update while alerts were paused"
|
||||
assert sent[0]["route"] == "#/my-work/following"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_device_controls_following_alerts_independently(tmp_path, monkeypatch):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
@@ -134,6 +233,43 @@ async def test_authenticated_device_controls_following_alerts_independently(tmp_
|
||||
assert store.start_day_preferences("session-a")["enabled"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_device_persists_validated_quiet_hours(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()))
|
||||
payload = main.QuietHoursPayload(
|
||||
enabled=True, start="22:30", end="06:45", timezone="America/New_York"
|
||||
)
|
||||
|
||||
result = await main.update_quiet_hours(payload, request)
|
||||
|
||||
assert result == {
|
||||
"quiet_hours_enabled": True,
|
||||
"quiet_hours_start": "22:30",
|
||||
"quiet_hours_end": "06:45",
|
||||
"quiet_hours_timezone": "America/New_York",
|
||||
}
|
||||
status = await main.push_status(request)
|
||||
assert {key: status[key] for key in result} == result
|
||||
|
||||
|
||||
def test_quiet_hours_reject_equal_boundaries_and_invalid_timezone():
|
||||
with pytest.raises(ValueError):
|
||||
main.QuietHoursPayload(enabled=True, start="22:00", end="22:00", timezone="UTC")
|
||||
with pytest.raises(ValueError):
|
||||
main.QuietHoursPayload(enabled=True, start="22:00", end="07:00", timezone="Moon/Base")
|
||||
|
||||
|
||||
def test_subscription_store_uses_private_filesystem_permissions(tmp_path):
|
||||
state_dir = tmp_path / "push-state"
|
||||
previous_umask = os.umask(0)
|
||||
@@ -217,6 +353,7 @@ def test_push_store_factory_only_requires_its_key_when_push_is_enabled(
|
||||
disabled = factory(tmp_path / "disabled.sqlite3", push_enabled=False)
|
||||
assert disabled.is_subscribed("session-a") is False
|
||||
assert disabled.deadline_preferences("session-a")["enabled"] is False
|
||||
assert disabled.claim_unseen({42: "r1"}, now=0) == []
|
||||
assert not (tmp_path / "disabled.sqlite3").exists()
|
||||
with pytest.raises(RuntimeError, match="encryption key"):
|
||||
factory(tmp_path / "enabled.sqlite3", push_enabled=True)
|
||||
@@ -1435,6 +1572,10 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
|
||||
"start_day_timezone": "UTC",
|
||||
"start_day_reminder_hour": 9,
|
||||
"following_enabled": False,
|
||||
"quiet_hours_enabled": False,
|
||||
"quiet_hours_start": "22:00",
|
||||
"quiet_hours_end": "07:00",
|
||||
"quiet_hours_timezone": "UTC",
|
||||
"delivery_health": {},
|
||||
}
|
||||
assert await main.subscribe_push(payload, request) == {"subscribed": True}
|
||||
|
||||
Reference in new issue
Block a user