Skip to content

Push Notifications v1 (waves / accepts / messages) - Manual Test Plan โ€‹

FieldValue
Branch / PRworktree-heavy-hitters (PR TBD, targets dev)
Environmentdev.ourlantern.app AFTER merge to dev (triggers only run once the Cloud Functions deploy); pre-merge scenarios marked [PRE-MERGE] run locally
Build flags / configVITE_FIREBASE_VAPID_KEY (already set in .env.development for lantern-app-dev)
Build (commit)see PR head
TesterMechelle (+ agent for browser-drivable rows)
Date2026-07-25

Live E2E runs on deployed dev (agent, 2026-07-25 evening, branded Chrome under xvfb) โ€‹

Stack: google-chrome-stable + Playwright persistent profile with the notifications content-setting pre-seeded (Playwright's bundled Chromium has no push service, and CDP permission grants do not satisfy Chrome push registration). Waves/messages were seeded via the Admin SDK so the DEPLOYED triggers fired for real; all seeded docs and tokens were cleaned up after each run.

Verified live (best run of three; flakiness was test-env timing, not product):

  • Scenario 1 PASS: enable flow completes, toggles render, and a real FCM token doc appears at users/{uid}/fcmTokens (real endpoint: fcm.googleapis.com).
  • Scenario 5 PASS (send half): message trigger fired, prefs gate passed, FCM accepted the send (pushState.lastMessagePushAt written, which only happens on successCount > 0), and the burst message inside the window produced NO second send. Lock-screen collapse behavior remains a scenario-10 observation.
  • Scenario 7 PASS: a real delivered push rendered as the in-app "You got a wave" toast while the app was focused (run A).
  • Scenarios 3/12 PARTIAL: background sends confirmed accepted by FCM (above), and payload shape is pinned by unit tests (exact key set, hashed tags); the OS-level lock-screen rendering could not be reliably observed under xvfb (getNotifications stayed empty even for verifiably delivered pushes), so final display + tap-routing verification lands with scenario 10 on the Android.

Real-device run (operator Android + agent, 2026-07-26) - scenario 10 PASS, one bug found โ€‹

Setup: operator signed into the deployed dev app as the test account on Android Chrome. The agent seeded a wave via the Admin SDK (waves doc, fake sender uid) so the DEPLOYED onWaveCreatedPush trigger did the send, then confirmed server-side that the send succeeded (no error logged, and the recipient's token was NOT pruned, which only holds when FCM accepted it).

Verified on the device:

  • Lock-screen / shade render PASS. "You got a wave" / "Someone nearby waved at you. Open Lantern to see it." with the Lantern flame icon.
  • Privacy copy PASS (scenario 12, display half). No sender identity, no lantern name, no venue name, no message content on the notification surface.
  • Tap routing PASS (scenario 3). Tapping opened dev.ourlantern.app/#/ and landed on the live incoming-wave card. The pre-merge automated run could not observe this under xvfb, so this closes that gap.

FINDING (FIXED same-day): duplicate notification per push โ€‹

Every background push produced TWO notifications: ours, plus a Chrome-generated "dev.ourlantern.app / This site has been updated in the background." placeholder.

Root cause: firebase-messaging-sw.js called self.registration.showNotification(...) without RETURNING the promise. The FCM SDK awaits the onBackgroundMessage callback inside the push event's waitUntil (@firebase/messaging sw bundle: await messaging.onBackgroundMessageHandler(payload), itself inside e.waitUntil(onPush(e, messaging))), and Chrome posts its own placeholder whenever a push event settles with no notification shown. Dropping the promise made that a race, lost on cold worker starts, so the placeholder and the real notification both appeared.

Fix: return the showNotification promise. Regression test added at apps/web/src/lib/__tests__/firebaseMessagingSw.test.js (11 cases; the worker is read from disk and evaluated in a node:vm context with stubbed self / firebase, since it is a classic script and cannot be imported). Confirmed the guard fails without the fix, and that ONLY that one case fails, so it is specific rather than incidental.

Why review missed it: the handler displays the notification correctly either way. Only an assertion on the callback's return value distinguishes the two, which is what the new test does.

Gate scenarios on deployed dev (agent, 2026-07-26) - scenarios 4, 6, 8 PASS โ€‹

These were blocked pre-merge because the triggers only run once deployed. Now driveable server-side, no device needed for the send half.

Why the message trigger is the instrument. onMessageCreatedPush writes connections/{id}.pushState.{uid}.lastMessagePushAt only when successCount > 0, so that field is a definitive "FCM accepted a send" marker. Wave triggers write no such marker. The preference gate and the block suppression both live in the shared sendPushToUser, so verifying them on the message path verifies them for every push kind.

A baseline ran first, deliberately. A "no push was sent" result proves nothing if sends were broken for an unrelated reason, so scenario 6 starts by confirming that with default prefs a message push IS sent (marker written), and only then flips the toggle. Both negatives below are measured against that positive.

#SetupMarker written?Verdict
6 baselinedefault prefs (unset)yessend happens, so the negatives are meaningful
6notificationPrefs.messages = falsenocategory gate suppressed the send
8users/{uid}/blocks/{sender} existsnosuppressIf(hasBlocked) held
4wave flipped pending -> acceptedn/a (no marker on this path)onwaveacceptedpush fired 1.3s after the flip, no error logged, recipient token NOT pruned (so FCM accepted it)

Scenario 4's display half is already covered: the same wave kind rendered correctly on the operator's Android in the run above.

Cleanup verified, not assumed. The run creates a synthetic connection, three messages, a block doc, a wave, and a prefs override, then asserts afterwards that the connection is gone (exists === false), the block doc is deleted, all seeded waves are removed, and notificationPrefs is back to its original unset state. Re-checked the token store after: unchanged and unpruned.

Scenario 9 (moderation-notice regression) stays open. Sending a notice requires an authenticated admin session through auth-api, so it is not agent-drivable; calling FCM directly would test the rail rather than the route.

Automated coverage already run (agent, 2026-07-25, all green) โ€‹

  • apps/web vitest: 9 tests for notificationsService (prefs merge/write guards, alias). npx vitest run src/lib/__tests__/notificationsService.test.js
  • Functions logic: 9 node:test cases (category gating incl. junk values, throttle window + per-recipient isolation, collapse-tag opacity). node --test test/push-notifications.test.mjs (manual-run workspace convention)
  • Firestore rules emulator: 177 tests incl. 3 new (clients cannot write pushState, alone or smuggled). npm run test:rules
  • eslint on all changed files (0 errors) and npm run lint:stories (both new components have stories)

Setup / preconditions โ€‹

  • Two accounts at the same venue: the dev test account as A (credentials live in the operator's agent memory / LANTERN_TEST_PHONE + LANTERN_TEST_PIN env vars, never in the repo), plus a second invite-created account B (needs one admin invite link).
  • Two devices or two browser profiles (push permission is per profile). Chrome desktop is fine for everything except scenario 10.
  • Verify data in Firebase console, project lantern-app-dev: users/{uid}/fcmTokens, users/{uid}.notificationPrefs, connections/{cid}.pushState.
  • Clean state per profile: DevTools > Application > clear site data (resets permission + the lantern:pushPromptDismissed localStorage flag). Permission reset alternatively via the padlock icon > Site settings > Notifications > Ask.
  • No destructive steps anywhere in this plan; everything uses test accounts on dev.

Summary โ€‹

#ScenarioResultNotes
1Settings: enable notifications (default -> granted)[x] pass [ ] fail [ ] blockedlive E2E post-VAPID-fix: toggles + real FCM token doc
2Contextual prompt after first wave (once per device)[ ] pass [ ] fail [ ] blocked
3Wave received -> background push (privacy copy)[x] pass [ ] fail [ ] blockedAndroid 2026-07-26: rendered + tap routed to /#/; surfaced the duplicate-placeholder bug (fixed)
4Wave accepted -> push to original sender[x] pass [ ] fail [ ] blockedsend half, agent 2026-07-26: trigger fired 1.3s after the status flip, no error, token not pruned
5Message push + 5-minute throttle[x] pass [ ] fail [ ] blockedsend half live-verified (pushState + no second send); display via #10
6Category toggle off actually stops sends[x] pass [ ] fail [ ] blockedagent 2026-07-26: baseline sent, messages:false suppressed, prefs restored
7Foreground: in-app toast, not a system notification[x] pass [ ] fail [ ] blockedlive E2E: real delivered push rendered as toast
8Blocked sender produces no push[x] pass [ ] fail [ ] blockedagent 2026-07-26: block doc suppressed the send, removed after
9Regression: moderation notices still deliver[ ] pass [ ] fail [ ] blockedstill open: needs an admin session to send a notice, not agent-drivable
10Real-device end-to-end (Android; iOS deferred to #722)[x] pass [ ] fail [ ] blockedoperator Android 2026-07-26, see the run section above
11[PRE-MERGE] Storybook states of both new components[x] pass [ ] fail [ ] blockedagent-run 2026-07-25, headless chromium
12Payload privacy inspection (SW console)[ ] pass [ ] fail [ ] blocked

Scenarios โ€‹

1. Settings: enable notifications (default -> granted) โ€‹

  • Goal: the Settings-tab card requests permission on a gesture and stores a token.
  • Preconditions: profile with permission state "Ask" (clean state), signed in as A.
  • Steps:
    1. Open Profile > Settings tab.
    2. Confirm the Notifications card shows the explainer + "Enable notifications" button.
    3. Click "Enable notifications"; accept the browser prompt.
  • Expected: card switches to the Waves / Messages toggles (both on) with the "Safety-team notices are always delivered" footnote.
  • Verify (data/console): a new doc under users/{A}/fcmTokens/t_<sha256> with token, platform: 'web', createdAt.
Result:    [ ] pass   [ ] fail   [x] blocked
Actual:    Agent live smoke 2026-07-25 (headless, test account): login automation
           works end to end; Notifications card renders on deployed dev and the
           denied-state copy displays correctly (headless reports permission
           denied). BLOCKED for full pass: deployed build had no
           VITE_FIREBASE_VAPID_KEY (deploy-dev.yml gap), so registration
           no-ops with console warning "push disabled". Re-run after the
           VAPID fix deploys.
Evidence:  scratchpad push-smoke/*.png; console: "[notifications]
           VITE_FIREBASE_VAPID_KEY unset; push disabled (inbox still works)"
Follow-up: VAPID key added to deploy-dev.yml app build env (follow-up PR)

2. Contextual prompt after first wave (once per device) โ€‹

  • Goal: the EnablePushPrompt card appears after the first sent wave, and never nags again.
  • Preconditions: clean profile (permission "Ask", localStorage cleared), A signed in, B's lantern lit at the venue.
  • Steps:
    1. As A, light a lantern and send a wave to B.
    2. Observe the bottom card "Know when someone waves back".
    3. Click "Not now".
    4. Send another wave (to anyone) or reload and wave again.
  • Expected: card appears once after step 1; after "Not now" it never reappears (step 4 shows nothing). "Turn on" instead of "Not now" would trigger the browser prompt.
  • Verify (data/console): localStorage['lantern:pushPromptDismissed'] === '1' after step 3.
Result:    [ ] pass   [ ] fail   [ ] blocked
Actual:
Evidence:
Follow-up:

3. Wave received -> background push (privacy copy) โ€‹

  • Goal: a wave to a user whose app is closed produces the generic nudge.
  • Preconditions: A has push enabled (scenario 1); A's tab CLOSED (or another app focused); B at the same venue.
  • Steps:
    1. As B, send a wave to A's lantern.
    2. Watch A's device notifications.
    3. Click the notification.
  • Expected: EXACTLY ONE system notification "You got a wave / Someone nearby waved at you. Open Lantern to see it." (a second copy = the data-only fix regressed). It must NOT contain B's lantern name or the venue name. Clicking opens/focuses the app on the dashboard.
  • Verify (data/console): none beyond the notification itself (payload inspection is scenario 12).
Result:    [ ] pass   [ ] fail   [ ] blocked
Actual:
Evidence:
Follow-up:

4. Wave accepted -> push to original sender โ€‹

  • Goal: the sender hears back when their wave is accepted.
  • Preconditions: B has push enabled and app closed; A has a pending wave FROM B (so B is the sender: have B wave at A first).
  • Steps:
    1. As A, accept B's wave.
    2. Watch B's device.
  • Expected: B gets "Your wave was accepted / You have a new connection. Say hi." No names, no venue.
  • Verify (data/console): connection doc created; wave doc status accepted.
Result:    [ ] pass   [ ] fail   [ ] blocked
Actual:
Evidence:
Follow-up:

5. Message push + 5-minute throttle โ€‹

  • Goal: a chat burst produces one push, not one per message.
  • Preconditions: A and B connected; A push enabled, A's app closed.
  • Steps:
    1. As B, send 4-5 chat messages to A within a minute.
    2. Watch A's device.
    3. Note the time; after 5+ minutes send one more message.
  • Expected: exactly ONE "New message" notification for the burst; the post-window message in step 3 produces a second one.
  • Verify (data/console): connections/{cid}.pushState.{A}.lastMessagePushAt updates at the first send and again at step 3.
Result:    [ ] pass   [ ] fail   [ ] blocked
Actual:
Evidence:
Follow-up:

6. Category toggle off actually stops sends โ€‹

  • Goal: the preference is enforced server-side at send time, not just hidden in the UI.
  • Preconditions: A push enabled, app closed.
  • Steps:
    1. As A, Settings > Notifications: toggle Waves OFF.
    2. Confirm in Firebase console: users/{A}.notificationPrefs.waves === false.
    3. Close A's app. As B, send A a wave.
    4. Wait ~1 minute; watch A's device. Then reopen A's app.
  • Expected: NO system notification; the wave still appears in-app (push is a nudge, never the source of truth). Messages category still pushes if tested.
  • Verify (data/console): step 2 field value; wave doc exists with status: 'pending'.
Result:    [ ] pass   [ ] fail   [ ] blocked
Actual:
Evidence:
Follow-up:

7. Foreground: in-app toast, not a system notification โ€‹

  • Goal: with the app focused, a push category event surfaces as a toast.
  • Preconditions: A push enabled, app OPEN and focused on the dashboard.
  • Steps:
    1. As B, send a wave to A.
    2. Watch A's open tab.
  • Expected: an in-app toast ("You got a wave"); no duplicate system notification from the SW while focused.
  • Verify (data/console): devLog line [notifications] foreground push: in A's console (dev builds).
Result:    [ ] pass   [ ] fail   [ ] blocked
Actual:
Evidence:
Follow-up:

8. Blocked sender produces no push โ€‹

  • Goal: a block suppresses the nudge as well as the content.
  • Preconditions: A has blocked B (Safety tab); A push enabled, app closed; A and B still have lanterns at the venue or an existing connection.
  • Steps:
    1. As B, attempt a wave at A (or send a message on a pre-existing connection).
    2. Watch A's device for 1-2 minutes.
  • Expected: no notification of any kind on A's device.
  • Verify (data/console): functions logs (Cloud console, onWaveCreatedPush / onMessageCreatedPush) show the invocation returning without a send.
Result:    [ ] pass   [ ] fail   [ ] blocked
Actual:
Evidence:
Follow-up:

9. Regression: moderation notices still deliver โ€‹

  • Goal: the alias rename left the safety-team notice path untouched.
  • Preconditions: admin portal access; A push enabled, app closed.
  • Steps:
    1. From the admin portal, send a test notice to A (informational type).
    2. Watch A's device; then open the app and the Notices inbox.
  • Expected: EXACTLY ONE push arrives with the notice title (the sender is data-only now; two copies = regression); inbox shows the notice; tapping the push deep-links to the safety screen (/#/profile/safety).
  • Verify (data/console): adminActions doc with action: 'sendNotice' and pushed >= 1.
Result:    [ ] pass   [ ] fail   [ ] blocked
Actual:
Evidence:
Follow-up:

10. Real-device end-to-end (USER: Android phone; iOS deferred to #722) โ€‹

  • Goal: the whole rail works on a real phone's lock screen.
  • Preconditions: operator's Android phone, Chrome, signed in as A with push enabled. (Original iOS variant is deferred: no iPhone in-house, tracked in #722 with borrow/BrowserStack options before the December alpha.)
  • Steps:
    1. On Android Chrome, open dev.ourlantern.app, sign in as A.
    2. Settings > Notifications > Enable notifications; accept the prompt (no home-screen install required on Android).
    3. Lock the phone.
    4. Agent seeds a wave addressed to A (or account B waves, once one exists).
  • Expected: step 2 lands on the Waves/Messages toggles; step 4 shows a lock-screen notification "You got a wave" with the generic copy (no names, no venue); tapping it opens Lantern on the dashboard.
  • Verify (data/console): users/{A}/fcmTokens gains a token doc for the phone.
Result:    [ ] pass   [ ] fail   [ ] blocked
Actual:
Evidence:
Follow-up:

11. [PRE-MERGE] Storybook states of both new components โ€‹

  • Goal: visual check of all rendered states without Firebase.
  • Preconditions: npm run storybook (port 6006) on the branch.
  • Steps:
    1. Open Components/Settings/NotificationSettingsSection: NotYetEnabled, Granted, Blocked, UnsupportedBrowser.
    2. Open Components/Settings/EnablePushPrompt: Default.
  • Expected: all five stories render on-brand (amber CTA, neutral card, ToggleSwitch) with no console errors.
  • Verify (data/console): Storybook console clean.
Result:    [x] pass   [ ] fail   [ ] blocked
Actual:    Agent-run 2026-07-25: all 5 stories screenshotted headlessly (Playwright
           chromium, iframe.html per story); amber CTA + toggles + copy render
           per the design system; zero pageerror events across all stories.
Evidence:  scratchpad sb-shots/*.png (attached in session); script output "no page errors"
Follow-up: none

12. Payload privacy inspection (SW console) โ€‹

  • Goal: prove the wire payload honors the privacy invariants, not just the visible copy.
  • Preconditions: scenario 3 or 5 reproducible; A's browser with DevTools open on the service worker (chrome://serviceworker-internals or Application > Service Workers > inspect firebase-messaging-sw.js).
  • Steps:
    1. In the SW console, log incoming payloads (breakpoint in onBackgroundMessage or observe the push event).
    2. Trigger a wave push and a message push.
    3. Read the full payload JSON.
  • Expected: the payload is DATA-ONLY: no notification key at all (its presence = the SDK double-display bug is back). data contains exactly kind, title, body, url (an inbox-level route), and tag (hash prefix wave-/msg- + 16 hex chars, never a raw wave/connection id). The strings are the fixed generic copy; nothing contains lantern names, uids, venue names, or message text.
  • Verify (data/console): paste one captured payload into Evidence.
Result:    [ ] pass   [ ] fail   [ ] blocked
Actual:
Evidence:
Follow-up:

Notes โ€‹

  • Scenarios 3-9 and 12 require the branch merged to dev (Cloud Functions deploy); they cannot run against the local Vite server alone because the triggers live server-side. The functions emulator CAN exercise trigger logic locally but cannot deliver real FCM pushes, so the end-to-end rows stay on dev.
  • Agent can drive scenarios 1, 2, 6 (data side), 11 headlessly with the saved test account; 3-5, 7-9, 12 need a second account (one invite link) and are semi-automatable; 10 is strictly user input.

Built with VitePress