feat: add end-to-end tests for parent notifications feature
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.
This commit is contained in:
2026-04-13 21:12:22 -04:00
parent ea0166d198
commit 0d50a324a3
2 changed files with 825 additions and 0 deletions

View File

@@ -0,0 +1,266 @@
# Feature: Notify parents of child activity requiring attention
## Overview
**Goal:** Alert parents in real-time when a child performs an action that requires their attention (completing a chore, or requesting an affordable reward), even when the parent does not have the app open in an active browser tab. Additionally, send a nightly email digest at 9 pm in the parent's local timezone summarising all still-unacknowledged items.
**User Story:**
As a parent, I want to receive a notification when:
- My child completes a chore so I can review and approve or reject it promptly.
- My child requests a reward they can afford so I can review and grant or deny it promptly.
I should receive these notifications without needing to have the app open.
Additionally, if any items are still unacknowledged at the end of the day, I want to receive a nightly summary email at 9 pm (my local time) that lists each pending chore and reward request with action links so I can approve or deny each item directly from the email without opening the app.
**Rules:**
- Follow `.github/copilot-instructions.md`
- Notifications must be user-opt-in (browser permission request)
- Every backend mutation must fire an SSE event (existing requirement)
- Web Push is the primary notification channel; in-app SSE toast remains the secondary channel
- Do not send email per event — email is reserved for account-level actions
- A reward request notification is only sent when the child has enough points to afford the reward; do not notify for requests the child cannot afford
- Tapping a notification must open (or focus) the app and navigate directly to the relevant child's `ParentView`, with the correct tab active and the pending item card scrolled into view
- Each notification must include an **Approve** and **Deny** action button so the parent can act directly from the OS notification tray without opening the app
- Action button API calls must be authenticated using the parent's existing HttpOnly JWT cookie (`credentials: 'include'`)
- Browsers that do not support notification `actions` (e.g. iOS Safari) fall back gracefully: tapping the notification body still opens the app as normal
- The nightly digest email is sent at 9 pm in the parent's local timezone regardless of whether they have push notifications enabled
- The digest is only sent if there is at least one unacknowledged pending item at send time
- Approve/deny links in the email are protected by short-lived signed action tokens (valid for 24 hours); they do not rely on browser cookies
- Action tokens must encode the `user_id`, `child_id`, `entity_id`, `entity_type`, and `action` and be signed with a server secret using HMAC-SHA256
- Action tokens are single-use: once redeemed the backend marks them consumed so they cannot be replayed
---
## Data Model Changes
### Backend Model
New model: `PushSubscription`
- `id: str` — unique ID
- `user_id: str` — the parent user this subscription belongs to
- `endpoint: str` — browser-provided push endpoint URL
- `keys: dict``{ p256dh: str, auth: str }` from the browser `PushSubscription`
- `timezone: str` — IANA timezone string captured from the browser (e.g. `"America/New_York"`); used to schedule the 9 pm digest
- `created_at: str` — ISO timestamp
New model: `DigestActionToken`
- `id: str` — unique token ID (also used as the URL token)
- `user_id: str`
- `child_id: str`
- `entity_id: str`
- `entity_type: str``"chore"` or `"reward"`
- `action: str``"approve"` or `"deny"`
- `expires_at: str` — ISO timestamp (24 hours from creation)
- `used: bool` — set to `True` once redeemed
- `signature: str` — HMAC-SHA256 of the above fields, keyed by `DIGEST_TOKEN_SECRET` env var
### Frontend Model
```ts
interface PushSubscription {
id: string;
user_id: string;
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
timezone: string;
created_at: string;
}
```
---
## Backend Implementation
1. Add `pywebpush` and `py-vapid` to `requirements.txt`.
2. Generate a VAPID key pair and store the public/private keys in environment config.
3. New API file: `api/push_subscription_api.py`
- `POST /push-subscription` — save a new `PushSubscription` to TinyDB for the authenticated user
- `DELETE /push-subscription` — remove the stored subscription for the authenticated user
4. New DB helper: `db/push_subscriptions.py` — CRUD for `PushSubscription` records.
5. In the chore confirmation flow (where `send_event_for_current_user` is called for a completed/pending chore), also look up the parent's stored `PushSubscription` and fire a web push payload containing: child name, chore name, `child_id`, `entity_id` (task ID), and `entity_type: "chore"`. The frontend will use these to construct the deep link `/parent/<child_id>?scrollTo=<entity_id>&entityType=chore`.
6. In the reward request flow (`child_reward_request` with operation `REQUEST_CREATED`), check whether the child's current point balance is >= the reward's cost. If so, look up the parent's stored `PushSubscription` and fire a web push payload containing: child name, reward name, reward cost, `child_id`, `entity_id` (reward ID), and `entity_type: "reward"`. The frontend constructs the deep link `/parent/<child_id>?scrollTo=<entity_id>&entityType=reward`. If the child cannot afford the reward, skip the push notification.
7. Add a new endpoint `POST /child/<id>/deny-reward-request` (with body `{ reward_id }`) that a parent can call to reject a child's pending reward request. It should remove the `PendingConfirmation` record, fire a `CHILD_REWARD_REQUEST` SSE event with operation `REQUEST_CANCELLED`, and create a tracking event with action `denied`. This is the parent-facing counterpart to the child's `cancel-request-reward`.
8. New utility: `utils/email_digest_scheduler.py`
- Uses `APScheduler` (already in use for the account deletion scheduler — follow the same pattern) with a `BackgroundScheduler`.
- Runs a job **every hour** on the server. Each run inspects all users, derives their current local hour from their stored `timezone` (from `PushSubscription`, falling back to UTC if absent), and sends the digest to any user whose local hour is `21` (9 pm) and who has at least one pending `PendingConfirmation`.
- For each pending item, generate a `DigestActionToken` for both `approve` and `deny` actions and persist them in TinyDB.
- Call `send_digest_email()` (see step 9) with the assembled item list.
- Skip sending if `DB_ENV == 'e2e'`.
9. New function `send_digest_email(to_email, items)` in `utils/email_sender.py`. Each item in `items` is a dict with keys: `child_name`, `entity_name`, `entity_type`, `view_url`, `approve_token`, `deny_token`. The HTML email body must:
- Open with a branded header ("Reward App — Daily Summary").
- Contain one section per child, listing that child's pending items.
- For each item show the item name and three links side by side:
- **View** — deep link to `<FRONTEND_URL>/parent/<child_id>?scrollTo=<entity_id>&entityType=<entity_type>`
- **Approve** — `<FRONTEND_URL>/api/digest-action/<approve_token>`
- **Deny** — `<FRONTEND_URL>/api/digest-action/<deny_token>`
- Use inline CSS styles consistent with the existing `send_pin_setup_email` format (no external stylesheets). Approve link styled green, Deny link styled red.
- Close with a footer: "You are receiving this because you have the Reward App. If items are already resolved, you can ignore this email."
10. New API endpoint `GET /digest-action/<token>` in `api/digest_action_api.py`:
- Validates the token exists in TinyDB, has not expired, has not been used, and the HMAC signature is valid.
- If invalid/expired: return a plain 400 HTML error page (no redirect).
- If valid: mark the token `used = True`, then:
- `approve` + `chore` → call the same logic as `approve-chore`
- `deny` + `chore` → call the same logic as `reject-chore`
- `approve` + `reward` → call the same logic as `trigger-reward`
- `deny` + `reward` → call the same logic as `deny-reward-request`
- On success: **302 redirect** to `<FRONTEND_URL>/parent/<child_id>?scrollTo=<entity_id>&entityType=<entity_type>` so the parent lands on the correct view.
- This endpoint is **unauthenticated** — the signed token is the credential.
11. Add `DIGEST_TOKEN_SECRET` to environment config. Document it in the README.
12. Register the new blueprint and start the digest scheduler in `main.py`.
## Backend Tests
- [ ] `POST /push-subscription` saves a subscription for the current user
- [ ] `POST /push-subscription` rejects unauthenticated requests
- [ ] `DELETE /push-subscription` removes the subscription for the current user
- [ ] Web push is fired when a chore is marked complete and the parent has a stored subscription
- [ ] Web push is not fired when the parent has no stored subscription (no error raised)
- [ ] Web push is fired when a child requests a reward they can afford and the parent has a stored subscription
- [ ] Web push is NOT fired when a child requests a reward they cannot afford
- [ ] Web push is not fired for reward requests when the parent has no stored subscription (no error raised)
- [ ] `POST /child/<id>/deny-reward-request` removes the pending confirmation and fires the `REQUEST_CANCELLED` SSE event
- [ ] `POST /child/<id>/deny-reward-request` returns 404 when no pending request exists
- [ ] `POST /child/<id>/deny-reward-request` rejects unauthenticated requests
- [ ] Digest scheduler identifies users whose local time is 9 pm and who have pending items
- [ ] Digest scheduler skips users with no pending items
- [ ] Digest scheduler skips sending when `DB_ENV == 'e2e'`
- [ ] `DigestActionToken` is created with correct fields, expiry, and valid HMAC signature
- [ ] `GET /digest-action/<token>` executes the correct backend action for each combination of `entity_type` × `action`
- [ ] `GET /digest-action/<token>` redirects to the correct deep-link URL on success
- [ ] `GET /digest-action/<token>` returns 400 for expired tokens
- [ ] `GET /digest-action/<token>` returns 400 for already-used tokens
- [ ] `GET /digest-action/<token>` returns 400 for tampered/invalid signatures
- [ ] Digest email HTML contains per-child sections, item names, and View / Approve / Deny links
---
## Frontend Implementation
1. Register a Service Worker (`public/sw.js`) that listens for the `push` event and calls `self.registration.showNotification(...)` with:
- `title`, `body`, and a `data` object containing `child_id`, `entity_id`, and `entity_type`
- An `actions` array: `[{ action: 'approve', title: 'Approve' }, { action: 'deny', title: 'Deny' }]`
- Note: the `actions` field is silently ignored by browsers that do not support it (e.g. iOS Safari); the notification body tap still works normally.
2. On parent mount (e.g., `ParentLayout.vue` or `App.vue`), request notification permission and, if granted, retrieve the `PushSubscription` from the browser and `POST` it to `/api/push-subscription`, including the user's IANA timezone string: `Intl.DateTimeFormat().resolvedOptions().timeZone`.
3. Clean up: if permission is denied or revoked, call `DELETE /api/push-subscription`.
4. The Service Worker's `notificationclick` handler must:
- Close the notification with `event.notification.close()`
- If `event.action === 'approve'`:
- For `entity_type === 'chore'`: `fetch('/api/child/<child_id>/approve-chore', { method: 'POST', credentials: 'include', body: { task_id: entity_id } })`
- For `entity_type === 'reward'`: `fetch('/api/child/<child_id>/trigger-reward', { method: 'POST', credentials: 'include', body: { reward_id: entity_id } })`
- If `event.action === 'deny'`:
- For `entity_type === 'chore'`: `fetch('/api/child/<child_id>/reject-chore', { method: 'POST', credentials: 'include', body: { task_id: entity_id } })`
- For `entity_type === 'reward'`: `fetch('/api/child/<child_id>/deny-reward-request', { method: 'POST', credentials: 'include', body: { reward_id: entity_id } })`
- If no action (body tap): construct the deep-link URL `/parent/<child_id>?scrollTo=<entity_id>&entityType=<entity_type>`, check `clients.matchAll({ type: 'window' })` for an existing open window on the same origin — if found, call `client.focus()` and `client.navigate(url)`; otherwise call `clients.openWindow(url)`.
5. `ParentView` already handles the `scrollTo` + `entityType` query params: it activates the corresponding tab (chore or reward) and scrolls the matching card into view. No changes to `ParentView` are required if this mechanism already works; verify and document any gaps.
6. Existing SSE in-app toast/badge behavior is unchanged — it remains the secondary channel when the tab is active.
## Frontend Tests
- [ ] Parent sees a browser notification when a chore is completed and the tab is backgrounded
- [ ] Chore notification includes "Approve" and "Deny" action buttons (on supporting browsers)
- [ ] Clicking "Approve" on a chore notification calls `approve-chore` and dismisses the notification without opening the app
- [ ] Clicking "Deny" on a chore notification calls `reject-chore` and dismisses the notification without opening the app
- [ ] Tapping the chore notification body opens the app, activates the task tab in `ParentView`, and scrolls the pending chore card into view
- [ ] Parent sees a browser notification when a child requests an affordable reward and the tab is backgrounded
- [ ] Reward notification includes "Approve" and "Deny" action buttons (on supporting browsers)
- [ ] Clicking "Approve" on a reward notification calls `trigger-reward` and dismisses the notification without opening the app
- [ ] Clicking "Deny" on a reward notification calls `deny-reward-request` and dismisses the notification without opening the app
- [ ] Tapping the reward notification body opens the app, activates the reward tab in `ParentView`, and scrolls the pending reward card into view
- [ ] If the app is already open in another tab, tapping the notification body focuses that tab and navigates it (no duplicate window opened)
- [ ] No browser notification is shown for a reward request the child cannot afford
- [ ] Notification permission is requested on parent login/mount
- [ ] If permission is denied, no subscription is posted to the backend
- [ ] Push subscription `POST` body includes the browser's IANA timezone string
---
## Future Considerations
- **iOS PWA caveat**: Web Push on iOS Safari requires the user to have added the app to their Home Screen (PWA mode). Until then, the in-app SSE toast is the only delivery mechanism for iOS Safari users in a regular browser tab. Consider prompting parents to install the PWA. Note that notification `actions` buttons are also not supported on iOS Safari — the body-tap deep-link is the only interaction available on that platform.
- **Notification preferences**: Allow parents to toggle specific notification types (chore complete, reward request, etc.) per child.
- **Configurable digest time**: Allow the parent to choose what time the daily digest is sent (default 9 pm).
- **Digest opt-out**: Allow parents to unsubscribe from the daily digest independently of push notifications.
---
## E2E Test Plan
A full Playwright E2E test plan has been produced and saved to:
`frontend/vue-app/e2e/plans/parent-notifications.plan.md`
The plan covers 9 scenario groups (41 automated test cases + 1 manual QA checklist):
| # | Group | Cases |
| --- | ----------------------------------------- | ----- |
| 1 | Push subscription registration | 5 |
| 2 | Chore notification — approve flow | 8 |
| 3 | Chore notification — reject flow | 5 |
| 4 | Reward notification — grant flow | 7 |
| 5 | Reward notification — deny flow | 6 |
| 6 | ParentView deep-link navigation | 7 |
| 7 | Digest action token — happy paths | 8 |
| 8 | Digest action token — error paths | 5 |
| 9 | Service Worker push — manual QA checklist | — |
**Prerequisite noted in plan:** A backend test-only endpoint `POST /api/admin/test/digest-token` (active only when `DB_ENV=e2e`) is required before scenario groups 7 and 8 can run.
---
## Acceptance Criteria (Definition of Done)
### Backend
- [ ] `PushSubscription` model and DB helper exist and follow existing patterns
- [ ] `POST /push-subscription` and `DELETE /push-subscription` endpoints are implemented and authenticated
- [ ] Web push is sent to the parent when a child completes a chore and a subscription is on file
- [ ] Web push is sent to the parent when a child requests an affordable reward and a subscription is on file
- [ ] Web push is NOT sent when the child cannot afford the requested reward
- [ ] `POST /child/<id>/deny-reward-request` endpoint is implemented, authenticated, fires the correct SSE event, and creates a tracking event
- [ ] `utils/email_digest_scheduler.py` is implemented using APScheduler, runs hourly, and sends digests at 9 pm local time
- [ ] `send_digest_email()` produces a well-formatted HTML email with per-child sections and View / Approve / Deny links for each pending item
- [ ] `GET /digest-action/<token>` validates the token and performs the correct action
- [ ] `DigestActionToken` is single-use: redeeming it marks it consumed and subsequent requests with the same token return 400
- [ ] `DIGEST_TOKEN_SECRET` and VAPID keys are configurable via environment variables (not hardcoded)
- [ ] All backend tests pass
### Frontend
- [ ] Service Worker is registered and handles `push` and `notificationclick` events
- [ ] Parent is prompted for notification permission on mount
- [ ] Subscription is posted to the backend on permission grant and removed on revoke/deny
- [ ] Native notification appears when a chore is completed with the tab backgrounded, with "Approve" and "Deny" action buttons
- [ ] Native notification appears when a child requests an affordable reward with the tab backgrounded, with "Approve" and "Deny" action buttons
- [ ] No native notification appears for a reward request the child cannot afford
- [ ] Clicking "Approve" on a chore notification calls `approve-chore` without opening the app
- [ ] Clicking "Deny" on a chore notification calls `reject-chore` without opening the app
- [ ] Clicking "Approve" on a reward notification calls `trigger-reward` without opening the app
- [ ] Clicking "Deny" on a reward notification calls `deny-reward-request` without opening the app
- [ ] Tapping a chore notification body opens (or focuses) the app, activates the task tab, and scrolls the pending chore card into view
- [ ] Tapping a reward notification body opens (or focuses) the app, activates the reward tab, and scrolls the pending reward card into view
- [ ] If the app is already open, the existing tab is focused and navigated rather than opening a new window
- [ ] In-app SSE toast behavior is unchanged
- [ ] Push subscription registration includes the browser's IANA timezone
- [ ] All frontend tests pass
### Email Digest
- [ ] Digest email is sent at 9 pm in the parent's local timezone when pending items exist
- [ ] Digest is not sent if there are no pending items
- [ ] Email contains one section per child with pending items
- [ ] Each item row shows the item name and three links: View, Approve, Deny
- [ ] Approve link is visually styled green; Deny link is styled red
- [ ] Clicking Approve in the email performs the approve action and redirects to the correct `ParentView` deep link
- [ ] Clicking Deny in the email performs the deny action and redirects to the correct `ParentView` deep link
- [ ] Clicking View in the email opens the app to the correct `ParentView` deep link
- [ ] Action tokens expire after 24 hours and return a 400 error page when used after expiry
- [ ] Action tokens are single-use: a second click on the same link returns a 400 error page
- [ ] Digest is not sent in `e2e` test environment