All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m19s
- Implement tests for push subscription registration, chore and reward notifications, and deep-link navigation. - Cover scenarios for approving and denying chores and rewards, including token validation for digest actions. - Introduce a mock strategy for service worker push delivery to facilitate manual testing. - Ensure isolated test setups with appropriate cleanup after tests.
560 lines
28 KiB
Markdown
560 lines
28 KiB
Markdown
# 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
|
||
|
||
---
|
||
|
||
### 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.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/<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
|
||
|
||
---
|
||
|
||
## 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 |
|
||
|
||
## 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.
|