Files
chore/frontend/e2e/plans/parent-notifications.plan.md
Ryan Kegel 127378797c
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m46s
Refactored frontend directory
2026-04-25 00:40:15 -04:00

603 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 1.1 and 1.2 (intercepting the subscription POST to verify endpoint/timezone) depend on VAPID being configured and `PushManager` being available — both absent in headless Chromium. These tests have been **removed** from the spec. The remaining tests (1.31.5) cover the permission-denied path and unauthenticated API rejection, which do not require VAPID.
**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.
> **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.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/<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
---
### 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/<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`.
#### 6.1. Deep link to chore — correct card is scrolled into viewport
- `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
#### 6.2. Deep link to reward — correct card is scrolled into 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
#### 6.3. Deep link with unknown `entityType` — page loads without JS error
- 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
#### 6.4. Deep link with missing `scrollTo` — page loads normally
- Navigate to `/parent/<childId>?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/<childId>` (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/<childId>?scrollTo=<choreId>&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/<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.
#### 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/<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.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)
---
## Test Summary
| # | Group | Spec File(s) | Test Cases |
| --- | --------------------------------- | -------------------------------------------- | ---------- |
| 1 | Push subscription registration | `push-subscription.spec.ts` | 3 |
| 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` | 7 |
## 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 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.
- Tests 1.1, 1.2, 10.7, 10.9, and 10.10 were removed: all required `PushManager.subscribe()` which is unavailable in headless Chromium without a VAPID key. The features they covered work correctly in a real browser; these scenarios are verified via manual QA (see §9).