# 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=&entityType=`. 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/`), and **Deny** (`GET /api/digest-action/`). 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=&entityType=`. 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/` 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. > **Note on trigger**: Push subscription is now initiated by the Push Notifications toggle in `/parent/profile` (which calls `subscribeToPushWithResult()` directly). For these tests, triggering is done via the profile toggle rather than navigating to `/parent/tasks/chores`. PIN entry also triggers subscription in production but pre-authenticated E2E sessions bypass the PIN flow. #### 1.1. Subscription POST is sent when Push Notifications toggle is enabled and permission is pre-granted - Call `page.context().grantPermissions(['notifications'])` - Intercept `POST /api/push-subscription` with `page.route()` to capture request body - Navigate to `/parent/profile` and click the Push Notifications toggle to enable it - 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/profile` and click the Push Notifications toggle to enable it - 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/profile` and attempt to click the Push Notifications toggle - 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//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=` and `entityType=chore` - expect: The "Chores" section heading is visible #### 2.4. Pending chore card shows PENDING stamp in ParentView - Navigate to `/parent/` - 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/` - 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//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/`, 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//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=` and `entityType=reward` #### 4.4. Pending reward card shows PENDING badge in ParentView - Navigate to `/parent/` - 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/`, 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//request-reward` (creates a fresh pending request before each test) and `afterEach` to call `POST /child//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/` - 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//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//cancel-request-reward` - Call `POST /child//deny-reward-request` with `{ reward_id: rewardId }` - expect: Response status is 404 --- ### 6. ParentView Deep-Link Navigation **File:** `e2e/mode_parent/notifications/deep-link-navigation.spec.ts` **Seed:** Creates child `DeepLinkChild` with `points = 100`. Creates 10 chores `DeepLinkChore1` … `DeepLinkChore10` (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//confirm-chore`. Requests the reward via `POST /child//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`. #### 6.1. Deep link to chore — correct card is scrolled into viewport - `await page.setViewportSize({ width: 480, height: 800 })` - Navigate to `/parent/?scrollTo=&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 #### 6.2. Deep link to reward — correct card is scrolled into viewport - `await page.setViewportSize({ width: 480, height: 800 })` - Navigate to `/parent/?scrollTo=&entityType=reward` - Wait for reward list to load and scroll delay - expect: The `DeepLinkReward` card is within the visible viewport #### 6.3. Deep link with unknown `entityType` — page loads without JS error - Listen for `page.on('pageerror', ...)` before navigating - Navigate to `/parent/?scrollTo=&entityType=unknown` - expect: No pageerror event fires - expect: Page loads and the "Chores" section heading is visible #### 6.4. Deep link with missing `scrollTo` — page loads normally - Navigate to `/parent/?entityType=chore` (no `scrollTo` query param) - expect: Page loads without error and "Chores" section is visible #### 6.5. Deep link with no query params — page loads normally - Navigate to `/parent/` (bare URL, no query params) - expect: Page loads and both "Chores" and "Rewards" section headings are visible #### 6.6. Clicking a chore notification produces the correct deep link - Navigate to `/parent/notifications` - Click the `DeepLinkChore10` notification item (confirmed chore) - expect: URL matches `/parent/?scrollTo=&entityType=chore` - Wait 800 ms for scroll delay - expect: `DeepLinkChore10` card is in viewport #### 6.7. Clicking a reward notification produces the correct deep link - Navigate to `/parent/notifications` - Click the `DeepLinkReward` notification item - expect: URL matches `/parent/?scrollTo=&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/` 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/` 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/` (unauthenticated, redirect following disabled) - expect: Response status is 302 - expect: `response.headers()['location']` contains `/parent/`, `scrollTo=`, 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/` - 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/` (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/` (unauthenticated, redirect following disabled) - expect: Response status is 302 - expect: Location header contains `/parent/`, `scrollTo=`, 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/` (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//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/` 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/` (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/` — expect: status 302 (token consumed, action executed) - Second call: `GET /api/digest-action/` 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/` (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.1–8.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. #### Recommended Mock Strategy — Service Worker Test Hook Add a test-only message handler to `public/sw.js`, guarded by `self.location.hostname === 'localhost'`: ```js // 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: ```ts 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/?scrollTo=&entityType=chore` with the chore card scrolled into view - Tapping the reward notification body opens/focuses the app at `/parent/?scrollTo=&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/profile` and click the Push Notifications toggle to enable it (triggers `subscribeToPushWithResult()` POST) - Reload or re-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.