---
status: complete
phase: 05-web-push-notifications
source: [05-VERIFICATION.md]
started: 2026-06-10T02:46:43Z
updated: 2026-06-10T18:30:00Z
---

## Current Test

[testing complete]

## Tests

### 1. iOS PWA install → push subscription → 15-min reminder receipt

expected: After adding FamilySync to the Home Screen on an iOS 16.4+ device and tapping "Enable Notifications", a push notification appears on the lock screen ~15 minutes before a shared Family-calendar timed event starts.
why_human: iOS-Safari standalone push delivery cannot be driven by playwright-cli per CLAUDE.md — requires a physical iOS device + Home Screen install.
result: pass
note: "PASS confirmed on a real iPhone via the SCHEDULED path (no manual trigger). Initially failed; two root causes found and fixed: (1) node-cron 4.2.1 skipped EVERY scheduled execution in the long-running API process ('missed execution' each tick) → replaced node-cron with setInterval in all 3 broker workers (quick 260610-i4x). (2) The reminder scan only checked a fixed [now+14,now+16] window with no catch-up, so a missed/late tick dropped the reminder permanently → added a catch-up window (now, now+16min] + per-uid exactly-once dedup (quick 260610-hbu). After redeploy, a shared-calendar timed event triggered a push reminder on its own (setInterval → catch-up scan → VAPID sign → Apple 201 → SW showNotification on the iPhone). Also required fixing a truncated VAPID private key in .env earlier."
fix_commits: ["260610-hbu (catch-up + per-uid dedup)", "260610-i4x (node-cron→setInterval)"]

### 2. iOS push subscription does not receive NotAllowedError

expected: Tapping "Enable Notifications" on iOS in the installed PWA (or the Settings toggle) successfully calls pushManager.subscribe() without throwing NotAllowedError. Both vapidKey and swRegistration are pre-resolved in state before the tap.
why_human: NEW-CR-01 fix is verified in code (zero awaits between tap and subscribe()), but runtime confirmation on a physical iOS device is the only way to close this.
result: pass
note: "Confirmed on a real iPhone (installed standalone PWA). Enable Notifications succeeded with no NotAllowedError; subscription persisted (push_subscriptions id 176, user 3, web.push.apple.com endpoint). Required first fixing a truncated VAPID private key in .env (was 30 bytes → restored to a valid matched 32-byte pair)."

### 3. iOS subscription health-check keeps subscription alive after 1+ week of inactivity

expected: After a week without opening the app, opening it again silently re-subscribes (if permission still granted) and notifications continue to be delivered.
why_human: Requires real elapsed time and a physical iOS device. Cannot be simulated.
result: skipped
reason: "Dropped by operator (2026-06-10) — requires 1+ week of real elapsed time; not gating for Phase 5 sign-off. The silent re-subscribe code path (D-10) is verified in code; long-horizon real-world behaviour is left to observe naturally rather than block on."

### 4. Android FCM: event-change push arrives after the other member modifies a calendar event

expected: When member A modifies a shared event title/time/location, member B receives a push notification on Android within the next 5-minute poll cycle, showing "A updated an event" with the event title.
why_human: End-to-end push delivery through FCM to a real Android device with a subscribed session cannot be driven by playwright-cli.
result: skipped
reason: "DEFERRED to Phase 6 verification by operator (2026-06-10). During Test 4 two bugs were found and FIXED + deployed: (a) the SettingsSheet 'How to enable' recovery link only closed the sheet (quick 260610-jlp — now opens the OS-step InstructionSheet); (b) Android push notifications delivered but displayed SILENTLY because the SW showNotification lacked icon/badge/renotify/vibrate (quick 260610-ka9 — now enriched). Android FCM delivery itself is proven server-side (direct send → FCM 201). The remaining open item — confirming an event-change push arrives and displays non-silently on a real subscribed Android device — is moved to Phase 6 verification. Operator must also raise the Edge/Android per-site notification-channel importance (an existing silent channel can't be overridden by app options)."
deferred_to: "Phase 6 verification"
fix_commits: ["260610-jlp (how-to-enable link)", "260610-ka9 (silent Android notification options)"]

### 5. List-change push coalescing is observable

expected: Member B making 5 rapid grocery-list edits results in a SINGLE push notification to member A (not 5), naming the actor and the list, arriving after the 45-second coalesce window.
why_human: Requires two devices/sessions, real timing, and real push delivery. Playwright-cli can exercise the API hooks but not multi-device push receipt.
result: pass
note: "Confirmed on-device — 5 rapid list edits produced a single coalesced push (not 5)."

## Summary

total: 5
passed: 3
issues: 0
pending: 0
skipped: 2
blocked: 0

# Sign-off (2026-06-10): Tests 1, 2, 5 PASS on real devices. Test 3 dropped

# (1-week elapsed time, non-gating). Test 4 deferred to Phase 6 verification

# (fixes deployed: how-to-enable link + silent-notification options; Android

# delivery confirmation moved to Phase 6). Phase 5 UAT resolved.

## Notes

- **Delivery pipeline PROVEN on a real iOS device (2026-06-10):** iOS standalone-PWA subscribe → `push_subscriptions` row → VAPID-signed `web-push` send → `web.push.apple.com` 201 → service-worker `push` handler `showNotification()` → notification on the iPhone lock screen. This is the core of Phase 5 and de-risks tests 4 and 5 (same chain, different trigger/endpoint).
- Prereq fix applied: VAPID `.env` private key was truncated (30 bytes); restored to a valid 32-byte key that pairs with the public key (verified via ECDH derivation).
- Shared calendar wired: operator's "FamilySync" Fastmail calendar synced as `calendars.id=10`, marked `is_shared=1` (D-16), giving reminders a real target.

## Gaps

- truth: "A shared-calendar timed event triggers a push reminder ~15 min before start, delivered to subscribed devices"
  status: RESOLVED 2026-06-10
  reason: "Originally failed (scheduled reminder never fired). Two root causes found + fixed: (a) node-cron 4.2.1 skipped EVERY scheduled execution in the long-running API process → replaced with setInterval in all 3 broker workers (quick 260610-i4x, commit d9efbc1); (b) reminder scan had no catch-up so a missed/late tick dropped the reminder → added catch-up window (now, now+16min] + per-uid exactly-once dedup (quick 260610-hbu, commit 19d92c6). After redeploy, a real shared-calendar event triggered a push on the iPhone via the SCHEDULED path with no manual trigger."
  severity: major
  test: 1
  artifacts: [apps/api/src/broker/reminderScheduler.ts, apps/api/src/broker/poller.ts, apps/api/src/broker/outboxWorker.ts]
  note: "Side benefit: the node-cron→setInterval fix also restores the CalDAV poller (5-min sync) and outbox drain (15s), which were ALSO being skipped by node-cron in the long-running process."

- truth: "When notifications are browser-blocked, the user is given a working path to re-enable them"
  status: failed
  reason: "SettingsSheet 'How to enable' link only closes the sheet (onClick={onClose}); shows no instructions. Blocks Test 4 (could not get a subscribed Android session to test event-change push)."
  severity: major
  test: 4
  artifacts: [apps/pwa/src/components/SettingsSheet.tsx, apps/pwa/src/components/PermissionDeniedBanner.tsx]
  missing:
  - "Wire SettingsSheet 'How to enable' to open the OS-specific InstructionSheet (extract/share it from PermissionDeniedBanner) instead of calling onClose."
  - "After unblocking in Chrome site settings, re-run Test 4: modify a shared event as member A, confirm member B's Android device receives the 'updated an event' push within the 5-min poll cycle."
