Files
chore/frontend/vue-app/e2e/plans/parent-notifications.plan.md
Ryan Kegel 9efbb455d7
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m4s
feat: add Daily Digest and Push Notifications toggles to User Profile with corresponding functionality
2026-04-18 23:03:23 -04:00

32 KiB
Raw Blame History

Parent Notifications E2E Tests

Application Overview

The parent notifications feature adds two channels for alerting parents when a child performs an action requiring their attention:

  1. Web Push Notifications — When a child confirms a chore is complete, or requests an affordable reward, the parent receives a native browser push notification via a Service Worker. The payload contains child_id, entity_id, and entity_type. Supporting browsers (Chrome desktop) show "Approve" and "Deny" action buttons; clicking an action button calls the matching backend API (approve-chore, reject-chore, trigger-reward, or deny-reward-request) without opening the app. Tapping the notification body opens or focuses the app and navigates to /parent/:id?scrollTo=<entityId>&entityType=<chore|reward>.

  2. Daily Email Digest — At 9 pm in the parent's local timezone, a HTML digest email is sent for any still-pending items (only if at least one exists). Each item has three links: View (ParentView deep link), Approve (GET /api/digest-action/<token>), and Deny (GET /api/digest-action/<token>). Tokens are HMAC-SHA256 signed, valid for 24 hours, and single-use. On success the endpoint executes the action and 302-redirects to the ParentView deep link; on error (expired, already-used, tampered) it returns a 400 HTML page with no redirect.

In-app notification view/parent/notifications lists all pending confirmations via GET /api/pending-confirmations. Clicking an item navigates to /parent/:id?scrollTo=<entityId>&entityType=<type>. Approval and rejection happen via ParentView:

  • Pending chores: card shows a .chore-stamp.pending-stamp "PENDING" badge; clicking the card opens ChoreApproveDialog which has Approve and Reject buttons.
  • Pending rewards: card shows a .pending "PENDING" badge when item.redeeming === true; clicking opens a reward-confirm dialog that calls POST trigger-reward. A Deny button (added by this feature) calls POST deny-reward-request.

The notification view refreshes reactively via SSE events: child_chore_confirmation (operations: CONFIRMED, APPROVED, REJECTED, CANCELLED) and child_reward_request (operations: CREATED, CANCELLED, GRANTED).

Spec covered: feat-parent-chore-notifications.md


Implementation

Tests live in e2e/mode_parent/notifications/. Each spec creates its own isolated child and entities via API so files can run in parallel within the bucket. All created resources are deleted in afterAll.

Playwright Projects

Project testMatch Notes
chromium-parent-notifications /mode_parent\/notifications\/.+\.spec\.ts/ Depends on setup only

Config: Add project to playwright.config.ts with dependencies: ['setup'] and storageState: STORAGE_STATE. Add /mode_parent\/notifications\// to the testIgnore list of any existing catch-all project so notification specs do not run in two buckets simultaneously.


Seed Strategy

Each spec file uses beforeAll to create isolated children, tasks, and rewards via the authenticated request fixture, and deletes them all in afterAll. The standard pattern is: pre-delete by name, create via PUT, re-fetch to get the ID.

Push subscription tests: Service Worker registration and PushManager.subscribe() require a VAPID public key configured in the dev server environment. Tests that intercept the subscription POST use page.context().grantPermissions(['notifications']) before navigation and page.route() to capture the outgoing request. If the VAPID key is absent, test 1.1 must be skipped with test.skip().

Digest action token prerequisite: The email digest scheduler is disabled in DB_ENV=e2e (to avoid sending real email). To exercise GET /api/digest-action/<token> in E2E tests, the backend must expose a test-only helper endpoint active only when DB_ENV=e2e:

POST /api/admin/test/digest-token
Auth: required (uses the E2E parent session)
Body: { child_id, entity_id, entity_type, action, expires_in_hours? }
Response 200: { token: string }

This endpoint creates a valid DigestActionToken (with correct HMAC-SHA256 signature) in TinyDB and returns the token string. All digest-action tests call this helper in beforeAll to obtain tokens for their scenarios.


Test Scenarios

1. Push Subscription Registration

File: e2e/mode_parent/notifications/push-subscription.spec.ts

Seed: No child/task setup required. Tests use page.context().grantPermissions(['notifications']) as needed. No afterAll cleanup.

1.1. Subscription POST is sent on parent mount when permission is pre-granted

  • Call page.context().grantPermissions(['notifications'])
  • Intercept POST /api/push-subscription with page.route() to capture request body
  • Navigate to /parent/tasks/chores (triggers ParentLayout mount → SW registration → subscribe → POST)
  • expect: Intercepted request body contains a non-empty endpoint string
  • expect: Intercepted request body contains non-empty keys.p256dh and keys.auth strings
  • expect: Intercepted request body contains a non-empty timezone string

1.2. Subscription POST body includes the browser's IANA timezone string

  • Grant notifications permission, intercept POST /api/push-subscription
  • Navigate to /parent/tasks/chores
  • Obtain browser timezone via page.evaluate(() => Intl.DateTimeFormat().resolvedOptions().timeZone)
  • expect: The timezone field in the intercepted POST body equals the value from page.evaluate()

1.3. No subscription POST is sent when notification permission is not granted

  • Do NOT grant notification permission (leave default)
  • Track calls to POST /api/push-subscription via page.route()
  • Navigate to /parent/tasks/chores, wait for page to settle
  • expect: No POST to /api/push-subscription was captured

1.4. Backend rejects unauthenticated subscription POST (401)

  • Create a fresh APIRequestContext without storageState (no auth cookies) via playwright.request.newContext()
  • POST to /api/push-subscription with a minimal payload
  • expect: Response status is 401

1.5. Backend rejects unauthenticated subscription DELETE (401)

  • Using the same unauthenticated context as 1.4, send DELETE /api/push-subscription
  • expect: Response status is 401

2. Chore Notification — Appearance and In-App Approval

File: e2e/mode_parent/notifications/chore-notification-approve.spec.ts

Seed: Creates child ChoreNotifApproveChild and chore ChoreNotifApproveChore (5 points, type chore) via API. Assigns chore to child. Sets a daily interval schedule (mode: 'interval', interval_days: 1, interval_has_deadline: false) so the chore persists post-approval. In beforeAll, calls POST /child/<childId>/confirm-chore with { task_id: choreId }. Deletes child and task in afterAll.

2.1. Confirmed chore appears in the notification view

  • Navigate to /parent/notifications
  • expect: A list item is visible containing the text ChoreNotifApproveChore
  • expect: The item contains the word "completed" (not "requested")

2.2. Notification item displays the child's name

  • Navigate to /parent/notifications
  • expect: The notification item for ChoreNotifApproveChore also shows ChoreNotifApproveChild

2.3. Clicking notification navigates to ParentView with correct query params

  • Navigate to /parent/notifications
  • Click the ChoreNotifApproveChore notification item
  • await page.waitForURL(new RegExp('/parent/' + childId + '\\?scrollTo=.*&entityType=chore'), { timeout: 5000 })
  • expect: URL contains scrollTo=<choreId> and entityType=chore
  • expect: The "Chores" section heading is visible

2.4. Pending chore card shows PENDING stamp in ParentView

  • Navigate to /parent/<childId>
  • Locate the chore card for ChoreNotifApproveChore in the Chores section
  • expect: Card contains an element with text "PENDING"

2.5. Clicking a pending chore card opens ChoreApproveDialog

  • Navigate to /parent/<childId>
  • Click the ChoreNotifApproveChore card
  • expect: A dialog element is visible containing both an "Approve" button and a "Reject" button

2.6. Approving via ChoreApproveDialog removes the PENDING stamp

  • Click the "Approve" button in the dialog
  • expect: The dialog closes
  • expect: The "PENDING" stamp is no longer present on the ChoreNotifApproveChore card

2.7. Approving a chore clears it from the notification view

  • After approving (2.6), navigate to /parent/notifications
  • expect: No list item containing ChoreNotifApproveChore is present

2.8. Approving a chore awards points to the child

  • Note child's point balance before approval
  • Approve the chore via the dialog
  • expect: Child's point balance increases by the chore's point value (5)

3. Chore Notification — In-App Rejection

File: e2e/mode_parent/notifications/chore-notification-deny.spec.ts

Seed: Creates child ChoreNotifDenyChild and chore ChoreNotifDenyChore (5 points) via API. Assigns chore. Sets daily interval schedule. In beforeAll, calls POST /child/<childId>/confirm-chore. Deletes all in afterAll.

3.1. Confirmed chore appears in the notification view

  • Navigate to /parent/notifications
  • expect: A list item is visible containing ChoreNotifDenyChore

3.2. ChoreApproveDialog shows both Approve and Reject buttons

  • Navigate to /parent/<childId>, click the ChoreNotifDenyChore card
  • expect: Dialog with "Approve" and "Reject" buttons is visible

3.3. Rejecting via ChoreApproveDialog removes the PENDING stamp

  • Click the "Reject" button
  • expect: Dialog closes
  • expect: "PENDING" stamp is no longer present on the ChoreNotifDenyChore card

3.4. Rejecting a chore clears it from the notification view

  • After rejecting (3.3), navigate to /parent/notifications
  • expect: No list item containing ChoreNotifDenyChore is present

3.5. Rejecting a chore does not award points

  • Note child's point balance before rejection
  • Reject the pending chore via ChoreApproveDialog
  • expect: Child's point balance is unchanged

4. Reward Notification — Appearance and In-App Grant

File: e2e/mode_parent/notifications/reward-notification-approve.spec.ts

Seed: Creates child RewardNotifApproveChild with points = 30 via API. Creates reward RewardNotifApproveReward with cost = 10. Assigns reward to child. In beforeAll, calls POST /child/<childId>/request-reward with { reward_id: rewardId }. Deletes child and reward in afterAll.

4.1. Requested reward appears in the notification view

  • Navigate to /parent/notifications
  • expect: A list item is visible containing RewardNotifApproveReward
  • expect: The item contains the word "requested" (not "completed")

4.2. Notification item displays the child's name

  • Navigate to /parent/notifications
  • expect: The notification item for RewardNotifApproveReward also shows RewardNotifApproveChild

4.3. Clicking reward notification navigates to ParentView with correct query params

  • Navigate to /parent/notifications
  • Click the RewardNotifApproveReward notification item
  • await page.waitForURL(new RegExp('/parent/' + childId + '\\?scrollTo=.*&entityType=reward'), { timeout: 5000 })
  • expect: URL contains scrollTo=<rewardId> and entityType=reward

4.4. Pending reward card shows PENDING badge in ParentView

  • Navigate to /parent/<childId>
  • Locate the reward card for RewardNotifApproveReward in the Rewards section
  • expect: Card contains an element with text "PENDING"

4.5. Clicking a pending reward card opens the grant confirmation dialog

  • Navigate to /parent/<childId>, click the RewardNotifApproveReward card
  • expect: A reward confirmation dialog appears

4.6. Confirming the grant removes the PENDING badge and updates child points

  • In the grant confirmation dialog, click the confirm button
  • expect: Dialog closes
  • expect: The "PENDING" badge is no longer visible on the reward card
  • expect: Child's point balance decreases by the reward cost (30 → 20)

4.7. Granting a reward clears it from the notification view

  • After confirming the grant (4.6), navigate to /parent/notifications
  • expect: No list item containing RewardNotifApproveReward is present

5. Reward Notification — In-App Denial

File: e2e/mode_parent/notifications/reward-notification-deny.spec.ts

Seed: Creates child RewardNotifDenyChild with points = 30. Creates reward RewardNotifDenyReward with cost = 10. Assigns reward to child. Uses beforeEach to call POST /child/<childId>/request-reward (creates a fresh pending request before each test) and afterEach to call POST /child/<childId>/cancel-request-reward to clean up any unconsumed requests. Deletes child and reward in afterAll.

5.1. Requested reward appears in the notification view

  • Navigate to /parent/notifications
  • expect: A list item is visible containing RewardNotifDenyReward

5.2. Pending reward card shows PENDING badge in ParentView

  • Navigate to /parent/<childId>
  • expect: The RewardNotifDenyReward card shows a "PENDING" badge

5.3. Denying a reward request removes the PENDING badge

  • Trigger the deny action for the pending reward (target the Deny button by its label or role — exact placement determined by implementation)
  • expect: The "PENDING" badge disappears from the reward card
  • expect: Child's point balance is unchanged

5.4. Denying a reward request clears it from the notification view

  • After denying (5.3), navigate to /parent/notifications
  • expect: No list item containing RewardNotifDenyReward is present

5.5. POST deny-reward-request returns success when a pending request exists (API layer)

  • Via the authenticated request fixture, call POST /child/<childId>/deny-reward-request with { reward_id: rewardId }
  • expect: Response status is 200

5.6. POST deny-reward-request returns 404 when no pending request exists (API layer)

  • Cancel any existing pending request first via POST /child/<childId>/cancel-request-reward
  • Call POST /child/<childId>/deny-reward-request with { reward_id: rewardId }
  • expect: Response status is 404

File: e2e/mode_parent/notifications/deep-link-navigation.spec.ts

Seed: Creates child DeepLinkChild with points = 100. Creates 10 chores DeepLinkChore1DeepLinkChore10 (each 5 points, type chore). Creates reward DeepLinkReward with cost = 20. Assigns all 10 chores and the reward to the child. Confirms DeepLinkChore10 via POST /child/<childId>/confirm-chore. Requests the reward via POST /child/<childId>/request-reward. Uses narrow viewport { width: 480, height: 800 } so cards near the bottom require actual scrolling. Deletes child, all tasks, and reward in afterAll.

  • await page.setViewportSize({ width: 480, height: 800 })
  • Navigate to /parent/<childId>?scrollTo=<choreId>&entityType=chore
  • Wait for chore list to load and allow 800 ms for the scroll delay to complete
  • expect: The DeepLinkChore10 card is within the visible viewport
  • await page.setViewportSize({ width: 480, height: 800 })
  • Navigate to /parent/<childId>?scrollTo=<rewardId>&entityType=reward
  • Wait for reward list to load and scroll delay
  • expect: The DeepLinkReward card is within the visible viewport
  • Listen for page.on('pageerror', ...) before navigating
  • Navigate to /parent/<childId>?scrollTo=<choreId>&entityType=unknown
  • expect: No pageerror event fires
  • expect: Page loads and the "Chores" section heading is visible
  • Navigate to /parent/<childId>?entityType=chore (no scrollTo query param)
  • expect: Page loads without error and "Chores" section is visible
  • Navigate to /parent/<childId> (bare URL, no query params)
  • expect: Page loads and both "Chores" and "Rewards" section headings are visible
  • Navigate to /parent/notifications
  • Click the DeepLinkChore10 notification item (confirmed chore)
  • expect: URL matches /parent/<childId>?scrollTo=<choreId>&entityType=chore
  • Wait 800 ms for scroll delay
  • expect: DeepLinkChore10 card is in viewport
  • Navigate to /parent/notifications
  • Click the DeepLinkReward notification item
  • expect: URL matches /parent/<childId>?scrollTo=<rewardId>&entityType=reward
  • Wait 800 ms
  • expect: DeepLinkReward card is in viewport

7. Digest Action Token — Happy Paths

Each combination of entity_type × action gets its own spec file to keep seeds isolated and specs independently runnable.

Files:

  • e2e/mode_parent/notifications/digest-action-approve-chore.spec.ts
  • e2e/mode_parent/notifications/digest-action-deny-chore.spec.ts
  • e2e/mode_parent/notifications/digest-action-approve-reward.spec.ts
  • e2e/mode_parent/notifications/digest-action-deny-reward.spec.ts

Seed (shared pattern for all four specs): Creates an isolated child, task or reward, assigns it, creates a pending confirmation (via confirm-chore or request-reward), then calls the authenticated POST /api/admin/test/digest-token helper to obtain a valid signed token for the required action. All GET /digest-action/<token> calls use a fresh unauthenticated APIRequestContext (no cookies) created via playwright.request.newContext({ ignoreHTTPSErrors: true }). The verifying GET /api/pending-confirmations and GET /api/child/<id> calls use the authenticated request fixture. Deletes child and task/reward in afterAll.

7.1. Approve-chore token — 302 redirect to correct ParentView URL

  • Call GET /api/digest-action/<approve_chore_token> (unauthenticated, redirect following disabled)
  • expect: Response status is 302
  • expect: response.headers()['location'] contains /parent/<childId>, scrollTo=<choreId>, and entityType=chore

7.2. Approve-chore token — approve action is performed, points awarded

  • After calling token (7.1), wait 200 ms for the action to complete
  • Via authenticated request: GET /api/pending-confirmations
  • expect: No pending confirmation record exists for this chore and child
  • Via authenticated request: GET /api/child/<childId>
  • expect: Points increased by the chore's point value

7.3. Deny-chore token — 302 redirect to correct ParentView URL

  • Call GET /api/digest-action/<deny_chore_token> (unauthenticated, redirect following disabled)
  • expect: Response status is 302
  • expect: Location header contains the correct ParentView deep link (entityType=chore)

7.4. Deny-chore token — rejection action is performed, no points awarded

  • After calling token (7.3), verify:
  • expect: Pending confirmation for this chore is removed from GET /api/pending-confirmations
  • expect: Child's point balance is unchanged

7.5. Approve-reward token — 302 redirect to correct ParentView URL

  • Call GET /api/digest-action/<approve_reward_token> (unauthenticated, redirect following disabled)
  • expect: Response status is 302
  • expect: Location header contains /parent/<childId>, scrollTo=<rewardId>, and entityType=reward

7.6. Approve-reward token — reward is triggered and child points are deducted

  • After calling token (7.5):
  • expect: Pending confirmation for this reward is gone from GET /api/pending-confirmations
  • expect: Child's point balance decreased by the reward cost

7.7. Deny-reward token — 302 redirect to correct ParentView URL

  • Call GET /api/digest-action/<deny_reward_token> (unauthenticated, redirect following disabled)
  • expect: Response status is 302
  • expect: Location header contains the correct ParentView deep link for this child and reward

7.8. Deny-reward token — pending reward request is removed, points unchanged

  • After calling token (7.7):
  • expect: Pending confirmation for this reward is gone
  • expect: Child's point balance is unchanged

8. Digest Action Token — Error Paths

File: e2e/mode_parent/notifications/digest-action-errors.spec.ts

Seed: Creates child DigestErrorChild and chore DigestErrorChore via API. Assigns chore. Calls POST /child/<childId>/confirm-chore to create a pending confirmation. Uses POST /api/admin/test/digest-token to generate tokens with specific expiry configurations. All GET /digest-action/<token> calls use a fresh unauthenticated context. Deletes child and task in afterAll.

8.1. Expired token returns 400

  • Call POST /api/admin/test/digest-token with expires_in_hours: -1 (already expired at creation)
  • Call GET /api/digest-action/<expired_token> (unauthenticated, redirect following disabled)
  • expect: Response status is 400
  • expect: No Location header is present (no redirect occurred)

8.2. Already-used token returns 400 on second use

  • Create a valid token via test helper
  • First call: GET /api/digest-action/<token> — expect: status 302 (token consumed, action executed)
  • Second call: GET /api/digest-action/<token> with the same token string
  • expect: Response status is 400

8.3. Tampered token returns 400

  • Create a valid token via test helper; store the raw token string
  • Modify one character of the token (e.g. replace the final character with a different letter)
  • Call GET /api/digest-action/<tampered_token> (unauthenticated)
  • expect: Response status is 400

8.4. Completely fake token returns 400

  • Call GET /api/digest-action/this-token-does-not-exist
  • expect: Response status is 400

8.5. Error response is a plain HTML page with no redirect

  • For any 400 response from 8.18.4:
  • expect: content-type header contains text/html
  • expect: The response body does not include a Location header

9. Service Worker Push Delivery — Manual Tests and Mock Strategy

Playwright limitation: The following scenarios cannot be automated with standard Playwright:

  • Playwright has no API to dispatch a push event to a registered Service Worker.
  • OS-level notification tray interactions ("Approve"/"Deny" action buttons) are outside the browser's JavaScript sandbox.
  • Delivering a real push message requires a live Web Push server sending to the browser's push endpoint, which is impractical in a local E2E environment.

These scenarios must be verified manually during feature QA, or via the mock strategy below.

Add a test-only message handler to public/sw.js, guarded by self.location.hostname === 'localhost':

// sw.js — localhost test hook only
self.addEventListener('message', async (event) => {
  if (event.data?.type !== '__TEST_NOTIFICATION_CLICK__') return
  const { action, childId, entityId, entityType } = event.data

  if (action === 'approve') {
    const url =
      entityType === 'chore'
        ? `/api/child/${childId}/approve-chore`
        : `/api/child/${childId}/trigger-reward`
    const body =
      entityType === 'chore'
        ? JSON.stringify({ task_id: entityId })
        : JSON.stringify({ reward_id: entityId })
    await fetch(url, {
      method: 'POST',
      credentials: 'include',
      headers: { 'Content-Type': 'application/json' },
      body,
    })
  } else if (action === 'deny') {
    const url =
      entityType === 'chore'
        ? `/api/child/${childId}/reject-chore`
        : `/api/child/${childId}/deny-reward-request`
    const body =
      entityType === 'chore'
        ? JSON.stringify({ task_id: entityId })
        : JSON.stringify({ reward_id: entityId })
    await fetch(url, {
      method: 'POST',
      credentials: 'include',
      headers: { 'Content-Type': 'application/json' },
      body,
    })
  } else {
    // body tap — focus or open the app at the deep link
    const deepLink = `/parent/${childId}?scrollTo=${entityId}&entityType=${entityType}`
    const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true })
    if (clients[0]) {
      await clients[0].focus()
      await clients[0].navigate(deepLink)
    } else {
      await self.clients.openWindow(deepLink)
    }
  }
})

Invoke from a Playwright spec after the SW is registered and controlling the page:

await page.evaluate(
  ({ childId, entityId, entityType, action }) => {
    navigator.serviceWorker.controller?.postMessage({
      type: '__TEST_NOTIFICATION_CLICK__',
      action,
      childId,
      entityId,
      entityType,
    })
  },
  { childId, entityId, entityType, action: 'approve' },
)

This allows a spec to simulate notification action clicks without OS interaction, enabling API verification and navigation assertions on top of the mock.

Manual QA Checklist (no spec file — document only)

  • Parent tab active: SSE in-app toast appears; no OS push notification is sent
  • Parent tab in background or closed: OS push notification appears showing the child name and chore/reward name
  • Notification does NOT appear for a reward request when the child cannot afford the reward
  • Notification shows "Approve" and "Deny" action buttons on Chrome desktop; no action buttons on iOS Safari (fallback: body tap only)
  • Clicking "Approve" on a chore notification calls POST approve-chore, dismisses the notification tray entry, and does NOT open or focus the app
  • Clicking "Deny" on a chore notification calls POST reject-chore, dismisses, no app focus
  • Clicking "Approve" on a reward notification calls POST trigger-reward, dismisses, no app focus
  • Clicking "Deny" on a reward notification calls POST deny-reward-request, dismisses, no app focus
  • Tapping the chore notification body opens (or focuses) the app at /parent/<childId>?scrollTo=<choreId>&entityType=chore with the chore card scrolled into view
  • Tapping the reward notification body opens/focuses the app at /parent/<childId>?scrollTo=<rewardId>&entityType=reward with the reward card scrolled into view
  • If the app is already open in another tab, tapping the notification body focuses the existing tab and navigates it — a second tab is NOT opened
  • iOS Safari fall-back: notification body tap still navigates correctly to the deep link; no action buttons are rendered
  • After the app tab is fully closed and a push notification arrives and is tapped, the app correctly opens at the deep-link URL

10. User Profile Notification Settings

File: e2e/mode_parent/notifications/user-profile-notification-settings.spec.ts

Seed: No child/task setup required. Tests navigate directly to the User Profile page. No afterAll cleanup beyond any push subscription state left by the tests (handled internally by beforeEach/afterEach where needed).

10.1. Email Digest toggle is visible on User Profile page

  • Navigate to /parent/profile (or the route that renders UserProfile.vue)
  • expect: An element with the label text "Daily Digest" is visible

10.2. Email Digest toggle initialises from the server value

  • Via authenticated request, call PUT /api/user/profile with { email_digest_enabled: true } to set a known state
  • Navigate to /parent/profile
  • expect: The Daily Digest toggle is checked/on
  • Via authenticated request, call PUT /api/user/profile with { email_digest_enabled: false }
  • Reload the page
  • expect: The Daily Digest toggle is unchecked/off

10.3. Toggling Email Digest off calls the correct API

  • Via request, ensure email_digest_enabled is true before the test
  • Navigate to /parent/profile
  • Intercept PUT /api/user/profile with page.route()
  • Click the Daily Digest toggle to switch it off
  • expect: Intercepted PUT body contains email_digest_enabled: false
  • expect: Toggle visually reflects the off state

10.4. Toggling Email Digest on calls the correct API

  • Via request, ensure email_digest_enabled is false before the test
  • Navigate to /parent/profile
  • Intercept PUT /api/user/profile with page.route()
  • Click the Daily Digest toggle to switch it on
  • expect: Intercepted PUT body contains email_digest_enabled: true
  • expect: Toggle visually reflects the on state

10.5. Push Notifications toggle is visible on User Profile page

  • Navigate to /parent/profile
  • expect: An element with the label text "Push Notifications" is visible

10.6. Push Notifications toggle initialises to off when no subscription exists

  • Do NOT call page.context().grantPermissions(['notifications']) (leave default denied/prompt)
  • Navigate to /parent/profile
  • expect: The Push Notifications toggle is unchecked/off

10.7. Push Notifications toggle initialises to on when a subscription already exists

  • Call page.context().grantPermissions(['notifications']) before navigation
  • Intercept POST /api/push-subscription and respond 200 OK (to avoid real push registration)
  • Navigate to /parent/tasks/chores first (triggers ParentLayout subscription POST)
  • Then navigate to /parent/profile
  • expect: The Push Notifications toggle is checked/on

10.8. Push Notifications toggle is disabled when browser permission is denied

  • Call page.context().clearPermissions() then navigate to /parent/profile with notifications permission absent (or set to 'denied' if Playwright supports it)
  • expect: The Push Notifications toggle input is disabled (aria-disabled or disabled attribute set)

10.9. Enabling Push Notifications toggle subscribes and posts to backend

  • Call page.context().grantPermissions(['notifications'])
  • Intercept POST /api/push-subscription to capture the request and respond 200 OK
  • Navigate to /parent/profile; ensure toggle starts off (no prior subscription)
  • Click the Push Notifications toggle to turn it on
  • expect: POST /api/push-subscription is called with a non-empty endpoint and keys
  • expect: Toggle visually reflects the on state

10.10. Disabling Push Notifications toggle unsubscribes and sends DELETE to backend

  • Set up a push subscription (grant permissions, intercept POST on /parent/tasks/chores to get a subscription in place)
  • Navigate to /parent/profile; toggle should be on
  • Intercept DELETE /api/push-subscription with page.route()
  • Click the Push Notifications toggle to turn it off
  • expect: DELETE /api/push-subscription is called
  • expect: Toggle visually reflects the off state

Test Summary

# Group Spec File(s) Test Cases
1 Push subscription registration push-subscription.spec.ts 5
2 Chore notification — approve flow chore-notification-approve.spec.ts 8
3 Chore notification — reject flow chore-notification-deny.spec.ts 5
4 Reward notification — grant flow reward-notification-approve.spec.ts 7
5 Reward notification — deny flow reward-notification-deny.spec.ts 6
6 ParentView deep-link navigation deep-link-navigation.spec.ts 7
7 Digest token — happy paths 4 spec files (one per entity×action) 8
8 Digest token — error paths digest-action-errors.spec.ts 5
9 SW push — manual/mock (no spec file) checklist
10 User Profile notification toggles user-profile-notification-settings.spec.ts 10

Implementation Notes

  • A backend test-only endpoint POST /api/admin/test/digest-token (guarded by DB_ENV=e2e) is a prerequisite for scenario groups 7 and 8 to work.
  • The exact UI location of the reward-denial button (group 5) is TBD by the feature implementation — update selectors once the button exists.
  • The SW mock strategy in group 9 requires a __TEST_NOTIFICATION_CLICK__ message handler added to sw.js (localhost-only, not present in production builds).
  • The playwright.config.ts catch-all project must get e2e/mode_parent/notifications/ added to its testIgnore list to prevent double-running.