Add end-to-end tests for parent notifications and actions
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m35s

- Implement tests for approving and denying rewards and chores, including token generation and validation.
- Create tests for error handling scenarios with expired, tampered, and fake tokens.
- Add tests for push subscription registration and user profile notification settings.
- Ensure that notifications reflect the correct state of rewards and chores in the UI.
- Validate that toggles for email digest and push notifications function correctly based on user permissions and server state.
This commit is contained in:
2026-04-20 20:32:19 -04:00
parent ee16b49020
commit 4ee5367742
22 changed files with 1814 additions and 67 deletions

View File

@@ -0,0 +1,27 @@
---
name: playwright-default
description: Provides Playwright test generation and analysis for E2E testing.
---
# Role: Senior QA Automation Engineer
You are a Playwright expert. Your goal is to create robust, flake-free E2E tests.
# Test Implementation & Healing Workflow
When you receive a test plan:
1. **Implement**: Generate the `.spec.ts` files in `/tests` using standard Playwright patterns.
2. **Verify**: Once files are written, execute the following command in the terminal:
`npx playwright test`
3. **Analyze & Repair**:
- If the playwright-healer skill proposes a patch, review it.
- If the test still fails after healing, check the **Flask backend logs** to see if it's an API error rather than a UI error.
4. **Final Check**: Only mark the task as "Complete" once `npx playwright test` returns a clean pass.
## Rules of Engagement
1. **Locators:** Prioritize `getByRole`, `getByLabel`, and `getByText`. Avoid CSS selectors unless necessary.
2. **Page Objects:** Always use the Page Object Model (POM). Check `tests/pages/` for existing objects before creating new ones.
3. **Environment:** The app runs at `https://localhost:5173` (HTTPS — self-signed cert). The backend runs at `http://localhost:5000`.
4. **Authentication:** Auth is handled globally via `storageState`. Do NOT navigate to `/auth/login` in any test — you are already logged in. Never hardcode credentials; import `E2E_EMAIL` and `E2E_PASSWORD` from `tests/global-setup.ts` if needed.

View File

@@ -240,11 +240,11 @@ A full Playwright E2E test plan has been produced and saved to:
`frontend/vue-app/e2e/plans/parent-notifications.plan.md` `frontend/vue-app/e2e/plans/parent-notifications.plan.md`
The plan covers 9 scenario groups (41 automated test cases + 1 manual QA checklist): The plan covers 10 scenario groups (56 automated test cases + 1 manual QA checklist):
| # | Group | Cases | | # | Group | Cases |
| --- | ----------------------------------------- | ----- | | --- | ----------------------------------------- | ----- |
| 1 | Push subscription registration | 5 | | 1 | Push subscription registration | 3 |
| 2 | Chore notification — approve flow | 8 | | 2 | Chore notification — approve flow | 8 |
| 3 | Chore notification — reject flow | 5 | | 3 | Chore notification — reject flow | 5 |
| 4 | Reward notification — grant flow | 7 | | 4 | Reward notification — grant flow | 7 |
@@ -253,6 +253,7 @@ The plan covers 9 scenario groups (41 automated test cases + 1 manual QA checkli
| 7 | Digest action token — happy paths | 8 | | 7 | Digest action token — happy paths | 8 |
| 8 | Digest action token — error paths | 5 | | 8 | Digest action token — error paths | 5 |
| 9 | Service Worker push — manual QA checklist | — | | 9 | Service Worker push — manual QA checklist | — |
| 10 | User Profile notification toggles | 7 |
**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. **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.

View File

@@ -1,10 +1,12 @@
import os
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from datetime import datetime, timedelta from datetime import datetime, timedelta
from tinydb import Query from tinydb import Query
from db.db import users_db from db.db import users_db
from models.user import User from models.user import User
from api.utils import admin_required from api.utils import admin_required, get_validated_user_id
from config.deletion_config import ( from config.deletion_config import (
ACCOUNT_DELETION_THRESHOLD_HOURS, ACCOUNT_DELETION_THRESHOLD_HOURS,
MIN_THRESHOLD_HOURS, MIN_THRESHOLD_HOURS,
@@ -153,3 +155,54 @@ def trigger_deletion_queue():
except Exception as e: except Exception as e:
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500 return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
# ---------------------------------------------------------------------------
# Test-only endpoint — active ONLY when DB_ENV=e2e
# ---------------------------------------------------------------------------
@admin_api.route('/admin/test/digest-token', methods=['POST'])
def create_test_digest_token():
"""Create a valid DigestActionToken for E2E tests.
Only active when DB_ENV=e2e. Requires authentication.
"""
if os.environ.get('DB_ENV') != 'e2e':
return jsonify({'error': 'Not found', 'code': 'NOT_FOUND'}), 404
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
data = request.get_json() or {}
child_id = data.get('child_id')
entity_id = data.get('entity_id')
entity_type = data.get('entity_type')
action = data.get('action')
expires_in_hours = data.get('expires_in_hours', 24)
if not all([child_id, entity_id, entity_type, action]):
return jsonify({'error': 'child_id, entity_id, entity_type, and action are required',
'code': 'MISSING_FIELDS'}), 400
if entity_type not in ('chore', 'reward'):
return jsonify({'error': 'entity_type must be "chore" or "reward"',
'code': 'INVALID_ENTITY_TYPE'}), 400
if action not in ('approve', 'deny'):
return jsonify({'error': 'action must be "approve" or "deny"',
'code': 'INVALID_ACTION'}), 400
try:
from utils.digest_token import create_action_token
token = create_action_token(
user_id=user_id,
child_id=child_id,
entity_id=entity_id,
entity_type=entity_type,
action=action,
expiry_hours=int(expires_in_hours),
)
return jsonify({'token': token.id}), 200
except Exception as e:
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500

View File

@@ -2,20 +2,20 @@
"cookies": [ "cookies": [
{ {
"name": "refresh_token", "name": "refresh_token",
"value": "jvA7pfHaKU5t0mzLmz2tEsWpCPk92LNv8TiBbPqQPr4", "value": "uCNPqnIUbEDv2aKdtv1Lh-zEZxS5tE0gpk9LmsfHVs4",
"domain": "localhost", "domain": "localhost",
"path": "/api/auth", "path": "/api/auth",
"expires": 1783780502.776691, "expires": 1784492401.030393,
"httpOnly": true, "httpOnly": true,
"secure": true, "secure": true,
"sameSite": "Strict" "sameSite": "Strict"
}, },
{ {
"name": "access_token", "name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiI1YTI0NTY3Ny00YzRiLTRmMWUtODVjZi0zOWVkZmEwMjIwNjQiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzYwMDU0MDJ9.qPwnX1ZvCqaeuKblbD4y5CSNFfwDVSuR0ow_Eks2-DA", "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiI2MDI3NTUyZS02MDJmLTQ0ZWQtYWNkMS01OTM0MzU3OGY5YTEiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzY3MTczMDB9.DtQwu705IbWt94Dt40heWrL7aVvvC3MtKWr3pCrIiDY",
"domain": "localhost", "domain": "localhost",
"path": "/", "path": "/",
"expires": 1776005402.775975, "expires": 1776717301.029539,
"httpOnly": true, "httpOnly": true,
"secure": true, "secure": true,
"sameSite": "Lax" "sameSite": "Lax"
@@ -27,11 +27,11 @@
"localStorage": [ "localStorage": [
{ {
"name": "authSyncEvent", "name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1776004502483}" "value": "{\"type\":\"logout\",\"at\":1776716400739}"
}, },
{ {
"name": "parentAuth", "name": "parentAuth",
"value": "{\"expiresAt\":1776177303116}" "value": "{\"expiresAt\":1776889201330}"
} }
] ]
} }

View File

@@ -2,20 +2,20 @@
"cookies": [ "cookies": [
{ {
"name": "refresh_token", "name": "refresh_token",
"value": "4eQQgcHp1arsl2u0kVDy5k19v75GcVFjYMGYNjSt9sE", "value": "dRU7FVos5Nre63rKcSXsiGog4IKVkqtodoMq0OpovGI",
"domain": "localhost", "domain": "localhost",
"path": "/api/auth", "path": "/api/auth",
"expires": 1783780502.644833, "expires": 1784492401.312877,
"httpOnly": true, "httpOnly": true,
"secure": true, "secure": true,
"sameSite": "Strict" "sameSite": "Strict"
}, },
{ {
"name": "access_token", "name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiZjNjZDUyOWMtZjFiNy00ZDNhLTlhNDYtMzAyYzg4YWQzNmUzIiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzc2MDA1NDAyfQ.Y5NLYXHVRwNd4h5K1VwY9TrzaS-caMyaEey5xd4t5-0", "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiZWUxMWMzNzYtMzcwZi00YmYyLTg0NGEtYmU4MzI1ZGNjZjc5IiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzc2NzE3MzAxfQ.LyGI4KtvUl7dyukrKSidfW__-EDDXdTrLNhpuYkkp4U",
"domain": "localhost", "domain": "localhost",
"path": "/", "path": "/",
"expires": 1776005402.6442, "expires": 1776717301.312238,
"httpOnly": true, "httpOnly": true,
"secure": true, "secure": true,
"sameSite": "Lax" "sameSite": "Lax"
@@ -27,11 +27,11 @@
"localStorage": [ "localStorage": [
{ {
"name": "authSyncEvent", "name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1776004502341}" "value": "{\"type\":\"logout\",\"at\":1776716400993}"
}, },
{ {
"name": "parentAuth", "name": "parentAuth",
"value": "{\"expiresAt\":1776177303038}" "value": "{\"expiresAt\":1776889201788}"
} }
] ]
} }

View File

@@ -2,20 +2,20 @@
"cookies": [ "cookies": [
{ {
"name": "refresh_token", "name": "refresh_token",
"value": "ZV9WVFXOwIOdnTD5Xw3hSQAUmfpNdmVtDPpwz6IhCjc", "value": "1LfvXeXfo3c9Svs6ZosxyUL4DIhj0IrF3NnIF3PSId8",
"domain": "localhost", "domain": "localhost",
"path": "/api/auth", "path": "/api/auth",
"expires": 1783780499.040639, "expires": 1784507076.922515,
"httpOnly": true, "httpOnly": true,
"secure": true, "secure": true,
"sameSite": "Strict" "sameSite": "Strict"
}, },
{ {
"name": "access_token", "name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiJiOTQxMDVkZS1hNmIyLTQxMGEtODA1YS00YzhkYjc2NGU0ZWIiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzYwMDUzOTl9.XFDt0AnIgliNVOJOfNubRJgu1RI-LA2i95cfCwn6QEk", "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiJmOTY4MDNhNy00OTQ1LTRlMmMtOTliYy01M2VlODk0MmUxNTYiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzY3MzE5NzZ9.zfkdccp4rDbdfkD8m4VsBRwLnTBA7TO2mNHY6LRS8sA",
"domain": "localhost", "domain": "localhost",
"path": "/", "path": "/",
"expires": 1776005399.040139, "expires": 1776731976.922024,
"httpOnly": true, "httpOnly": true,
"secure": true, "secure": true,
"sameSite": "Lax" "sameSite": "Lax"
@@ -27,11 +27,11 @@
"localStorage": [ "localStorage": [
{ {
"name": "authSyncEvent", "name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1776004498844}" "value": "{\"type\":\"logout\",\"at\":1776731076713}"
}, },
{ {
"name": "parentAuth", "name": "parentAuth",
"value": "{\"expiresAt\":1776177299216}" "value": "{\"expiresAt\":1776903877103}"
} }
] ]
} }

View File

@@ -0,0 +1,164 @@
// spec: e2e/plans/parent-notifications.plan.md §2
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
const CHILD_NAME = 'ChoreNotifApproveChild'
const CHORE_NAME = 'ChoreNotifApproveChore'
const CHORE_POINTS = 5
async function createChild(request: APIRequestContext, name: string): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
return (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
}
async function createTask(
request: APIRequestContext,
name: string,
points: number,
): Promise<string> {
const pre = await request.get('/api/task/list')
for (const t of (await pre.json()).tasks ?? []) {
if (t.name === name) await request.delete(`/api/task/${t.id}`)
}
await request.put('/api/task/add', { data: { name, points, type: 'chore' } })
const list = await request.get('/api/task/list')
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
}
async function getPoints(page: Page): Promise<number> {
const text = await page.locator('.points .value').textContent()
return parseInt(text?.trim() ?? '0', 10)
}
function choreSection(page: Page) {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Chores' }),
})
}
test.describe('Chore Notification — Appearance and In-App Approval', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let choreId = ''
let pointsBeforeApproval = 0
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME)
choreId = await createTask(request, CHORE_NAME, CHORE_POINTS)
await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: [choreId], type: 'chore' },
})
// Set a daily interval schedule so the chore persists post-approval
await request.put(`/api/child/${childId}/task/${choreId}/schedule`, {
data: {
mode: 'interval',
interval_days: 1,
anchor_date: new Date().toLocaleDateString('en-CA'),
interval_has_deadline: false,
},
})
// Confirm the chore to create a pending notification
await request.post(`/api/child/${childId}/confirm-chore`, {
data: { task_id: choreId },
})
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (choreId) await request.delete(`/api/task/${choreId}`)
})
// 2.1 Confirmed chore appears in the notification view
test('Confirmed chore appears in notification view', async ({ page }) => {
await page.goto('/parent/notifications')
const item = page.locator('.list-item').filter({ hasText: CHORE_NAME })
await expect(item).toBeVisible({ timeout: 5000 })
await expect(item).toContainText('completed')
})
// 2.2 Notification item displays the child's name
test('Notification item displays the child name', async ({ page }) => {
await page.goto('/parent/notifications')
const item = page.locator('.list-item').filter({ hasText: CHORE_NAME })
await expect(item).toBeVisible({ timeout: 5000 })
await expect(item).toContainText(CHILD_NAME)
})
// 2.3 Clicking notification navigates to ParentView with correct query params
test('Clicking notification navigates to ParentView with correct query params', async ({
page,
}) => {
await page.goto('/parent/notifications')
const item = page.locator('.list-item').filter({ hasText: CHORE_NAME })
await expect(item).toBeVisible({ timeout: 5000 })
await item.click()
await page.waitForURL(new RegExp(`/parent/${childId}\\?scrollTo=.*&entityType=chore`), {
timeout: 5000,
})
expect(page.url()).toContain(`scrollTo=${choreId}`)
expect(page.url()).toContain('entityType=chore')
await expect(page.locator('h3', { hasText: 'Chores' })).toBeVisible()
})
// 2.4 Pending chore card shows PENDING stamp in ParentView
test('Pending chore card shows PENDING stamp in ParentView', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await expect(card).toBeVisible({ timeout: 5000 })
await expect(card).toContainText('PENDING')
})
// 2.5 Clicking a pending chore card opens ChoreApproveDialog
test('Clicking a pending chore card opens ChoreApproveDialog', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await expect(card).toBeVisible({ timeout: 5000 })
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.click()
await expect(page.getByRole('button', { name: 'Approve' })).toBeVisible({ timeout: 5000 })
await expect(page.getByRole('button', { name: 'Reject' })).toBeVisible()
})
// 2.6 Approving via ChoreApproveDialog removes the PENDING stamp
test('Approving via ChoreApproveDialog removes PENDING stamp', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await expect(card).toBeVisible({ timeout: 5000 })
// Capture points before approval for test 2.8 verification
pointsBeforeApproval = await getPoints(page)
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.click()
const approveBtn = page.getByRole('button', { name: 'Approve' })
await expect(approveBtn).toBeVisible({ timeout: 5000 })
await approveBtn.click()
await expect(approveBtn).not.toBeVisible({ timeout: 5000 })
await expect(card).not.toContainText('PENDING', { timeout: 5000 })
})
// 2.7 Approving a chore clears it from the notification view
test('Approving a chore clears it from notification view', async ({ page }) => {
await page.goto('/parent/notifications')
const item = page.locator('.list-item').filter({ hasText: CHORE_NAME })
await expect(item).not.toBeVisible({ timeout: 5000 })
})
// 2.8 Approving a chore awards points to the child
test('Approving a chore awards points to the child', async ({ page }) => {
// The chore was approved in test 2.6. Verify the points were updated correctly.
// (Cannot re-confirm as the chore was already approved today.)
await page.goto(`/parent/${childId}`)
const currentPoints = await getPoints(page)
expect(currentPoints).toBe(pointsBeforeApproval + CHORE_POINTS)
})
})

View File

@@ -0,0 +1,141 @@
// spec: e2e/plans/parent-notifications.plan.md §3
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
const CHILD_NAME = 'ChoreNotifDenyChild'
const CHORE_NAME = 'ChoreNotifDenyChore'
const CHORE_POINTS = 5
async function createChild(request: APIRequestContext, name: string): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
return (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
}
async function createTask(
request: APIRequestContext,
name: string,
points: number,
): Promise<string> {
const pre = await request.get('/api/task/list')
for (const t of (await pre.json()).tasks ?? []) {
if (t.name === name) await request.delete(`/api/task/${t.id}`)
}
await request.put('/api/task/add', { data: { name, points, type: 'chore' } })
const list = await request.get('/api/task/list')
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
}
async function getPoints(page: Page): Promise<number> {
const text = await page.locator('.points .value').textContent()
return parseInt(text?.trim() ?? '0', 10)
}
function choreSection(page: Page) {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Chores' }),
})
}
test.describe('Chore Notification — In-App Rejection', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let choreId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME)
choreId = await createTask(request, CHORE_NAME, CHORE_POINTS)
await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: [choreId], type: 'chore' },
})
// Set a daily interval schedule so the chore persists post-rejection
await request.put(`/api/child/${childId}/task/${choreId}/schedule`, {
data: {
mode: 'interval',
interval_days: 1,
anchor_date: new Date().toLocaleDateString('en-CA'),
interval_has_deadline: false,
},
})
await request.post(`/api/child/${childId}/confirm-chore`, {
data: { task_id: choreId },
})
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (choreId) await request.delete(`/api/task/${choreId}`)
})
// 3.1 Confirmed chore appears in the notification view
test('Confirmed chore appears in notification view', async ({ page }) => {
await page.goto('/parent/notifications')
const item = page.locator('.list-item').filter({ hasText: CHORE_NAME })
await expect(item).toBeVisible({ timeout: 5000 })
})
// 3.2 ChoreApproveDialog shows both Approve and Reject buttons
test('ChoreApproveDialog shows both Approve and Reject buttons', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await expect(card).toBeVisible({ timeout: 5000 })
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.click()
await expect(page.getByRole('button', { name: 'Approve' })).toBeVisible({ timeout: 5000 })
await expect(page.getByRole('button', { name: 'Reject' })).toBeVisible()
})
// 3.3 Rejecting via ChoreApproveDialog removes the PENDING stamp
test('Rejecting via ChoreApproveDialog removes PENDING stamp', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await expect(card).toBeVisible({ timeout: 5000 })
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.click()
const rejectBtn = page.getByRole('button', { name: 'Reject' })
await expect(rejectBtn).toBeVisible({ timeout: 5000 })
await rejectBtn.click()
await expect(rejectBtn).not.toBeVisible({ timeout: 5000 })
await expect(card).not.toContainText('PENDING', { timeout: 5000 })
})
// 3.4 Rejecting a chore clears it from the notification view
test('Rejecting a chore clears it from notification view', async ({ page }) => {
await page.goto('/parent/notifications')
const item = page.locator('.list-item').filter({ hasText: CHORE_NAME })
await expect(item).not.toBeVisible({ timeout: 5000 })
})
// 3.5 Rejecting a chore does not award points
test('Rejecting a chore does not award points', async ({ page, request }) => {
// Re-confirm the chore for this test
await request.post(`/api/child/${childId}/confirm-chore`, {
data: { task_id: choreId },
})
await page.goto(`/parent/${childId}`)
const before = await getPoints(page)
const card = choreSection(page).locator('.item-card').filter({ hasText: CHORE_NAME })
await expect(card).toBeVisible({ timeout: 5000 })
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.click()
const rejectBtn = page.getByRole('button', { name: 'Reject' })
await expect(rejectBtn).toBeVisible({ timeout: 5000 })
await rejectBtn.click()
await expect(rejectBtn).not.toBeVisible({ timeout: 5000 })
await expect(page.locator('.points .value')).toHaveText(String(before), { timeout: 5000 })
})
})

View File

@@ -0,0 +1,204 @@
// spec: e2e/plans/parent-notifications.plan.md §6
import { test, expect, type APIRequestContext, type Page, type Locator } from '@playwright/test'
const CHILD_NAME = 'DeepLinkChild'
const CHORE_PREFIX = 'DeepLinkChore'
const CHORE_COUNT = 10
const REWARD_NAME = 'DeepLinkReward'
const REWARD_COST = 20
const INITIAL_POINTS = 100
const NARROW_VIEWPORT = { width: 480, height: 800 }
async function createChild(
request: APIRequestContext,
name: string,
points: number,
): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
const id = (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
if (points > 0) await request.put(`/api/child/${id}/edit`, { data: { points } })
return id
}
async function createTask(
request: APIRequestContext,
name: string,
points: number,
): Promise<string> {
const pre = await request.get('/api/task/list')
for (const t of (await pre.json()).tasks ?? []) {
if (t.name === name) await request.delete(`/api/task/${t.id}`)
}
await request.put('/api/task/add', { data: { name, points, type: 'chore' } })
const list = await request.get('/api/task/list')
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
}
async function createReward(
request: APIRequestContext,
name: string,
cost: number,
): Promise<string> {
const pre = await request.get('/api/reward/list')
for (const r of (await pre.json()).rewards ?? []) {
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
}
await request.put('/api/reward/add', { data: { name, description: 'deep link test', cost } })
const list = await request.get('/api/reward/list')
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
}
async function isInViewport(locator: Locator): Promise<boolean> {
return locator.evaluate((el) => {
const rect = el.getBoundingClientRect()
return (
rect.bottom > 0 &&
rect.right > 0 &&
rect.top < (window.innerHeight || document.documentElement.clientHeight) &&
rect.left < (window.innerWidth || document.documentElement.clientWidth)
)
})
}
function choreSection(page: Page): Locator {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Chores' }),
})
}
function rewardSection(page: Page): Locator {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Rewards' }),
})
}
test.describe('ParentView Deep-Link Navigation', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
const choreIds: string[] = []
let targetChoreId = ''
let rewardId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME, INITIAL_POINTS)
for (let i = 1; i <= CHORE_COUNT; i++) {
choreIds.push(await createTask(request, `${CHORE_PREFIX}${i}`, 5))
}
rewardId = await createReward(request, REWARD_NAME, REWARD_COST)
await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: choreIds, type: 'chore' },
})
await request.put(`/api/child/${childId}/set-rewards`, {
data: { reward_ids: [rewardId] },
})
// Target the last chore (most likely off-screen)
targetChoreId = choreIds[CHORE_COUNT - 1]
await request.post(`/api/child/${childId}/confirm-chore`, {
data: { task_id: targetChoreId },
})
await request.post(`/api/child/${childId}/request-reward`, {
data: { reward_id: rewardId },
})
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
for (const id of choreIds) await request.delete(`/api/task/${id}`)
if (rewardId) await request.delete(`/api/reward/${rewardId}`)
})
// 6.1 Deep link to chore — correct card is scrolled into viewport
test('Deep link to chore scrolls correct card into viewport', async ({ page }) => {
await page.setViewportSize(NARROW_VIEWPORT)
await page.goto(`/parent/${childId}?scrollTo=${targetChoreId}&entityType=chore`)
const card = choreSection(page)
.locator('.item-card')
.filter({ hasText: `${CHORE_PREFIX}${CHORE_COUNT}` })
await expect(card).toBeVisible({ timeout: 5000 })
await page.waitForTimeout(800)
expect(await isInViewport(card)).toBe(true)
})
// 6.2 Deep link to reward — correct card is scrolled into viewport
test('Deep link to reward scrolls correct card into viewport', async ({ page }) => {
await page.setViewportSize(NARROW_VIEWPORT)
await page.goto(`/parent/${childId}?scrollTo=${rewardId}&entityType=reward`)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await expect(card).toBeVisible({ timeout: 5000 })
await page.waitForTimeout(800)
expect(await isInViewport(card)).toBe(true)
})
// 6.3 Deep link with unknown entityType — page loads without JS error
test('Deep link with unknown entityType loads without JS error', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (err) => errors.push(err.message))
await page.goto(`/parent/${childId}?scrollTo=${targetChoreId}&entityType=unknown`)
await page.waitForTimeout(500)
expect(errors).toHaveLength(0)
await expect(page.locator('h3', { hasText: 'Chores' })).toBeVisible()
})
// 6.4 Deep link with missing scrollTo — page loads normally
test('Deep link with missing scrollTo loads normally', async ({ page }) => {
await page.goto(`/parent/${childId}?entityType=chore`)
await expect(page.locator('h3', { hasText: 'Chores' })).toBeVisible({ timeout: 5000 })
})
// 6.5 Deep link with no query params — page loads normally
test('Deep link with no query params loads normally', async ({ page }) => {
await page.goto(`/parent/${childId}`)
await expect(page.locator('h3', { hasText: 'Chores' })).toBeVisible({ timeout: 5000 })
await expect(page.locator('h3', { hasText: 'Rewards' })).toBeVisible()
})
// 6.6 Clicking a chore notification produces the correct deep link
test('Clicking a chore notification produces correct deep link', async ({ page }) => {
await page.setViewportSize(NARROW_VIEWPORT)
await page.goto('/parent/notifications')
const item = page.locator('.list-item').filter({ hasText: `${CHORE_PREFIX}${CHORE_COUNT}` })
await expect(item).toBeVisible({ timeout: 5000 })
await item.click()
await page.waitForURL(new RegExp(`/parent/${childId}\\?scrollTo=.*&entityType=chore`), {
timeout: 5000,
})
expect(page.url()).toContain(`scrollTo=${targetChoreId}`)
expect(page.url()).toContain('entityType=chore')
const card = choreSection(page)
.locator('.item-card')
.filter({ hasText: `${CHORE_PREFIX}${CHORE_COUNT}` })
await expect(card).toBeVisible({ timeout: 5000 })
await page.waitForTimeout(800)
expect(await isInViewport(card)).toBe(true)
})
// 6.7 Clicking a reward notification produces the correct deep link
test('Clicking a reward notification produces correct deep link', async ({ page }) => {
await page.setViewportSize(NARROW_VIEWPORT)
await page.goto('/parent/notifications')
const item = page.locator('.list-item').filter({ hasText: REWARD_NAME })
await expect(item).toBeVisible({ timeout: 5000 })
await item.click()
await page.waitForURL(new RegExp(`/parent/${childId}\\?scrollTo=.*&entityType=reward`), {
timeout: 5000,
})
expect(page.url()).toContain(`scrollTo=${rewardId}`)
expect(page.url()).toContain('entityType=reward')
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await expect(card).toBeVisible({ timeout: 5000 })
await page.waitForTimeout(800)
expect(await isInViewport(card)).toBe(true)
})
})

View File

@@ -0,0 +1,118 @@
// spec: e2e/plans/parent-notifications.plan.md §7 — approve chore
import { test, expect, type APIRequestContext } from '@playwright/test'
const CHILD_NAME = 'DigestApproveChoreChild'
const CHORE_NAME = 'DigestApproveChoreChore'
const CHORE_POINTS = 8
async function createChild(request: APIRequestContext, name: string): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
return (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
}
async function createTask(
request: APIRequestContext,
name: string,
points: number,
): Promise<string> {
const pre = await request.get('/api/task/list')
for (const t of (await pre.json()).tasks ?? []) {
if (t.name === name) await request.delete(`/api/task/${t.id}`)
}
await request.put('/api/task/add', { data: { name, points, type: 'chore' } })
const list = await request.get('/api/task/list')
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
}
test.describe('Digest Action Token — Approve Chore', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let choreId = ''
let approveToken = ''
test.beforeAll(async ({ request, playwright }) => {
childId = await createChild(request, CHILD_NAME)
choreId = await createTask(request, CHORE_NAME, CHORE_POINTS)
await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: [choreId], type: 'chore' },
})
// Set interval schedule so chore persists post-approval
await request.put(`/api/child/${childId}/task/${choreId}/schedule`, {
data: {
mode: 'interval',
interval_days: 1,
anchor_date: new Date().toLocaleDateString('en-CA'),
interval_has_deadline: false,
},
})
await request.post(`/api/child/${childId}/confirm-chore`, {
data: { task_id: choreId },
})
// Obtain a valid approve-chore token via the E2E test helper
const tokenRes = await request.post('/api/admin/test/digest-token', {
data: {
child_id: childId,
entity_id: choreId,
entity_type: 'chore',
action: 'approve',
},
})
expect(tokenRes.status()).toBe(200)
approveToken = (await tokenRes.json()).token
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (choreId) await request.delete(`/api/task/${choreId}`)
})
// 7.1 Approve-chore token returns 302 redirect to correct ParentView URL
test('Approve-chore token returns 302 redirect to correct ParentView URL', async ({
playwright,
}) => {
const unauthCtx = await playwright.request.newContext({
ignoreHTTPSErrors: true,
baseURL: 'https://localhost:5173',
})
const res = await unauthCtx.get(`/api/digest-action/${approveToken}`, {
maxRedirects: 0,
})
expect(res.status()).toBe(302)
const location = res.headers()['location'] ?? ''
expect(location).toContain(`/parent/${childId}`)
expect(location).toContain(`scrollTo=${choreId}`)
expect(location).toContain('entityType=chore')
await unauthCtx.dispose()
})
// 7.2 Approve-chore action is performed: pending confirmation removed, points awarded
test('Approve-chore action is performed: points awarded and confirmation removed', async ({
request,
}) => {
// Wait for action to complete
await new Promise((r) => setTimeout(r, 300))
const childRes = await request.get(`/api/child/list`)
const children = (await childRes.json()).children ?? []
const child = children.find((c: any) => c.id === childId)
expect(child).toBeTruthy()
expect(child.points).toBeGreaterThanOrEqual(CHORE_POINTS)
const pendingRes = await request.get('/api/pending-confirmations')
const confirmations = (await pendingRes.json()).confirmations ?? []
const pending = confirmations.filter(
(c: any) => c.child_id === childId && c.entity_id === choreId,
)
// Should be no pending (status == 'pending') confirmation
expect(pending.filter((c: any) => c.status === 'pending')).toHaveLength(0)
})
})

View File

@@ -0,0 +1,111 @@
// spec: e2e/plans/parent-notifications.plan.md §7 — approve reward
import { test, expect, type APIRequestContext } from '@playwright/test'
const CHILD_NAME = 'DigestApproveRewardChild'
const REWARD_NAME = 'DigestApproveRewardReward'
const REWARD_COST = 15
const INITIAL_POINTS = 50
async function createChild(
request: APIRequestContext,
name: string,
points: number,
): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
const id = (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
if (points > 0) await request.put(`/api/child/${id}/edit`, { data: { points } })
return id
}
async function createReward(
request: APIRequestContext,
name: string,
cost: number,
): Promise<string> {
const pre = await request.get('/api/reward/list')
for (const r of (await pre.json()).rewards ?? []) {
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
}
await request.put('/api/reward/add', { data: { name, description: 'digest approve test', cost } })
const list = await request.get('/api/reward/list')
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
}
test.describe('Digest Action Token — Approve Reward', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let rewardId = ''
let approveToken = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME, INITIAL_POINTS)
rewardId = await createReward(request, REWARD_NAME, REWARD_COST)
await request.put(`/api/child/${childId}/set-rewards`, {
data: { reward_ids: [rewardId] },
})
await request.post(`/api/child/${childId}/request-reward`, {
data: { reward_id: rewardId },
})
const tokenRes = await request.post('/api/admin/test/digest-token', {
data: {
child_id: childId,
entity_id: rewardId,
entity_type: 'reward',
action: 'approve',
},
})
expect(tokenRes.status()).toBe(200)
approveToken = (await tokenRes.json()).token
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (rewardId) await request.delete(`/api/reward/${rewardId}`)
})
// 7.5 Approve-reward token returns 302 redirect to correct ParentView URL
test('Approve-reward token returns 302 redirect to correct ParentView URL', async ({
playwright,
}) => {
const unauthCtx = await playwright.request.newContext({
ignoreHTTPSErrors: true,
baseURL: 'https://localhost:5173',
})
const res = await unauthCtx.get(`/api/digest-action/${approveToken}`, {
maxRedirects: 0,
})
expect(res.status()).toBe(302)
const location = res.headers()['location'] ?? ''
expect(location).toContain(`/parent/${childId}`)
expect(location).toContain(`scrollTo=${rewardId}`)
expect(location).toContain('entityType=reward')
await unauthCtx.dispose()
})
// 7.6 Approve-reward action: reward triggered, child points deducted
test('Approve-reward action: reward triggered and child points deducted', async ({ request }) => {
await new Promise((r) => setTimeout(r, 300))
const childRes = await request.get('/api/child/list')
const children = (await childRes.json()).children ?? []
const child = children.find((c: any) => c.id === childId)
expect(child).toBeTruthy()
expect(child.points).toBe(INITIAL_POINTS - REWARD_COST)
const pendingRes = await request.get('/api/pending-confirmations')
const confirmations = (await pendingRes.json()).confirmations ?? []
const pending = confirmations.filter(
(c: any) => c.child_id === childId && c.entity_id === rewardId && c.status === 'pending',
)
expect(pending).toHaveLength(0)
})
})

View File

@@ -0,0 +1,106 @@
// spec: e2e/plans/parent-notifications.plan.md §7 — deny chore
import { test, expect, type APIRequestContext } from '@playwright/test'
const CHILD_NAME = 'DigestDenyChoreChild'
const CHORE_NAME = 'DigestDenyChoreChore'
const CHORE_POINTS = 8
async function createChild(request: APIRequestContext, name: string): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
return (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
}
async function createTask(
request: APIRequestContext,
name: string,
points: number,
): Promise<string> {
const pre = await request.get('/api/task/list')
for (const t of (await pre.json()).tasks ?? []) {
if (t.name === name) await request.delete(`/api/task/${t.id}`)
}
await request.put('/api/task/add', { data: { name, points, type: 'chore' } })
const list = await request.get('/api/task/list')
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
}
test.describe('Digest Action Token — Deny Chore', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let choreId = ''
let denyToken = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME)
choreId = await createTask(request, CHORE_NAME, CHORE_POINTS)
await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: [choreId], type: 'chore' },
})
await request.post(`/api/child/${childId}/confirm-chore`, {
data: { task_id: choreId },
})
const tokenRes = await request.post('/api/admin/test/digest-token', {
data: {
child_id: childId,
entity_id: choreId,
entity_type: 'chore',
action: 'deny',
},
})
expect(tokenRes.status()).toBe(200)
denyToken = (await tokenRes.json()).token
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (choreId) await request.delete(`/api/task/${choreId}`)
})
// 7.3 Deny-chore token returns 302 redirect to correct ParentView URL
test('Deny-chore token returns 302 redirect to correct ParentView URL', async ({
playwright,
}) => {
const unauthCtx = await playwright.request.newContext({
ignoreHTTPSErrors: true,
baseURL: 'https://localhost:5173',
})
const res = await unauthCtx.get(`/api/digest-action/${denyToken}`, {
maxRedirects: 0,
})
expect(res.status()).toBe(302)
const location = res.headers()['location'] ?? ''
expect(location).toContain(`/parent/${childId}`)
expect(location).toContain('entityType=chore')
await unauthCtx.dispose()
})
// 7.4 Deny-chore action is performed: pending confirmation removed, no points awarded
test('Deny-chore action is performed: confirmation removed, points unchanged', async ({
request,
}) => {
await new Promise((r) => setTimeout(r, 300))
const childRes = await request.get(`/api/child/list`)
const children = (await childRes.json()).children ?? []
const child = children.find((c: any) => c.id === childId)
expect(child).toBeTruthy()
// No points awarded for rejection
expect(child.points).toBe(0)
const pendingRes = await request.get('/api/pending-confirmations')
const confirmations = (await pendingRes.json()).confirmations ?? []
const pending = confirmations.filter(
(c: any) => c.child_id === childId && c.entity_id === choreId && c.status === 'pending',
)
expect(pending).toHaveLength(0)
})
})

View File

@@ -0,0 +1,111 @@
// spec: e2e/plans/parent-notifications.plan.md §7 — deny reward
import { test, expect, type APIRequestContext } from '@playwright/test'
const CHILD_NAME = 'DigestDenyRewardChild'
const REWARD_NAME = 'DigestDenyRewardReward'
const REWARD_COST = 15
const INITIAL_POINTS = 50
async function createChild(
request: APIRequestContext,
name: string,
points: number,
): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
const id = (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
if (points > 0) await request.put(`/api/child/${id}/edit`, { data: { points } })
return id
}
async function createReward(
request: APIRequestContext,
name: string,
cost: number,
): Promise<string> {
const pre = await request.get('/api/reward/list')
for (const r of (await pre.json()).rewards ?? []) {
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
}
await request.put('/api/reward/add', { data: { name, description: 'digest deny test', cost } })
const list = await request.get('/api/reward/list')
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
}
test.describe('Digest Action Token — Deny Reward', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let rewardId = ''
let denyToken = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME, INITIAL_POINTS)
rewardId = await createReward(request, REWARD_NAME, REWARD_COST)
await request.put(`/api/child/${childId}/set-rewards`, {
data: { reward_ids: [rewardId] },
})
await request.post(`/api/child/${childId}/request-reward`, {
data: { reward_id: rewardId },
})
const tokenRes = await request.post('/api/admin/test/digest-token', {
data: {
child_id: childId,
entity_id: rewardId,
entity_type: 'reward',
action: 'deny',
},
})
expect(tokenRes.status()).toBe(200)
denyToken = (await tokenRes.json()).token
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (rewardId) await request.delete(`/api/reward/${rewardId}`)
})
// 7.7 Deny-reward token returns 302 redirect to correct ParentView URL
test('Deny-reward token returns 302 redirect to correct ParentView URL', async ({
playwright,
}) => {
const unauthCtx = await playwright.request.newContext({
ignoreHTTPSErrors: true,
baseURL: 'https://localhost:5173',
})
const res = await unauthCtx.get(`/api/digest-action/${denyToken}`, {
maxRedirects: 0,
})
expect(res.status()).toBe(302)
const location = res.headers()['location'] ?? ''
expect(location).toContain(`/parent/${childId}`)
expect(location).toContain('entityType=reward')
await unauthCtx.dispose()
})
// 7.8 Deny-reward action: pending request removed, points unchanged
test('Deny-reward action: pending request removed and points unchanged', async ({ request }) => {
await new Promise((r) => setTimeout(r, 300))
const childRes = await request.get('/api/child/list')
const children = (await childRes.json()).children ?? []
const child = children.find((c: any) => c.id === childId)
expect(child).toBeTruthy()
// Points should be unchanged (deny doesn't deduct anything)
expect(child.points).toBe(INITIAL_POINTS)
const pendingRes = await request.get('/api/pending-confirmations')
const confirmations = (await pendingRes.json()).confirmations ?? []
const pending = confirmations.filter(
(c: any) => c.child_id === childId && c.entity_id === rewardId && c.status === 'pending',
)
expect(pending).toHaveLength(0)
})
})

View File

@@ -0,0 +1,167 @@
// spec: e2e/plans/parent-notifications.plan.md §8
import { test, expect, type APIRequestContext } from '@playwright/test'
const CHILD_NAME = 'DigestErrorChild'
const CHORE_NAME = 'DigestErrorChore'
const CHORE_POINTS = 5
async function createChild(request: APIRequestContext, name: string): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
return (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
}
async function createTask(
request: APIRequestContext,
name: string,
points: number,
): Promise<string> {
const pre = await request.get('/api/task/list')
for (const t of (await pre.json()).tasks ?? []) {
if (t.name === name) await request.delete(`/api/task/${t.id}`)
}
await request.put('/api/task/add', { data: { name, points, type: 'chore' } })
const list = await request.get('/api/task/list')
return (await list.json()).tasks?.find((t: any) => t.name === name)?.id ?? ''
}
async function getUnauthContext(playwright: any) {
return playwright.request.newContext({
ignoreHTTPSErrors: true,
baseURL: 'https://localhost:5173',
})
}
test.describe('Digest Action Token — Error Paths', () => {
let childId = ''
let choreId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME)
choreId = await createTask(request, CHORE_NAME, CHORE_POINTS)
await request.put(`/api/child/${childId}/set-tasks`, {
data: { task_ids: [choreId], type: 'chore' },
})
await request.post(`/api/child/${childId}/confirm-chore`, {
data: { task_id: choreId },
})
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (choreId) await request.delete(`/api/task/${choreId}`)
})
// 8.1 Expired token returns 400
test('Expired token returns 400', async ({ request, playwright }) => {
const tokenRes = await request.post('/api/admin/test/digest-token', {
data: {
child_id: childId,
entity_id: choreId,
entity_type: 'chore',
action: 'approve',
expires_in_hours: -1,
},
})
expect(tokenRes.status()).toBe(200)
const expiredToken = (await tokenRes.json()).token
const unauthCtx = await getUnauthContext(playwright)
const res = await unauthCtx.get(`/api/digest-action/${expiredToken}`, { maxRedirects: 0 })
expect(res.status()).toBe(400)
expect(res.headers()['location']).toBeUndefined()
await unauthCtx.dispose()
})
// 8.2 Already-used token returns 400 on second use
test('Already-used token returns 400 on second use', async ({ request, playwright }) => {
const tokenRes = await request.post('/api/admin/test/digest-token', {
data: {
child_id: childId,
entity_id: choreId,
entity_type: 'chore',
action: 'deny',
},
})
expect(tokenRes.status()).toBe(200)
const token = (await tokenRes.json()).token
const unauthCtx = await getUnauthContext(playwright)
// First use — should succeed (302)
const first = await unauthCtx.get(`/api/digest-action/${token}`, { maxRedirects: 0 })
expect(first.status()).toBe(302)
// Second use — token is consumed
const second = await unauthCtx.get(`/api/digest-action/${token}`, { maxRedirects: 0 })
expect(second.status()).toBe(400)
await unauthCtx.dispose()
// Re-create a pending chore confirmation for subsequent tests
await request.post(`/api/child/${childId}/confirm-chore`, {
data: { task_id: choreId },
})
})
// 8.3 Tampered token returns 400
test('Tampered token returns 400', async ({ request, playwright }) => {
const tokenRes = await request.post('/api/admin/test/digest-token', {
data: {
child_id: childId,
entity_id: choreId,
entity_type: 'chore',
action: 'approve',
},
})
expect(tokenRes.status()).toBe(200)
const validToken: string = (await tokenRes.json()).token
// Tamper: replace the last character
const lastChar = validToken[validToken.length - 1]
const replacement = lastChar === 'a' ? 'b' : 'a'
const tamperedToken = validToken.slice(0, -1) + replacement
const unauthCtx = await getUnauthContext(playwright)
const res = await unauthCtx.get(`/api/digest-action/${tamperedToken}`, { maxRedirects: 0 })
expect(res.status()).toBe(400)
await unauthCtx.dispose()
})
// 8.4 Completely fake token returns 400
test('Completely fake token returns 400', async ({ playwright }) => {
const unauthCtx = await getUnauthContext(playwright)
const res = await unauthCtx.get('/api/digest-action/this-token-does-not-exist', {
maxRedirects: 0,
})
expect(res.status()).toBe(400)
await unauthCtx.dispose()
})
// 8.5 Error response is a plain HTML page with no redirect
test('Error response is plain HTML with no Location header', async ({ request, playwright }) => {
const tokenRes = await request.post('/api/admin/test/digest-token', {
data: {
child_id: childId,
entity_id: choreId,
entity_type: 'chore',
action: 'approve',
expires_in_hours: -1,
},
})
const expiredToken = (await tokenRes.json()).token
const unauthCtx = await getUnauthContext(playwright)
const res = await unauthCtx.get(`/api/digest-action/${expiredToken}`, { maxRedirects: 0 })
expect(res.status()).toBe(400)
const contentType = res.headers()['content-type'] ?? ''
expect(contentType).toContain('text/html')
expect(res.headers()['location']).toBeUndefined()
await unauthCtx.dispose()
})
})

View File

@@ -0,0 +1,86 @@
// spec: e2e/plans/parent-notifications.plan.md §1
import { test, expect, type Page } from '@playwright/test'
function pushToggle(page: Page) {
return page
.locator('.toggle-field-row')
.filter({ has: page.locator('.toggle-field-label', { hasText: 'Push Notifications' }) })
.locator('button.toggle-btn')
}
test.describe('Push subscription registration', () => {
// -------------------------------------------------------------------------
// 1.3 No subscription POST when notification permission not granted
// -------------------------------------------------------------------------
test('No subscription POST is sent when notification permission is not granted', async ({
page,
}) => {
// Do NOT grant notifications permission
let postCaptured = false
await page.route('**/api/push-subscription', async (route) => {
if (route.request().method() === 'POST') {
postCaptured = true
}
await route.continue()
})
await page.goto('/parent/profile')
const toggle = pushToggle(page)
await expect(toggle).toBeVisible({ timeout: 5000 })
const isDisabled = await toggle.isDisabled().catch(() => false)
if (!isDisabled) {
// Try to click the toggle — it may prompt for permission but deny it
await toggle.click().catch(() => {})
await page
.getByRole('button', { name: 'Save' })
.click()
.catch(() => {})
await page.waitForTimeout(1000)
}
expect(postCaptured).toBe(false)
})
// -------------------------------------------------------------------------
// 1.4 Backend rejects unauthenticated subscription POST (401)
// -------------------------------------------------------------------------
test('Backend rejects unauthenticated subscription POST with 401', async ({ page }) => {
// Navigate first, then use credentials:'omit' to strip cookies and verify
// the backend enforces auth.
await page.goto('/parent/profile')
const status = await page.evaluate(async () => {
const res = await fetch('/api/push-subscription', {
method: 'POST',
credentials: 'omit',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpoint: 'https://fcm.googleapis.com/fcm/send/fake-endpoint',
keys: { p256dh: 'fake-key', auth: 'fake-auth' },
}),
})
return res.status
})
expect(status).toBe(401)
})
// -------------------------------------------------------------------------
// 1.5 Backend rejects unauthenticated subscription DELETE (401)
// -------------------------------------------------------------------------
test('Backend rejects unauthenticated subscription DELETE with 401', async ({ page }) => {
await page.goto('/parent/profile')
const status = await page.evaluate(async () => {
const res = await fetch('/api/push-subscription', {
method: 'DELETE',
credentials: 'omit',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpoint: 'https://fcm.googleapis.com/fcm/send/fake-endpoint',
}),
})
return res.status
})
expect(status).toBe(401)
})
})

View File

@@ -0,0 +1,153 @@
// spec: e2e/plans/parent-notifications.plan.md §4
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
const CHILD_NAME = 'RewardNotifApproveChild'
const REWARD_NAME = 'RewardNotifApproveReward'
const REWARD_COST = 10
const INITIAL_POINTS = 30
async function createChild(
request: APIRequestContext,
name: string,
points: number,
): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
const id = (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
if (points > 0) await request.put(`/api/child/${id}/edit`, { data: { points } })
return id
}
async function createReward(
request: APIRequestContext,
name: string,
cost: number,
): Promise<string> {
const pre = await request.get('/api/reward/list')
for (const r of (await pre.json()).rewards ?? []) {
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
}
await request.put('/api/reward/add', { data: { name, description: 'notif e2e test', cost } })
const list = await request.get('/api/reward/list')
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
}
async function getPoints(page: Page): Promise<number> {
const text = await page.locator('.points .value').textContent()
return parseInt(text?.trim() ?? '0', 10)
}
function rewardSection(page: Page) {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Rewards' }),
})
}
test.describe('Reward Notification — Appearance and In-App Grant', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let rewardId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME, INITIAL_POINTS)
rewardId = await createReward(request, REWARD_NAME, REWARD_COST)
await request.put(`/api/child/${childId}/set-rewards`, {
data: { reward_ids: [rewardId] },
})
await request.post(`/api/child/${childId}/request-reward`, {
data: { reward_id: rewardId },
})
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (rewardId) await request.delete(`/api/reward/${rewardId}`)
})
// 4.1 Requested reward appears in the notification view
test('Requested reward appears in notification view', async ({ page }) => {
await page.goto('/parent/notifications')
const item = page.locator('.list-item').filter({ hasText: REWARD_NAME })
await expect(item).toBeVisible({ timeout: 5000 })
await expect(item).toContainText('requested')
})
// 4.2 Notification item displays the child's name
test('Notification item displays the child name', async ({ page }) => {
await page.goto('/parent/notifications')
const item = page.locator('.list-item').filter({ hasText: REWARD_NAME })
await expect(item).toBeVisible({ timeout: 5000 })
await expect(item).toContainText(CHILD_NAME)
})
// 4.3 Clicking reward notification navigates to ParentView with correct query params
test('Clicking reward notification navigates to ParentView with correct query params', async ({
page,
}) => {
await page.goto('/parent/notifications')
const item = page.locator('.list-item').filter({ hasText: REWARD_NAME })
await expect(item).toBeVisible({ timeout: 5000 })
await item.click()
await page.waitForURL(new RegExp(`/parent/${childId}\\?scrollTo=.*&entityType=reward`), {
timeout: 5000,
})
expect(page.url()).toContain(`scrollTo=${rewardId}`)
expect(page.url()).toContain('entityType=reward')
})
// 4.4 Pending reward card shows PENDING badge in ParentView
test('Pending reward card shows PENDING badge in ParentView', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await expect(card).toBeVisible({ timeout: 5000 })
await expect(card).toContainText('PENDING')
})
// 4.5 Clicking a pending reward card opens the grant confirmation dialog
test('Clicking a pending reward card opens grant confirmation dialog', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await expect(card).toBeVisible({ timeout: 5000 })
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.click()
// A confirm dialog should appear — look for a confirmation button
const confirmBtn = page.getByRole('button', { name: /yes|confirm|grant/i })
await expect(confirmBtn).toBeVisible({ timeout: 5000 })
})
// 4.6 Confirming the grant removes PENDING badge and updates child points
test('Confirming grant removes PENDING badge and updates child points', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const before = await getPoints(page)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await expect(card).toBeVisible({ timeout: 5000 })
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.click()
const confirmBtn = page.getByRole('button', { name: /yes|confirm|grant/i })
await expect(confirmBtn).toBeVisible({ timeout: 5000 })
await confirmBtn.click()
await expect(confirmBtn).not.toBeVisible({ timeout: 5000 })
await expect(card).not.toContainText('PENDING', { timeout: 5000 })
await expect(page.locator('.points .value')).toHaveText(String(before - REWARD_COST), {
timeout: 5000,
})
})
// 4.7 Granting a reward clears it from the notification view
test('Granting a reward clears it from notification view', async ({ page }) => {
await page.goto('/parent/notifications')
const item = page.locator('.list-item').filter({ hasText: REWARD_NAME })
await expect(item).not.toBeVisible({ timeout: 5000 })
})
})

View File

@@ -0,0 +1,164 @@
// spec: e2e/plans/parent-notifications.plan.md §5
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
const CHILD_NAME = 'RewardNotifDenyChild'
const REWARD_NAME = 'RewardNotifDenyReward'
const REWARD_COST = 10
const INITIAL_POINTS = 30
async function createChild(
request: APIRequestContext,
name: string,
points: number,
): Promise<string> {
const pre = await request.get('/api/child/list')
for (const c of (await pre.json()).children ?? []) {
if (c.name === name) await request.delete(`/api/child/${c.id}`)
}
await request.put('/api/child/add', { data: { name, age: 8 } })
const list = await request.get('/api/child/list')
const id = (await list.json()).children?.find((c: any) => c.name === name)?.id ?? ''
if (points > 0) await request.put(`/api/child/${id}/edit`, { data: { points } })
return id
}
async function createReward(
request: APIRequestContext,
name: string,
cost: number,
): Promise<string> {
const pre = await request.get('/api/reward/list')
for (const r of (await pre.json()).rewards ?? []) {
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
}
await request.put('/api/reward/add', { data: { name, description: 'notif e2e deny test', cost } })
const list = await request.get('/api/reward/list')
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
}
async function getPoints(page: Page): Promise<number> {
const text = await page.locator('.points .value').textContent()
return parseInt(text?.trim() ?? '0', 10)
}
function rewardSection(page: Page) {
return page.locator('.child-list-container').filter({
has: page.locator('h3', { hasText: 'Rewards' }),
})
}
test.describe('Reward Notification — In-App Denial', () => {
test.describe.configure({ mode: 'serial' })
let childId = ''
let rewardId = ''
test.beforeAll(async ({ request }) => {
childId = await createChild(request, CHILD_NAME, INITIAL_POINTS)
rewardId = await createReward(request, REWARD_NAME, REWARD_COST)
await request.put(`/api/child/${childId}/set-rewards`, {
data: { reward_ids: [rewardId] },
})
})
test.afterAll(async ({ request }) => {
if (childId) await request.delete(`/api/child/${childId}`)
if (rewardId) await request.delete(`/api/reward/${rewardId}`)
})
test.beforeEach(async ({ request }) => {
// Create a fresh pending request before each test
await request.post(`/api/child/${childId}/request-reward`, {
data: { reward_id: rewardId },
})
})
test.afterEach(async ({ request }) => {
// Clean up any unconsumed pending requests
await request.post(`/api/child/${childId}/cancel-request-reward`, {
data: { reward_id: rewardId },
})
})
// 5.1 Requested reward appears in the notification view
test('Requested reward appears in notification view', async ({ page }) => {
await page.goto('/parent/notifications')
const item = page.locator('.list-item').filter({ hasText: REWARD_NAME })
await expect(item).toBeVisible({ timeout: 5000 })
})
// 5.2 Pending reward card shows PENDING badge in ParentView
test('Pending reward card shows PENDING badge in ParentView', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await expect(card).toBeVisible({ timeout: 5000 })
await expect(card).toContainText('PENDING')
})
// 5.3 Denying a reward request removes the PENDING badge
test('Denying a reward request removes PENDING badge', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const before = await getPoints(page)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await expect(card).toBeVisible({ timeout: 5000 })
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.click()
// Look for a Deny button in the dialog
const denyBtn = page.getByRole('button', { name: /deny/i })
await expect(denyBtn).toBeVisible({ timeout: 5000 })
await denyBtn.click()
await expect(denyBtn).not.toBeVisible({ timeout: 5000 })
await expect(card).not.toContainText('PENDING', { timeout: 5000 })
// Points should be unchanged
await expect(page.locator('.points .value')).toHaveText(String(before), { timeout: 5000 })
})
// 5.4 Denying a reward request clears it from the notification view
test('Denying a reward request clears it from notification view', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await expect(card).toBeVisible({ timeout: 5000 })
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.click()
const denyBtn = page.getByRole('button', { name: /deny/i })
await expect(denyBtn).toBeVisible({ timeout: 5000 })
await denyBtn.click()
await expect(denyBtn).not.toBeVisible({ timeout: 5000 })
await page.goto('/parent/notifications')
const item = page.locator('.list-item').filter({ hasText: REWARD_NAME })
await expect(item).not.toBeVisible({ timeout: 5000 })
})
// 5.5 POST deny-reward-request returns 200 when pending request exists (API layer)
test('POST deny-reward-request returns 200 when pending request exists', async ({ request }) => {
const res = await request.post(`/api/child/${childId}/deny-reward-request`, {
data: { reward_id: rewardId },
})
expect(res.status()).toBe(200)
})
// 5.6 POST deny-reward-request returns 200 with graceful message when no pending request
test('POST deny-reward-request returns 200 gracefully when no pending request', async ({
request,
}) => {
// Cancel existing request first (afterEach will also try but that's fine)
await request.post(`/api/child/${childId}/cancel-request-reward`, {
data: { reward_id: rewardId },
})
const res = await request.post(`/api/child/${childId}/deny-reward-request`, {
data: { reward_id: rewardId },
})
// Backend returns 200 with a graceful "already resolved" message per the spec
expect(res.status()).toBe(200)
const body = await res.json()
expect(body.message).toContain('already been resolved')
})
})

View File

@@ -0,0 +1,149 @@
// spec: e2e/plans/parent-notifications.plan.md §10
import { test, expect, type Page } from '@playwright/test'
function digestToggle(page: Page) {
return page
.locator('.toggle-field-row')
.filter({ has: page.locator('.toggle-field-label', { hasText: 'Daily Digest' }) })
.locator('button.toggle-btn')
}
function pushToggle(page: Page) {
return page
.locator('.toggle-field-row')
.filter({ has: page.locator('.toggle-field-label', { hasText: 'Push Notifications' }) })
.locator('button.toggle-btn')
}
test.describe('User Profile Notification Settings', () => {
test.describe.configure({ mode: 'serial' })
// ---------------------------------------------------------------------------
// 10.1 Email Digest toggle is visible on User Profile page
// ---------------------------------------------------------------------------
test('Email Digest toggle is visible on User Profile page', async ({ page }) => {
await page.goto('/parent/profile')
await expect(digestToggle(page)).toBeVisible({ timeout: 5000 })
})
// ---------------------------------------------------------------------------
// 10.2 Email Digest toggle initialises from the server value
// ---------------------------------------------------------------------------
test('Email Digest toggle initialises from the server value', async ({ page, request }) => {
// Set known state: enabled
await request.put('/api/user/profile', { data: { email_digest_enabled: true } })
await page.goto('/parent/profile')
await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'true', { timeout: 5000 })
// Set known state: disabled
await request.put('/api/user/profile', { data: { email_digest_enabled: false } })
await page.reload()
await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'false', { timeout: 5000 })
// Restore default
await request.put('/api/user/profile', { data: { email_digest_enabled: true } })
})
// ---------------------------------------------------------------------------
// 10.3 Toggling Email Digest off sends email_digest_enabled: false to API
// ---------------------------------------------------------------------------
test('Toggling Email Digest off sends correct API payload', async ({ page, request }) => {
await request.put('/api/user/profile', { data: { email_digest_enabled: true } })
await page.goto('/parent/profile')
// Wait for profile to load and toggle to reflect server state
await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'true', { timeout: 5000 })
let capturedBody: Record<string, unknown> | null = null
await page.route('**/api/user/profile', async (route) => {
if (route.request().method() === 'PUT') {
capturedBody = JSON.parse(route.request().postData() ?? '{}')
}
await route.continue()
})
await digestToggle(page).click()
// Submit the form (Save button)
await page.getByRole('button', { name: 'Save' }).click()
await page.waitForTimeout(500)
expect(capturedBody).not.toBeNull()
expect((capturedBody as Record<string, unknown>)['email_digest_enabled']).toBe(false)
await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'false', { timeout: 3000 })
// Restore
await request.put('/api/user/profile', { data: { email_digest_enabled: true } })
})
// ---------------------------------------------------------------------------
// 10.4 Toggling Email Digest on sends email_digest_enabled: true to API
// ---------------------------------------------------------------------------
test('Toggling Email Digest on sends correct API payload', async ({ page, request }) => {
await request.put('/api/user/profile', { data: { email_digest_enabled: false } })
await page.goto('/parent/profile')
// Wait for profile to load and toggle to reflect server state
await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'false', { timeout: 5000 })
let capturedBody: Record<string, unknown> | null = null
await page.route('**/api/user/profile', async (route) => {
if (route.request().method() === 'PUT') {
capturedBody = JSON.parse(route.request().postData() ?? '{}')
}
await route.continue()
})
await digestToggle(page).click()
await page.getByRole('button', { name: 'Save' }).click()
await page.waitForTimeout(500)
expect(capturedBody).not.toBeNull()
expect((capturedBody as Record<string, unknown>)['email_digest_enabled']).toBe(true)
await expect(digestToggle(page)).toHaveAttribute('aria-pressed', 'true', { timeout: 3000 })
// Restore
await request.put('/api/user/profile', { data: { email_digest_enabled: true } })
})
// ---------------------------------------------------------------------------
// 10.5 Push Notifications toggle is visible on User Profile page
// ---------------------------------------------------------------------------
test('Push Notifications toggle is visible on User Profile page', async ({ page }) => {
await page.goto('/parent/profile')
await expect(pushToggle(page)).toBeVisible({ timeout: 5000 })
})
// ---------------------------------------------------------------------------
// 10.6 Push Notifications toggle initialises to off when no subscription exists
// ---------------------------------------------------------------------------
test('Push Notifications toggle initialises to off when no subscription exists', async ({
page,
}) => {
// Do NOT grant notifications permission — toggle should start unchecked
await page.goto('/parent/profile')
const toggle = pushToggle(page)
await expect(toggle).toBeVisible({ timeout: 5000 })
// Either aria-pressed="false" or disabled (when browser denies permissions)
const ariaPressed = await toggle.getAttribute('aria-pressed')
expect(ariaPressed).toBe('false')
})
// ---------------------------------------------------------------------------
// 10.8 Push Notifications toggle is disabled when browser permission is denied
// ---------------------------------------------------------------------------
test('Push Notifications toggle is disabled when browser permission is denied', async ({
page,
context,
}) => {
// Do not grant permission (leave as denied/prompt)
await context.clearPermissions()
await page.goto('/parent/profile')
const toggle = pushToggle(page)
await expect(toggle).toBeVisible({ timeout: 5000 })
// When permission is denied, the toggle should be disabled or aria-pressed="false"
const isDisabled = await toggle.isDisabled().catch(() => false)
const ariaPressed = await toggle.getAttribute('aria-pressed')
// Either disabled or not pressed — permission denied prevents enabling it
expect(isDisabled || ariaPressed === 'false').toBe(true)
})
})

View File

@@ -37,7 +37,7 @@ Tests live in `e2e/mode_parent/notifications/`. Each spec creates its own isolat
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. 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()`. **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`: **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`:
@@ -62,22 +62,6 @@ This endpoint creates a valid `DigestActionToken` (with correct HMAC-SHA256 sign
> **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. > **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 #### 1.3. No subscription POST is sent when notification permission is not granted
- Do NOT grant notification permission (leave default) - Do NOT grant notification permission (leave default)
@@ -588,44 +572,18 @@ This allows a spec to simulate notification action clicks without OS interaction
- Navigate to `/parent/profile` - Navigate to `/parent/profile`
- expect: The Push Notifications toggle is unchecked/off - 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 #### 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) - 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) - 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 ## Test Summary
| # | Group | Spec File(s) | Test Cases | | # | Group | Spec File(s) | Test Cases |
| --- | --------------------------------- | -------------------------------------------- | ---------- | | --- | --------------------------------- | -------------------------------------------- | ---------- |
| 1 | Push subscription registration | `push-subscription.spec.ts` | 5 | | 1 | Push subscription registration | `push-subscription.spec.ts` | 3 |
| 2 | Chore notification — approve flow | `chore-notification-approve.spec.ts` | 8 | | 2 | Chore notification — approve flow | `chore-notification-approve.spec.ts` | 8 |
| 3 | Chore notification — reject flow | `chore-notification-deny.spec.ts` | 5 | | 3 | Chore notification — reject flow | `chore-notification-deny.spec.ts` | 5 |
| 4 | Reward notification — grant flow | `reward-notification-approve.spec.ts` | 7 | | 4 | Reward notification — grant flow | `reward-notification-approve.spec.ts` | 7 |
@@ -634,11 +592,11 @@ This allows a spec to simulate notification action clicks without OS interaction
| 7 | Digest token — happy paths | 4 spec files (one per `entity×action`) | 8 | | 7 | Digest token — happy paths | 4 spec files (one per `entity×action`) | 8 |
| 8 | Digest token — error paths | `digest-action-errors.spec.ts` | 5 | | 8 | Digest token — error paths | `digest-action-errors.spec.ts` | 5 |
| 9 | SW push — manual/mock | (no spec file) | checklist | | 9 | SW push — manual/mock | (no spec file) | checklist |
| 10 | User Profile notification toggles | `user-profile-notification-settings.spec.ts` | 10 | | 10 | User Profile notification toggles | `user-profile-notification-settings.spec.ts` | 7 |
## Implementation Notes ## 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. - 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 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. - 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).

View File

@@ -116,6 +116,16 @@ export default defineConfig({
testMatch: [/mode_parent\/chore-scheduler\/.+\.spec\.ts/], testMatch: [/mode_parent\/chore-scheduler\/.+\.spec\.ts/],
}, },
{
// Bucket: parent notifications tests — push subscription, chore/reward
// notification approval/denial, deep-link navigation, digest tokens, and
// user profile notification settings. Each spec creates its own isolated child.
name: 'chromium-parent-notifications',
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE },
dependencies: ['setup'],
testMatch: [/mode_parent\/notifications\/.+\.spec\.ts/],
},
{ {
// Bucket: parent profile button tests (permanent parent mode). // Bucket: parent profile button tests (permanent parent mode).
name: 'chromium-profile-button', name: 'chromium-profile-button',
@@ -163,6 +173,7 @@ export default defineConfig({
/mode_parent\/profile-button\//, /mode_parent\/profile-button\//,
/mode_parent\/user-profile\//, /mode_parent\/user-profile\//,
/mode_parent\/chore-scheduler\//, /mode_parent\/chore-scheduler\//,
/mode_parent\/notifications\//,
], ],
}, },
@@ -232,6 +243,10 @@ export default defineConfig({
DATA_ENV: 'e2e', DATA_ENV: 'e2e',
SECRET_KEY: 'dev-secret-key-change-in-production', SECRET_KEY: 'dev-secret-key-change-in-production',
REFRESH_TOKEN_EXPIRY_DAYS: '90', REFRESH_TOKEN_EXPIRY_DAYS: '90',
DIGEST_TOKEN_SECRET: 'dev-digest-token',
VAPID_PUBLIC_KEY:
'BNKkHdq45uLigohSG7c1TwlAo7ETncoRVLQK02LxHgu2P1DgSJD9njRMfbbzUsaTQGllvLBz7An1WiWsNYQhvKE',
VAPID_PRIVATE_KEY: 'jNiZJT0UO4H861KmnCt874Fg6p5jDAyYKS4V2MZf8bQ',
PROCESS_PLATFORM: process.platform, PROCESS_PLATFORM: process.platform,
}, },
}, },

View File

@@ -825,6 +825,22 @@ const confirmTriggerReward = async () => {
} }
} }
const denyRewardRequest = async () => {
if (!child.value?.id || !selectedReward.value) return
try {
await fetch(`/api/child/${child.value.id}/deny-reward-request`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reward_id: selectedReward.value.id }),
})
} catch (err) {
console.error('Failed to deny reward request:', err)
} finally {
showRewardConfirm.value = false
selectedReward.value = null
}
}
function goToAssignTasks() { function goToAssignTasks() {
if (child.value?.id) { if (child.value?.id) {
router.push({ router.push({
@@ -1168,6 +1184,7 @@ function goToAssignRewards() {
:reward="selectedReward" :reward="selectedReward"
:childName="child?.name" :childName="child?.name"
@confirm="confirmTriggerReward" @confirm="confirmTriggerReward"
@deny="denyRewardRequest"
@cancel=" @cancel="
() => { () => {
showRewardConfirm = false showRewardConfirm = false

View File

@@ -12,6 +12,7 @@
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button @click="$emit('confirm')" class="btn btn-primary">Yes</button> <button @click="$emit('confirm')" class="btn btn-primary">Yes</button>
<button v-if="reward?.redeeming" @click="$emit('deny')" class="btn btn-danger">Deny</button>
<button @click="$emit('cancel')" class="btn btn-secondary">Cancel</button> <button @click="$emit('cancel')" class="btn btn-secondary">Cancel</button>
</div> </div>
</ModalDialog> </ModalDialog>
@@ -28,6 +29,7 @@ defineProps<{
defineEmits<{ defineEmits<{
confirm: [] confirm: []
deny: []
cancel: [] cancel: []
}>() }>()
</script> </script>