319 lines
10 KiB
Python
319 lines
10 KiB
Python
import asyncio
|
|
import json
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from types import SimpleNamespace
|
|
|
|
from src import main
|
|
from src.push_notifications import PushConfiguration, dispatch_start_day_reminders
|
|
from src.push_subscription_store import PushSubscriptionStore
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_start_day_reminder_sends_one_private_prompt_for_due_plan(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_start_day_preferences(
|
|
"device-a", enabled=True, timezone="America/New_York", reminder_hour=9
|
|
)
|
|
sent = []
|
|
|
|
async def tomorrow():
|
|
return {
|
|
"revision": 3,
|
|
"ids": ["private/repo#42"],
|
|
"plan_date": "2026-08-20",
|
|
"timezone": "America/New_York",
|
|
}
|
|
|
|
async def send(_subscription, payload):
|
|
sent.append(json.loads(payload))
|
|
|
|
configuration = PushConfiguration("public", "private", "mailto:ops@example.com")
|
|
now = datetime(2026, 8, 20, 13, 5, tzinfo=timezone.utc)
|
|
|
|
assert await dispatch_start_day_reminders(
|
|
store, configuration, tomorrow, send, now=now
|
|
) == 1
|
|
assert await dispatch_start_day_reminders(
|
|
store, configuration, tomorrow, send, now=now
|
|
) == 0
|
|
assert sent == [{
|
|
"title": "Your planned day is ready",
|
|
"body": "Open Stackchain to prepare Today.",
|
|
"route": "#/my-work/start-day",
|
|
"tag": "stackchain-start-day-2026-08-20",
|
|
"plan_date": "2026-08-20",
|
|
}]
|
|
assert "private/repo" not in json.dumps(sent)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_start_day_reminders_bound_parallel_device_delivery(tmp_path):
|
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
|
for index in range(6):
|
|
session_id = f"device-{index}"
|
|
store.upsert(session_id, {
|
|
"endpoint": f"https://push.example/{session_id}",
|
|
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
|
})
|
|
store.set_start_day_preferences(
|
|
session_id, enabled=True, timezone="UTC", reminder_hour=9
|
|
)
|
|
|
|
active = 0
|
|
peak = 0
|
|
first_wave = asyncio.Event()
|
|
release = asyncio.Event()
|
|
|
|
async def tomorrow():
|
|
return {"ids": ["one"], "plan_date": "2026-08-20"}
|
|
|
|
async def send(_subscription, _payload):
|
|
nonlocal active, peak
|
|
active += 1
|
|
peak = max(peak, active)
|
|
if active == 2:
|
|
first_wave.set()
|
|
await release.wait()
|
|
active -= 1
|
|
|
|
dispatch = asyncio.create_task(dispatch_start_day_reminders(
|
|
store,
|
|
PushConfiguration("public", "private", "mailto:ops@example.com"),
|
|
tomorrow,
|
|
send,
|
|
now=datetime(2026, 8, 20, 9, 0, tzinfo=timezone.utc),
|
|
max_concurrency=2,
|
|
))
|
|
await asyncio.wait_for(first_wave.wait(), timeout=0.5)
|
|
assert peak == 2
|
|
release.set()
|
|
assert await dispatch == 6
|
|
assert peak == 2
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_start_day_reminder_stops_queued_send_after_lease_loss(
|
|
tmp_path, monkeypatch
|
|
):
|
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
|
for session_id in ("device-a", "device-b"):
|
|
store.upsert(session_id, {
|
|
"endpoint": f"https://push.example/{session_id}",
|
|
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
|
})
|
|
store.set_start_day_preferences(
|
|
session_id, enabled=True, timezone="UTC", reminder_hour=9
|
|
)
|
|
|
|
lease_checks = 0
|
|
|
|
def acquire(_owner, *, channel, now, lease_seconds):
|
|
nonlocal lease_checks
|
|
assert channel == "start-day"
|
|
assert now > 0
|
|
assert lease_seconds >= 15
|
|
lease_checks += 1
|
|
return lease_checks <= 2
|
|
|
|
monkeypatch.setattr(store, "acquire_dispatch_lease", acquire)
|
|
sent = []
|
|
|
|
async def tomorrow():
|
|
return {"ids": ["one"], "plan_date": "2026-08-20"}
|
|
|
|
async def send(subscription, _payload):
|
|
sent.append(subscription["endpoint"])
|
|
|
|
delivered = await dispatch_start_day_reminders(
|
|
store,
|
|
PushConfiguration("public", "private", "mailto:ops@example.com"),
|
|
tomorrow,
|
|
send,
|
|
now=datetime(2026, 8, 20, 9, 0, tzinfo=timezone.utc),
|
|
max_concurrency=1,
|
|
)
|
|
|
|
assert delivered == 1
|
|
assert lease_checks == 3
|
|
assert len(sent) == 1
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_start_day_reminder_contains_one_device_persistence_failure(
|
|
tmp_path, monkeypatch
|
|
):
|
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
|
for session_id in ("device-a", "device-b"):
|
|
store.upsert(session_id, {
|
|
"endpoint": f"https://push.example/{session_id}",
|
|
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
|
})
|
|
store.set_start_day_preferences(
|
|
session_id, enabled=True, timezone="UTC", reminder_hour=9
|
|
)
|
|
|
|
mark_succeeded = store.mark_delivery_succeeded
|
|
|
|
def fail_one_device(session_id, channel):
|
|
if session_id == "device-a":
|
|
raise RuntimeError("device checkpoint unavailable")
|
|
return mark_succeeded(session_id, channel)
|
|
|
|
monkeypatch.setattr(store, "mark_delivery_succeeded", fail_one_device)
|
|
sent = []
|
|
|
|
async def tomorrow():
|
|
return {"ids": ["one"], "plan_date": "2026-08-20"}
|
|
|
|
async def send(subscription, _payload):
|
|
sent.append(subscription["endpoint"])
|
|
|
|
delivered = await dispatch_start_day_reminders(
|
|
store,
|
|
PushConfiguration("public", "private", "mailto:ops@example.com"),
|
|
tomorrow,
|
|
send,
|
|
now=datetime(2026, 8, 20, 9, 0, tzinfo=timezone.utc),
|
|
max_concurrency=2,
|
|
)
|
|
|
|
assert delivered == 1
|
|
assert len(sent) == 2
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_push_poll_applies_configured_concurrency_to_start_day(
|
|
monkeypatch
|
|
):
|
|
captured = {}
|
|
hold = asyncio.Event()
|
|
|
|
async def no_wait(_seconds):
|
|
return None
|
|
|
|
async def hold_dispatch(*_args, **_kwargs):
|
|
await hold.wait()
|
|
|
|
async def capture_start_day(*_args, **kwargs):
|
|
captured.update(kwargs)
|
|
raise asyncio.CancelledError
|
|
|
|
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)
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await main._push_poll_loop()
|
|
|
|
assert captured["max_concurrency"] == 3
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_start_day_reminder_waits_for_hour_and_nonempty_due_plan(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_start_day_preferences(
|
|
"device-a", enabled=True, timezone="America/Los_Angeles", reminder_hour=9
|
|
)
|
|
sent = []
|
|
|
|
async def send(_subscription, payload):
|
|
sent.append(payload)
|
|
|
|
configuration = PushConfiguration("public", "private", "mailto:ops@example.com")
|
|
before_hour = datetime(2026, 8, 20, 15, 59, tzinfo=timezone.utc)
|
|
|
|
async def due_plan():
|
|
return {"ids": ["one"], "plan_date": "2026-08-20"}
|
|
|
|
assert await dispatch_start_day_reminders(
|
|
store, configuration, due_plan, send, now=before_hour
|
|
) == 0
|
|
|
|
async def empty_plan():
|
|
return {"ids": [], "plan_date": "2026-08-20"}
|
|
|
|
assert await dispatch_start_day_reminders(
|
|
store, configuration, empty_plan, send,
|
|
now=datetime(2026, 8, 20, 16, 0, tzinfo=timezone.utc),
|
|
) == 0
|
|
assert sent == []
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_authenticated_device_can_enable_start_day_reminders(tmp_path, monkeypatch):
|
|
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
|
store.upsert("device-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 "device-a"
|
|
|
|
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
|
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
|
|
payload = main.StartDayReminderPayload(
|
|
enabled=True, timezone="America/New_York", reminder_hour=8
|
|
)
|
|
|
|
assert await main.update_start_day_reminders(payload, request) == {
|
|
"start_day_enabled": True,
|
|
"start_day_timezone": "America/New_York",
|
|
"start_day_reminder_hour": 8,
|
|
}
|
|
assert store.start_day_preferences("device-a") == {
|
|
"enabled": True, "timezone": "America/New_York", "reminder_hour": 8,
|
|
}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_worker_snapshot_reads_the_confirmed_accounts_next_planned_day(monkeypatch):
|
|
async def current_user():
|
|
return {"login": "Timmy"}
|
|
|
|
class Store:
|
|
def get_start_day_plan(self, login):
|
|
assert login == "Timmy"
|
|
return {"ids": ["one"], "plan_date": "2026-08-20"}
|
|
|
|
def get_tomorrow(self, _login):
|
|
raise AssertionError("the worker must use Week Ahead discovery")
|
|
|
|
monkeypatch.setattr(main, "current_user", current_user)
|
|
monkeypatch.setattr(main, "_today_store", lambda: Store())
|
|
|
|
assert await main._start_day_plan_snapshot() == {
|
|
"ids": ["one"], "plan_date": "2026-08-20",
|
|
}
|
|
|
|
|
|
def test_mobile_settings_and_launch_route_wire_start_day_into_existing_promotion():
|
|
frontend = Path(__file__).parents[1] / "frontend"
|
|
html = (frontend / "index.html").read_text()
|
|
dashboard = (frontend / "dashboard.js").read_text()
|
|
|
|
assert 'id="push-start-day"' in html
|
|
assert 'id="push-start-day-hour"' in html
|
|
assert 'id="device-setup-start-day"' in html
|
|
assert "startDayControl:qs('#push-start-day')" in dashboard
|
|
assert "startDayHour:qs('#push-start-day-hour')" in dashboard
|
|
assert "window.location.hash !== '#/my-work/start-day'" in dashboard
|
|
assert "weekFlow.promote(plan).finally(() =>" in dashboard
|
|
assert "onRemotePlan: async plan =>" not in dashboard
|
|
assert "openMobileStartDay()" in dashboard
|