feat: Implement user profile and parent profile button tests
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m38s
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m38s
- Added tests for user profile editing, including name changes, image uploads, and password changes. - Implemented tests for changing the parent PIN with verification code handling. - Created tests for account deletion with confirmation dialog and email validation. - Introduced parent profile button tests for both temporary and permanent modes, verifying badge visibility and menu options. - Updated Playwright configuration to include new test buckets for user profile and parent profile button scenarios. - Added e2e plans documentation for user profile and parent profile button tests.
This commit is contained in:
21
.vscode/tasks.json
vendored
21
.vscode/tasks.json
vendored
@@ -61,6 +61,27 @@
|
||||
"command": "cmd /c \"cd /d \\\"D:\\Python Utilities\\Reward\\frontend\\vue-app\\\" && npx playwright test e2e/mode_parent/task-modification --project=chromium-task-modification 2>&1\"",
|
||||
"isBackground": false,
|
||||
"group": "test"
|
||||
},
|
||||
{
|
||||
"label": "PW: User Profile Tests",
|
||||
"type": "shell",
|
||||
"command": "cd \"${workspaceFolder}/frontend/vue-app\" && npx playwright test e2e/mode_parent/user-profile --project=chromium-user-profile --project=chromium-user-profile-pin --project=chromium-user-profile-delete",
|
||||
"isBackground": false,
|
||||
"group": "test"
|
||||
},
|
||||
{
|
||||
"label": "PW: User Profile Tests 2",
|
||||
"type": "shell",
|
||||
"command": "cd \"${workspaceFolder}/frontend/vue-app\" && npx playwright test e2e/mode_parent/user-profile --project=chromium-user-profile --project=chromium-user-profile-pin --project=chromium-user-profile-delete",
|
||||
"isBackground": false,
|
||||
"group": "test"
|
||||
},
|
||||
{
|
||||
"label": "PW: User Profile Tests Final",
|
||||
"type": "shell",
|
||||
"command": "cd \"${workspaceFolder}/frontend/vue-app\" && npx playwright test e2e/mode_parent/user-profile --project=chromium-user-profile --project=chromium-user-profile-pin --project=chromium-user-profile-delete",
|
||||
"isBackground": false,
|
||||
"group": "test"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -249,3 +249,18 @@ def mark_for_deletion():
|
||||
send_event_to_user(user.id, Event(EventType.FORCE_LOGOUT.value, Payload({'reason': 'account_deleted'})))
|
||||
|
||||
return jsonify({'success': True}), 200
|
||||
|
||||
|
||||
@user_api.route('/user/e2e-get-pin-code', methods=['GET'])
|
||||
def e2e_get_pin_code():
|
||||
"""Return the current user's pin_setup_code for e2e testing. Never available in production."""
|
||||
import os
|
||||
if os.environ.get('DB_ENV', 'prod') == 'prod':
|
||||
return jsonify({'error': 'Not available in production'}), 403
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
user = get_current_user()
|
||||
if not user:
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
return jsonify({'code': user.pin_setup_code or ''}), 200
|
||||
|
||||
35
frontend/vue-app/e2e/.auth/user-temp-parent.json
Normal file
35
frontend/vue-app/e2e/.auth/user-temp-parent.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"cookies": [
|
||||
{
|
||||
"name": "refresh_token",
|
||||
"value": "rwrt2Ui8ecL_ZSoiWpPY5Ahvp-L9Egbegz_FaYn9KA8",
|
||||
"domain": "localhost",
|
||||
"path": "/api/auth",
|
||||
"expires": 1781620038.006557,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Strict"
|
||||
},
|
||||
{
|
||||
"name": "access_token",
|
||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiI4ZTc3OTM1Yi1jYThlLTRiOGYtYmFlMi00NGQyNDA5MjVjODAiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzM4NDQ5Mzd9.NHp7pbMRgbO1jamIJBVwlv75OzbTFoOR801C9gTBNEY",
|
||||
"domain": "localhost",
|
||||
"path": "/",
|
||||
"expires": -1,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Strict"
|
||||
}
|
||||
],
|
||||
"origins": [
|
||||
{
|
||||
"origin": "https://localhost:5173",
|
||||
"localStorage": [
|
||||
{
|
||||
"name": "authSyncEvent",
|
||||
"value": "{\"type\":\"logout\",\"at\":1773844037684}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,17 +2,17 @@
|
||||
"cookies": [
|
||||
{
|
||||
"name": "refresh_token",
|
||||
"value": "13xlv0Lpv2WCL1slwsRXhVfCEBTuzXREnnUYgpfkmRU",
|
||||
"value": "zSbcFMeA59rQCWIkZEDsLO54zjpNu1M0mH0AsHq0Unc",
|
||||
"domain": "localhost",
|
||||
"path": "/api/auth",
|
||||
"expires": 1781577686.529928,
|
||||
"expires": 1781644571.72276,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Strict"
|
||||
},
|
||||
{
|
||||
"name": "access_token",
|
||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiJjYjFhOTYzNy01ZDRkLTRiYTEtODFjNi0xYmM0MjA4M2MyY2YiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzM4MDI1ODZ9.jxoB6roEbEMI0NQT3THPC68Q2L-i1gN1KCheJGCUT4U",
|
||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiJjMDFmNWJhYS1kMGQ3LTQwYzctOTE2OC1mZGY0M2FiNTVmMzUiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzM4Njk0NzF9._LZhytX_HqOpeFQ8XGlHqTz6efhIUDZa2Dp7SB-acqE",
|
||||
"domain": "localhost",
|
||||
"path": "/",
|
||||
"expires": -1,
|
||||
@@ -27,11 +27,11 @@
|
||||
"localStorage": [
|
||||
{
|
||||
"name": "authSyncEvent",
|
||||
"value": "{\"type\":\"logout\",\"at\":1773801686181}"
|
||||
"value": "{\"type\":\"logout\",\"at\":1773868571358}"
|
||||
},
|
||||
{
|
||||
"name": "parentAuth",
|
||||
"value": "{\"expiresAt\":1773974486751}"
|
||||
"value": "{\"expiresAt\":1774041371935}"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export const STORAGE_STATE = 'e2e/.auth/user.json'
|
||||
export const STORAGE_STATE_NO_PIN = 'e2e/.auth/user-no-pin.json'
|
||||
export const STORAGE_STATE_TEMP_PARENT = 'e2e/.auth/user-temp-parent.json'
|
||||
export const E2E_EMAIL = 'e2e@test.com'
|
||||
export const E2E_PASSWORD = 'E2eTestPass1!'
|
||||
export const E2E_PIN = '1234'
|
||||
export const E2E_FIRST_NAME = 'E2E'
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { E2E_PIN } from '../../e2e-constants'
|
||||
|
||||
test.describe('Parent profile button – temporary parent mode', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/parent')
|
||||
// Switch from permanent mode to temporary mode:
|
||||
// 1. Exit parent mode via Child Mode
|
||||
await page.getByRole('button', { name: 'Parent menu' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Child Mode' }).click()
|
||||
await expect(page.getByRole('button', { name: 'Parent login' })).toBeVisible()
|
||||
// 2. Re-enter parent mode WITHOUT the persistence checkbox
|
||||
await page.getByRole('button', { name: 'Parent login' }).click()
|
||||
const pinInput = page.getByPlaceholder('4–6 digits')
|
||||
await pinInput.waitFor({ timeout: 5000 })
|
||||
await pinInput.fill(E2E_PIN)
|
||||
// Do NOT check 'Stay in parent mode'
|
||||
await page.getByRole('button', { name: 'OK' }).click()
|
||||
await page.waitForURL(/\/parent(\/|$)/)
|
||||
})
|
||||
|
||||
test('Badge – no 🔒 badge in temporary parent mode', async ({ page }) => {
|
||||
await expect(page.getByLabel('Persistent parent mode active')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { E2E_EMAIL, E2E_FIRST_NAME, E2E_PIN } from '../../e2e-constants'
|
||||
|
||||
test.describe('Parent profile button – permanent parent mode', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/parent')
|
||||
})
|
||||
|
||||
test('Badge – shows 🔒 in permanent parent mode', async ({ page }) => {
|
||||
await expect(page.getByLabel('Persistent parent mode active')).toBeVisible()
|
||||
await expect(page.getByLabel('Persistent parent mode active')).toHaveText('🔒')
|
||||
})
|
||||
|
||||
test('Menu – shows user name and email', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Parent menu' }).click()
|
||||
|
||||
await expect(page.getByText(E2E_FIRST_NAME, { exact: true })).toBeVisible()
|
||||
await expect(page.getByText(E2E_EMAIL, { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Menu – Profile option navigates to the User Profile page', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Parent menu' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Profile' }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/parent\/profile/)
|
||||
await expect(page.getByRole('heading', { name: 'User Profile' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Menu – Child Mode exits parent mode', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Parent menu' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Child Mode' }).click()
|
||||
|
||||
// View-selector navigation buttons disappear after exiting parent mode
|
||||
await expect(page.getByRole('button', { name: 'Children' })).not.toBeVisible()
|
||||
// Profile button changes label to "Parent login" when not in parent mode
|
||||
await expect(page.getByRole('button', { name: 'Parent login' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Menu – Child Mode re-entry without checkbox enters temporary mode', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Parent menu' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Child Mode' }).click()
|
||||
|
||||
// Click profile button to open PIN modal
|
||||
await page.getByRole('button', { name: 'Parent login' }).click()
|
||||
|
||||
const pinInput = page.getByPlaceholder('4–6 digits')
|
||||
await pinInput.waitFor({ timeout: 5000 })
|
||||
await pinInput.fill(E2E_PIN)
|
||||
// Do NOT check "Stay in parent mode"
|
||||
await page.getByRole('button', { name: 'OK' }).click()
|
||||
|
||||
await page.waitForURL(/\/parent(\/|$)/)
|
||||
|
||||
// No persistent badge — temporary mode
|
||||
await expect(page.getByLabel('Persistent parent mode active')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Menu – Child Mode re-entry with checkbox enters permanent mode', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Parent menu' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Child Mode' }).click()
|
||||
|
||||
// Click profile button to open PIN modal
|
||||
await page.getByRole('button', { name: 'Parent login' }).click()
|
||||
|
||||
const pinInput = page.getByPlaceholder('4–6 digits')
|
||||
await pinInput.waitFor({ timeout: 5000 })
|
||||
await pinInput.fill(E2E_PIN)
|
||||
await page.getByLabel('Stay in parent mode on this device').check()
|
||||
await page.getByRole('button', { name: 'OK' }).click()
|
||||
|
||||
await page.waitForURL(/\/parent(\/|$)/)
|
||||
|
||||
// Persistent badge visible — permanent mode
|
||||
await expect(page.getByLabel('Persistent parent mode active')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Menu – Sign Out navigates to landing page', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Parent menu' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Sign out' }).click()
|
||||
|
||||
await expect(page).toHaveURL('/')
|
||||
await expect(
|
||||
page.getByRole('navigation').getByRole('button', { name: 'Sign In' }),
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -37,23 +37,13 @@ test.describe('Convert default reward', () => {
|
||||
await expect(page.locator('input#name')).toHaveValue('Choose meal')
|
||||
|
||||
// change fields
|
||||
await page.evaluate(() => {
|
||||
const setVal = (sel: string, val: string) => {
|
||||
const el = document.querySelector(sel) as HTMLInputElement | null
|
||||
if (el) {
|
||||
el.value = val
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
setVal('#name', 'Choose meal (custom)')
|
||||
setVal('#cost', '35')
|
||||
})
|
||||
await page.locator('#name').fill('Choose meal (custom)')
|
||||
await page.locator('#cost').fill('35')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
await page.click('button:has-text("Save")')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
// after save, ensure row now has delete control
|
||||
const updated = page.locator('text=Choose meal (custom)').first()
|
||||
const updated = page.getByText('Choose meal (custom)', { exact: true }).first()
|
||||
await expect(updated).toBeVisible()
|
||||
await expect(updated.locator('..').getByRole('button', { name: 'Delete' })).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -29,9 +29,12 @@ test.describe('Default penalty management', () => {
|
||||
}
|
||||
|
||||
// locate default penalty
|
||||
await expect(page.locator('text=Fighting')).toBeVisible()
|
||||
await expect(page.getByText('Fighting', { exact: true })).toBeVisible()
|
||||
await expect(
|
||||
page.locator('text=Fighting >> .. >> button[aria-label="Delete item"]'),
|
||||
page
|
||||
.getByText('Fighting', { exact: true })
|
||||
.locator('..')
|
||||
.locator('button[aria-label="Delete item"]'),
|
||||
).toHaveCount(0)
|
||||
|
||||
// edit it (click the item itself)
|
||||
|
||||
181
frontend/vue-app/e2e/mode_parent/user-profile/change-pin.spec.ts
Normal file
181
frontend/vue-app/e2e/mode_parent/user-profile/change-pin.spec.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
// spec: e2e/plans/user-profile.plan.md
|
||||
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
import { E2E_PIN } from '../../e2e-constants'
|
||||
|
||||
const BACKEND = 'http://localhost:5000'
|
||||
const NEW_PIN = '5678'
|
||||
|
||||
async function setPinDirectly(request: APIRequestContext, pin: string): Promise<void> {
|
||||
// Request a new code, retrieve it via test endpoint, verify it, then set the pin
|
||||
await request.post(`${BACKEND}/user/request-pin-setup`)
|
||||
const codeRes = await request.get(`${BACKEND}/user/e2e-get-pin-code`)
|
||||
const { code } = await codeRes.json()
|
||||
await request.post(`${BACKEND}/user/verify-pin-setup`, { data: { code } })
|
||||
await request.post(`${BACKEND}/user/set-pin`, { data: { pin } })
|
||||
}
|
||||
|
||||
test.describe('User Profile – Change Parent PIN', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
// Restore original PIN so subsequent test runs start fresh
|
||||
await setPinDirectly(request, E2E_PIN)
|
||||
})
|
||||
|
||||
test('Change Parent PIN link navigates to PIN setup page', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
await page.getByRole('button', { name: 'Change Parent PIN' }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/parent\/pin-setup/)
|
||||
await expect(page.getByRole('heading', { name: 'Set up your Parent PIN' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Back from PIN setup page returns to profile', async ({ page }) => {
|
||||
// Navigate from the profile page so browser history exists
|
||||
await page.goto('/parent/profile')
|
||||
await page.getByRole('button', { name: 'Change Parent PIN' }).click()
|
||||
await expect(page).toHaveURL(/\/parent\/pin-setup/)
|
||||
|
||||
await page.goBack()
|
||||
|
||||
await expect(page).toHaveURL(/\/parent\/profile/)
|
||||
})
|
||||
|
||||
test('Send Verification Code advances to step 2', async ({ page }) => {
|
||||
await page.goto('/parent/pin-setup')
|
||||
|
||||
await page.getByRole('button', { name: 'Send Verification Code' }).click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Enter Verification Code' })).toBeVisible()
|
||||
|
||||
const codeInput = page.getByPlaceholder('6-digit code')
|
||||
await expect(codeInput).toBeVisible()
|
||||
|
||||
// Verify Code is disabled while input is empty
|
||||
await expect(page.getByRole('button', { name: 'Verify Code' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Resend Code button appears after delay', async ({ page }) => {
|
||||
await page.goto('/parent/pin-setup')
|
||||
|
||||
await page.getByRole('button', { name: 'Send Verification Code' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Enter Verification Code' })).toBeVisible()
|
||||
|
||||
// According to the component, resend appears after 10 seconds
|
||||
await expect(page.getByRole('button', { name: 'Resend Code' })).toBeVisible({ timeout: 15000 })
|
||||
})
|
||||
|
||||
test('Invalid code shows error', async ({ page }) => {
|
||||
await page.goto('/parent/pin-setup')
|
||||
await page.getByRole('button', { name: 'Send Verification Code' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Enter Verification Code' })).toBeVisible()
|
||||
|
||||
await page.getByPlaceholder('6-digit code').fill('ZZZZZZ')
|
||||
await page.getByRole('button', { name: 'Verify Code' }).click()
|
||||
|
||||
await expect(page.getByText(/invalid code/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Valid code advances to step 3 (Create PIN)', async ({ page, request }) => {
|
||||
await page.goto('/parent/pin-setup')
|
||||
await page.getByRole('button', { name: 'Send Verification Code' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Enter Verification Code' })).toBeVisible()
|
||||
|
||||
// Retrieve the code via the test-only endpoint
|
||||
const codeRes = await request.get(`${BACKEND}/user/e2e-get-pin-code`)
|
||||
const { code } = await codeRes.json()
|
||||
|
||||
await page.getByPlaceholder('6-digit code').fill(code)
|
||||
await page.getByRole('button', { name: 'Verify Code' }).click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Create Parent PIN' })).toBeVisible()
|
||||
await expect(page.getByPlaceholder('New PIN')).toBeVisible()
|
||||
await expect(page.getByPlaceholder('Confirm PIN')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Set PIN' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Set PIN disabled when PINs do not match', async ({ page, request }) => {
|
||||
await page.goto('/parent/pin-setup')
|
||||
await page.getByRole('button', { name: 'Send Verification Code' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Enter Verification Code' })).toBeVisible()
|
||||
|
||||
const codeRes = await request.get(`${BACKEND}/user/e2e-get-pin-code`)
|
||||
const { code } = await codeRes.json()
|
||||
await page.getByPlaceholder('6-digit code').fill(code)
|
||||
await page.getByRole('button', { name: 'Verify Code' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Create Parent PIN' })).toBeVisible()
|
||||
|
||||
await page.getByPlaceholder('New PIN').fill('1111')
|
||||
await page.getByPlaceholder('Confirm PIN').fill('2222')
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Set PIN' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Set PIN disabled for fewer than 4 digits', async ({ page, request }) => {
|
||||
await page.goto('/parent/pin-setup')
|
||||
await page.getByRole('button', { name: 'Send Verification Code' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Enter Verification Code' })).toBeVisible()
|
||||
|
||||
const codeRes = await request.get(`${BACKEND}/user/e2e-get-pin-code`)
|
||||
const { code } = await codeRes.json()
|
||||
await page.getByPlaceholder('6-digit code').fill(code)
|
||||
await page.getByRole('button', { name: 'Verify Code' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Create Parent PIN' })).toBeVisible()
|
||||
|
||||
await page.getByPlaceholder('New PIN').fill('12')
|
||||
await page.getByPlaceholder('Confirm PIN').fill('12')
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Set PIN' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Set PIN succeeds with matching 4-digit PIN – shows success step', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await page.goto('/parent/pin-setup')
|
||||
await page.getByRole('button', { name: 'Send Verification Code' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Enter Verification Code' })).toBeVisible()
|
||||
|
||||
const codeRes = await request.get(`${BACKEND}/user/e2e-get-pin-code`)
|
||||
const { code } = await codeRes.json()
|
||||
await page.getByPlaceholder('6-digit code').fill(code)
|
||||
await page.getByRole('button', { name: 'Verify Code' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Create Parent PIN' })).toBeVisible()
|
||||
|
||||
await page.getByPlaceholder('New PIN').fill(NEW_PIN)
|
||||
await page.getByPlaceholder('Confirm PIN').fill(NEW_PIN)
|
||||
await expect(page.getByRole('button', { name: 'Set PIN' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Set PIN' }).click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Parent PIN Set!' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Back', exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Back from success step exits parent mode and goes to child selector', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await page.goto('/parent/pin-setup')
|
||||
await page.getByRole('button', { name: 'Send Verification Code' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Enter Verification Code' })).toBeVisible()
|
||||
|
||||
const codeRes = await request.get(`${BACKEND}/user/e2e-get-pin-code`)
|
||||
const { code } = await codeRes.json()
|
||||
await page.getByPlaceholder('6-digit code').fill(code)
|
||||
await page.getByRole('button', { name: 'Verify Code' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Create Parent PIN' })).toBeVisible()
|
||||
|
||||
await page.getByPlaceholder('New PIN').fill(NEW_PIN)
|
||||
await page.getByPlaceholder('Confirm PIN').fill(NEW_PIN)
|
||||
await page.getByRole('button', { name: 'Set PIN' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Parent PIN Set!' })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Back', exact: true }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/child(\/|$)/)
|
||||
// Parent mode exited – "Parent login" button visible instead of parent menu
|
||||
await expect(page.getByRole('button', { name: 'Parent login' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
// spec: e2e/plans/user-profile.plan.md
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { E2E_EMAIL, E2E_PASSWORD } from '../../e2e-constants'
|
||||
|
||||
const BACKEND = 'http://localhost:5000'
|
||||
|
||||
test.describe('User Profile – Delete Account', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
// Re-seed the e2e database to restore the test user for any subsequent test suites
|
||||
await request.post(`${BACKEND}/auth/e2e-seed`)
|
||||
})
|
||||
|
||||
test('Delete My Account opens confirmation dialog', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
await page.getByRole('button', { name: 'Delete My Account' }).click()
|
||||
|
||||
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
|
||||
await expect(page.locator('#confirmEmail')).toBeVisible()
|
||||
await expect(page.locator('#confirmEmail')).toHaveValue('')
|
||||
await expect(
|
||||
page.locator('.modal-dialog').getByRole('button', { name: 'Delete', exact: true }),
|
||||
).toBeDisabled()
|
||||
await expect(
|
||||
page.locator('.modal-dialog').getByRole('button', { name: 'Cancel' }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Delete button stays disabled for incomplete email', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await page.getByRole('button', { name: 'Delete My Account' }).click()
|
||||
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
|
||||
|
||||
await page.locator('#confirmEmail').fill('e2e@')
|
||||
|
||||
await expect(
|
||||
page.locator('.modal-dialog').getByRole('button', { name: 'Delete', exact: true }),
|
||||
).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Delete button enables when matching email is entered', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await page.getByRole('button', { name: 'Delete My Account' }).click()
|
||||
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
|
||||
|
||||
await page.locator('#confirmEmail').fill(E2E_EMAIL)
|
||||
|
||||
await expect(
|
||||
page.locator('.modal-dialog').getByRole('button', { name: 'Delete', exact: true }),
|
||||
).toBeEnabled()
|
||||
})
|
||||
|
||||
test('Cancel closes the dialog without deleting the account', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await page.getByRole('button', { name: 'Delete My Account' }).click()
|
||||
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
|
||||
|
||||
await page.locator('.modal-dialog').getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
// Dialog is gone, still on profile page
|
||||
await expect(
|
||||
page.locator('.modal-title', { hasText: 'Delete Your Account?' }),
|
||||
).not.toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'User Profile' })).toBeVisible()
|
||||
await expect(page).toHaveURL(/\/parent\/profile/)
|
||||
})
|
||||
|
||||
test('Backdrop click does NOT close the delete dialog', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
await page.getByRole('button', { name: 'Delete My Account' }).click()
|
||||
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
|
||||
|
||||
// Click the backdrop (outside the modal-dialog box)
|
||||
await page.locator('.modal-backdrop').click({ position: { x: 10, y: 10 }, force: true })
|
||||
|
||||
// Dialog must still be visible – the delete warning has no backdrop-click listener
|
||||
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Successful deletion: confirmation modal appears then redirects to landing page', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/parent/profile')
|
||||
await page.getByRole('button', { name: 'Delete My Account' }).click()
|
||||
await expect(page.locator('.modal-title', { hasText: 'Delete Your Account?' })).toBeVisible()
|
||||
|
||||
await page.locator('#confirmEmail').fill(E2E_EMAIL)
|
||||
await page.locator('.modal-dialog').getByRole('button', { name: 'Delete', exact: true }).click()
|
||||
|
||||
// Confirmation modal appears
|
||||
await expect(page.locator('.modal-title', { hasText: 'Account Deleted' })).toBeVisible()
|
||||
await expect(page.getByText(/marked for deletion/i)).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'OK' }).click()
|
||||
|
||||
// Redirect to landing page
|
||||
await expect(page).toHaveURL('/')
|
||||
await expect(page.getByRole('button', { name: 'Sign In' }).first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Login after deletion is blocked with an appropriate error', async ({ page }) => {
|
||||
await page.goto('/auth/login')
|
||||
|
||||
await page.getByLabel('Email address').fill(E2E_EMAIL)
|
||||
await page.getByLabel('Password').fill(E2E_PASSWORD)
|
||||
await page.getByRole('button', { name: 'Sign in' }).click()
|
||||
|
||||
// Login must fail with a deletion-related error message
|
||||
await expect(page.getByText(/marked for deletion/i)).toBeVisible({ timeout: 8000 })
|
||||
// URL stays on the login page
|
||||
await expect(page).toHaveURL(/\/auth\/login/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,235 @@
|
||||
// spec: e2e/plans/user-profile.plan.md
|
||||
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { E2E_EMAIL, E2E_FIRST_NAME } from '../../e2e-constants'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const TEST_IMAGE = path.join(__dirname, '../../.resources/crown.png')
|
||||
|
||||
const BACKEND = 'http://localhost:5000'
|
||||
|
||||
interface ProfileData {
|
||||
first_name: string
|
||||
last_name: string
|
||||
image_id: string | null
|
||||
}
|
||||
|
||||
async function getProfile(request: APIRequestContext): Promise<ProfileData> {
|
||||
const res = await request.get(`${BACKEND}/user/profile`)
|
||||
const data = await res.json()
|
||||
return { first_name: data.first_name, last_name: data.last_name, image_id: data.image_id ?? null }
|
||||
}
|
||||
|
||||
async function restoreProfile(request: APIRequestContext, profile: ProfileData): Promise<void> {
|
||||
await request.put(`${BACKEND}/user/profile`, {
|
||||
data: {
|
||||
first_name: profile.first_name,
|
||||
last_name: profile.last_name,
|
||||
image_id: profile.image_id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('User Profile – editing', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let originalProfile: ProfileData
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
originalProfile = await getProfile(request)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
await restoreProfile(request, originalProfile)
|
||||
})
|
||||
|
||||
test('Profile page loads with correct data', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'User Profile' })).toBeVisible()
|
||||
await expect(page.getByLabel('First Name')).toHaveValue(E2E_FIRST_NAME)
|
||||
await expect(page.getByLabel('Last Name')).toHaveValue('Tester')
|
||||
await expect(page.getByLabel('Email Address')).toHaveValue(E2E_EMAIL)
|
||||
await expect(page.getByLabel('Email Address')).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Back button navigates back', async ({ page }) => {
|
||||
await page.goto('/parent')
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
await expect(page).toHaveURL('/parent')
|
||||
})
|
||||
|
||||
test('Save is disabled when form is clean (not dirty)', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Save is disabled when First Name is empty', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
await page.getByLabel('First Name').fill('')
|
||||
await page.getByLabel('First Name').blur()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Save is disabled when Last Name is empty', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
await page.getByLabel('Last Name').fill('')
|
||||
await page.getByLabel('Last Name').blur()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Save is disabled when both name fields are empty', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
await page.getByLabel('First Name').fill('')
|
||||
await page.getByLabel('Last Name').fill('')
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Save enables when a name is changed', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
await page.getByLabel('First Name').fill('UpdatedE2E')
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
})
|
||||
|
||||
test('Save persists name changes and shows confirmation modal', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
await page.getByLabel('First Name').fill('UpdatedE2E')
|
||||
await page.getByLabel('Last Name').fill('UpdatedTester')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
const dialog = page.locator('.modal-dialog')
|
||||
await expect(dialog.locator('.modal-title', { hasText: 'Profile Updated' })).toBeVisible()
|
||||
await expect(dialog.getByText('Your profile was updated successfully.')).toBeVisible()
|
||||
await dialog.getByRole('button', { name: 'OK' }).click()
|
||||
|
||||
// OK navigates back; go back to profile to verify persistence
|
||||
await page.goto('/parent/profile')
|
||||
await expect(page.getByLabel('First Name')).toHaveValue('UpdatedE2E')
|
||||
await expect(page.getByLabel('Last Name')).toHaveValue('UpdatedTester')
|
||||
})
|
||||
|
||||
test('Cancel discards unsaved changes', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
await page.getByLabel('First Name').fill('Discarded')
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
// Navigate back to verify no changes were saved
|
||||
await page.goto('/parent/profile')
|
||||
await expect(page.getByLabel('First Name')).toHaveValue(E2E_FIRST_NAME)
|
||||
})
|
||||
|
||||
test('Email field is read-only', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
const emailInput = page.locator('#email')
|
||||
await expect(emailInput).toBeDisabled()
|
||||
await expect(emailInput).toHaveValue(E2E_EMAIL)
|
||||
})
|
||||
|
||||
test('Change profile image (built-in)', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
// Wait for images to load
|
||||
await page.waitForSelector('.selectable-image')
|
||||
|
||||
// Pick the second image in the list (index 1), regardless of which is selected
|
||||
const images = page.locator('.selectable-image')
|
||||
const count = await images.count()
|
||||
expect(count).toBeGreaterThan(1)
|
||||
|
||||
// Find first image that is NOT currently selected
|
||||
let targetIndex = -1
|
||||
for (let i = 0; i < count; i++) {
|
||||
const cls = await images.nth(i).getAttribute('class')
|
||||
if (!cls?.includes('selected')) {
|
||||
targetIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
expect(targetIndex).toBeGreaterThanOrEqual(0)
|
||||
|
||||
await images.nth(targetIndex).click()
|
||||
|
||||
// Confirm it is now selected
|
||||
await expect(images.nth(targetIndex)).toHaveClass(/selected/)
|
||||
|
||||
// Save
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(
|
||||
page.locator('.modal-dialog .modal-title', { hasText: 'Profile Updated' }),
|
||||
).toBeVisible()
|
||||
await page.locator('.modal-dialog').getByRole('button', { name: 'OK' }).click()
|
||||
|
||||
// Re-visit and confirm selection persists
|
||||
await page.goto('/parent/profile')
|
||||
await page.waitForSelector('.selectable-image')
|
||||
const selectedSrc = await page.locator('.selectable-image.selected').getAttribute('src')
|
||||
expect(selectedSrc).toBeTruthy()
|
||||
// The URL will differ after a new load (Object URL vs cached), so verify via API
|
||||
const profile = await getProfile(page.request)
|
||||
expect(profile.image_id).toBeTruthy()
|
||||
})
|
||||
|
||||
test('Upload a custom profile image', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
await page.waitForSelector('.selectable-image')
|
||||
|
||||
const fileChooserPromise = page.waitForEvent('filechooser')
|
||||
await page.getByRole('button', { name: 'Add from device' }).click()
|
||||
const fileChooser = await fileChooserPromise
|
||||
await fileChooser.setFiles(TEST_IMAGE)
|
||||
|
||||
// The uploaded image appears first in the list and is selected
|
||||
await expect(page.locator('.selectable-image').first()).toHaveClass(/selected/)
|
||||
|
||||
// Save is now enabled
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
await expect(
|
||||
page.locator('.modal-dialog .modal-title', { hasText: 'Profile Updated' }),
|
||||
).toBeVisible()
|
||||
await page.locator('.modal-dialog').getByRole('button', { name: 'OK' }).click()
|
||||
})
|
||||
|
||||
test('Change Password shows email-sent modal', async ({ page }) => {
|
||||
await page.goto('/parent/profile')
|
||||
|
||||
await page.getByRole('button', { name: 'Change Password' }).click()
|
||||
|
||||
// Modal appears
|
||||
const dialog = page.locator('.modal-dialog')
|
||||
await expect(dialog.locator('.modal-title', { hasText: 'Change Password' })).toBeVisible()
|
||||
|
||||
// Eventually becomes the confirmed state
|
||||
await expect(
|
||||
dialog.locator('.modal-title', { hasText: 'Password Change Email Sent' }),
|
||||
).toBeVisible({
|
||||
timeout: 10000,
|
||||
})
|
||||
await expect(dialog.locator('.modal-message')).toContainText(/password change link/i)
|
||||
|
||||
await dialog.getByRole('button', { name: 'OK' }).click()
|
||||
|
||||
// Modal dismissed, back on the profile page
|
||||
await expect(page.getByRole('heading', { name: 'User Profile' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
65
frontend/vue-app/e2e/plans/parent-profile-button.plan.md
Normal file
65
frontend/vue-app/e2e/plans/parent-profile-button.plan.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Parent Profile Button
|
||||
|
||||
## Application Overview
|
||||
|
||||
In parent mode, the top right of the screen should display the parent's profile icon. When clicking this icon, a menu should appear giving options to select.
|
||||
This is a parent mode only test, and probably should be in it's own bucket of tests
|
||||
|
||||
**Profile Button Badge:**
|
||||
|
||||
- There are 2 modes for the parent profile button in parent mode: Temporary access and Permanent access.
|
||||
- In temporary mode, there is no badge overlaying the profile icon button. This mode will revert back to child mode after after 1 minute or a reload of the site (see feat-parent-mode-expire.md)
|
||||
- In permanent mode, there should be a lock emoji badge over the icon button. This lets the user know that parent mode is active for 2 days. Reloading the page still keeps permanent mode. (see feat-parent-mode-expire.md)
|
||||
|
||||
**Profile Button Menu Options:**
|
||||
When clicking the profile button, we should see the user's selected profile picture, name, and email. Verify those are present. Under this are options.
|
||||
|
||||
1. Profile
|
||||
- This option should take the user to the Edit Profile page. We just need to verify that page is there, testing the page will be for another plan.
|
||||
2. Child Mode
|
||||
- Clicking this will take us out of parent mode and back to child mode. This can be verified by no longer having the view-selector navigation tab at the top-middle of the screen.
|
||||
- In Child mode, clicking the profile button again should prompt us for the parent PIN.
|
||||
- Entering the correct PIN will take us back into temporary parent mode. It will take us back into permanent parent mode if the checkbox is selected during PIN entry.
|
||||
3. Sign Out
|
||||
- This will take us back to the landing page.
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### Constants
|
||||
|
||||
`E2E_FIRST_NAME` and `STORAGE_STATE_TEMP_PARENT` were added to `e2e/e2e-constants.ts`. `E2E_FIRST_NAME` holds the first name of the seeded test user and is used to verify the name appears in the profile menu.
|
||||
|
||||
### Auth Setup
|
||||
|
||||
`e2e/auth-temp-parent.setup.ts` was created to produce a temporary parent mode storage state (logs in and enters PIN without checking "Stay in parent mode"). It depends on `setup` so it runs after the database is seeded.
|
||||
|
||||
### Playwright Projects (`playwright.config.ts`)
|
||||
|
||||
Two new isolated projects were added:
|
||||
|
||||
- **`chromium-profile-button`** — uses the standard permanent parent mode storage state. Runs all spec files under `e2e/mode_parent/profile-button/`.
|
||||
- **`chromium-profile-button-temp`** — uses the temporary parent mode storage state. Runs the temporary-mode badge spec only.
|
||||
|
||||
Both projects are excluded from the catch-all `chromium-tasks-rewards` project.
|
||||
|
||||
### Test Files
|
||||
|
||||
**`e2e/mode_parent/profile-button/profile-button.spec.ts`** ✅ IMPLEMENTED
|
||||
|
||||
Covers all permanent parent mode scenarios (7 tests):
|
||||
|
||||
1. **Badge – 🔒 visible in permanent mode** ✅
|
||||
2. **Menu – shows user name and email** ✅
|
||||
3. **Menu → Profile navigates to User Profile page** ✅
|
||||
4. **Menu → Child Mode exits parent mode** ✅ (view-selector disappears, button label changes to "Parent login")
|
||||
5. **Menu → Child Mode re-entry without checkbox enters temporary mode** ✅ (no 🔒 badge after re-entry)
|
||||
6. **Menu → Child Mode re-entry with checkbox enters permanent mode** ✅ (🔒 badge visible after re-entry)
|
||||
7. **Menu → Sign Out navigates to landing page** ✅ ("Sign In" nav button visible)
|
||||
|
||||
**`e2e/mode_parent/profile-button/profile-button-temporary.spec.ts`** ✅ IMPLEMENTED
|
||||
|
||||
Covers the temporary parent mode badge scenario (1 test):
|
||||
|
||||
1. **Badge – no 🔒 badge in temporary mode** ✅ (enters temp mode by exiting to child mode then re-entering PIN without "Stay" checkbox)
|
||||
169
frontend/vue-app/e2e/plans/user-profile.plan.md
Normal file
169
frontend/vue-app/e2e/plans/user-profile.plan.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# User Profile
|
||||
|
||||
## Application Overview
|
||||
|
||||
The User Profile page at `/parent/profile` allows changing a profile image, first name, and last name. It also provides links to: Change Parent PIN, Change Password, and Delete Account. Save and Cancel buttons control form submission.
|
||||
|
||||
**Email resolution for e2e PIN/password tests:** A test-only backend endpoint (`GET /user/e2e-get-pin-code`) returns the verification code directly. This endpoint is gated to non-production environments. Password reset tests simply verify the email-sent modal appears without inspecting the actual email.
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
Tests are split into three isolated Playwright project buckets, all under `e2e/mode_parent/user-profile/`.
|
||||
|
||||
## Playwright Projects
|
||||
|
||||
| Project | File | Notes |
|
||||
| ------------------------------ | ------------------------- | ------------------------------------------- |
|
||||
| `chromium-user-profile` | `profile-editing.spec.ts` | Restores profile via API in `afterEach` |
|
||||
| `chromium-user-profile-pin` | `change-pin.spec.ts` | Restores original PIN via API in `afterAll` |
|
||||
| `chromium-user-profile-delete` | `delete-account.spec.ts` | Re-seeds entire DB in `afterAll`; run last |
|
||||
|
||||
**Config:** `playwright.config.ts` — all three depend on `setup` only.
|
||||
|
||||
---
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### 1. Profile Editing ✅ IMPLEMENTED
|
||||
|
||||
**File:** `e2e/mode_parent/user-profile/profile-editing.spec.ts`
|
||||
|
||||
**Seed:** Snapshots profile via API in `beforeEach`; restores it in `afterEach`.
|
||||
|
||||
#### 1.1. Page loads with correct data ✅
|
||||
|
||||
- Profile heading is visible; First Name, Last Name, and Email fields show the seeded e2e values
|
||||
|
||||
#### 1.2. Cancel navigates back ✅
|
||||
|
||||
- Clicking Cancel returns to the previous page without saving
|
||||
|
||||
#### 1.3. Save disabled when form is clean ✅
|
||||
|
||||
- Save button is disabled when no fields have been changed
|
||||
|
||||
#### 1.4. Save disabled when First Name is empty ✅
|
||||
|
||||
- Clearing the First Name field disables the Save button
|
||||
|
||||
#### 1.5. Save disabled when Last Name is empty ✅
|
||||
|
||||
- Clearing the Last Name field disables the Save button
|
||||
|
||||
#### 1.6. Save disabled when both name fields are empty ✅
|
||||
|
||||
- Clearing both fields keeps Save disabled
|
||||
|
||||
#### 1.7. Save enables when a name is changed ✅
|
||||
|
||||
- Editing any name field enables the Save button
|
||||
|
||||
#### 1.8. Save persists name changes ✅
|
||||
|
||||
- After saving, re-opening the profile shows the new values; a "Profile Updated" confirmation modal appears on save
|
||||
|
||||
#### 1.9. Cancel discards unsaved changes ✅
|
||||
|
||||
- Changes made before Cancel are not reflected when the profile is re-opened
|
||||
|
||||
#### 1.10. Email field is read-only ✅
|
||||
|
||||
- The email input is disabled and shows the account email
|
||||
|
||||
#### 1.11. Change profile image (built-in) ✅
|
||||
|
||||
- Selecting an unselected built-in image and saving shows the confirmation modal; image persists on reload
|
||||
|
||||
#### 1.12. Upload a custom profile image ✅
|
||||
|
||||
- Using "Add from device" to upload a local file selects it as the profile image; saving shows the confirmation modal
|
||||
|
||||
#### 1.13. Change Password shows email-sent modal ✅
|
||||
|
||||
- Clicking Change Password shows a loading modal that transitions to a "Password Change Email Sent" confirmation; OK dismisses it and returns to the profile page
|
||||
|
||||
---
|
||||
|
||||
### 2. Change Parent PIN ✅ IMPLEMENTED
|
||||
|
||||
**File:** `e2e/mode_parent/user-profile/change-pin.spec.ts`
|
||||
|
||||
**Seed:** Restores the original e2e PIN via the test API helper in `afterAll`.
|
||||
|
||||
#### 2.1. Change Parent PIN link navigates to PIN setup page ✅
|
||||
|
||||
- Clicking the link navigates to `/parent/pin-setup` and shows the "Set up your Parent PIN" step
|
||||
|
||||
#### 2.2. Back from PIN setup returns to profile ✅
|
||||
|
||||
- Browser back from the PIN setup page returns to `/parent/profile`
|
||||
|
||||
#### 2.3. Send Verification Code advances to step 2 ✅
|
||||
|
||||
- Clicking the button shows the "Enter Verification Code" step with a code input; Verify Code is disabled while the input is empty
|
||||
|
||||
#### 2.4. Resend Code button appears after delay ✅
|
||||
|
||||
- A "Resend Code" button appears within ~10 seconds of reaching step 2
|
||||
|
||||
#### 2.5. Invalid code shows error ✅
|
||||
|
||||
- Submitting a wrong code shows an "invalid code" error message
|
||||
|
||||
#### 2.6. Valid code advances to step 3 (Create PIN) ✅
|
||||
|
||||
- Entering the code from the test endpoint and clicking Verify Code shows the "Create Parent PIN" step with New PIN and Confirm PIN fields; Set PIN is initially disabled
|
||||
|
||||
#### 2.7. Set PIN disabled when PINs do not match ✅
|
||||
|
||||
- Entering different values in both fields keeps Set PIN disabled
|
||||
|
||||
#### 2.8. Set PIN disabled for fewer than 4 digits ✅
|
||||
|
||||
- Entering a 2-digit value in both fields keeps Set PIN disabled
|
||||
|
||||
#### 2.9. Set PIN succeeds with matching 4-digit PIN ✅
|
||||
|
||||
- Entering matching 4-digit values and clicking Set PIN shows the "Parent PIN Set!" success step with a Back button
|
||||
|
||||
#### 2.10. Back from success step exits parent mode ✅
|
||||
|
||||
- Clicking Back on the success step navigates to the child selector (`/child`) and exits parent mode
|
||||
|
||||
---
|
||||
|
||||
### 3. Delete Account ✅ IMPLEMENTED
|
||||
|
||||
**File:** `e2e/mode_parent/user-profile/delete-account.spec.ts`
|
||||
|
||||
**Seed:** Re-seeds the entire e2e database via `POST /auth/e2e-seed` in `afterAll` to restore the test user.
|
||||
|
||||
#### 3.1. Delete My Account opens confirmation dialog ✅
|
||||
|
||||
- Clicking the button shows a "Delete Your Account?" modal with an email confirmation input; Delete is disabled and Cancel is visible
|
||||
|
||||
#### 3.2. Delete button stays disabled for incomplete email ✅
|
||||
|
||||
- Entering a partial email keeps the Delete button disabled
|
||||
|
||||
#### 3.3. Delete button enables when matching email is entered ✅
|
||||
|
||||
- Entering the exact account email enables the Delete button
|
||||
|
||||
#### 3.4. Cancel closes the dialog without deleting ✅
|
||||
|
||||
- Clicking Cancel dismisses the modal and stays on the profile page with no account changes
|
||||
|
||||
#### 3.5. Backdrop click does NOT close the dialog ✅
|
||||
|
||||
- Clicking the modal backdrop leaves the dialog open (no backdrop-dismiss behavior)
|
||||
|
||||
#### 3.6. Successful deletion redirects to landing page ✅
|
||||
|
||||
- After entering the email and clicking Delete, an "Account Deleted" confirmation modal appears; clicking OK redirects to the landing page (`/`)
|
||||
|
||||
#### 3.7. Login after deletion is blocked ✅
|
||||
|
||||
- Attempting to sign in with the deleted account's credentials shows a "marked for deletion" error and stays on the login page
|
||||
@@ -98,6 +98,38 @@ export default defineConfig({
|
||||
testMatch: [/mode_parent\/task-modification\/.+\.spec\.ts/],
|
||||
},
|
||||
|
||||
{
|
||||
// Bucket: parent profile button tests (permanent parent mode).
|
||||
name: 'chromium-profile-button',
|
||||
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE },
|
||||
dependencies: ['setup'],
|
||||
testMatch: [/mode_parent\/profile-button\/.+\.spec\.ts/],
|
||||
},
|
||||
|
||||
{
|
||||
// Bucket: user profile editing tests (name, image, save/cancel, change password).
|
||||
name: 'chromium-user-profile',
|
||||
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE },
|
||||
dependencies: ['setup'],
|
||||
testMatch: [/mode_parent\/user-profile\/profile-editing\.spec\.ts/],
|
||||
},
|
||||
|
||||
{
|
||||
// Bucket: change PIN flow tests — restores the original PIN in afterAll.
|
||||
name: 'chromium-user-profile-pin',
|
||||
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE },
|
||||
dependencies: ['setup'],
|
||||
testMatch: [/mode_parent\/user-profile\/change-pin\.spec\.ts/],
|
||||
},
|
||||
|
||||
{
|
||||
// Bucket: delete account tests — MUST run last; re-seeds the DB in afterAll.
|
||||
name: 'chromium-user-profile-delete',
|
||||
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE },
|
||||
dependencies: ['setup'],
|
||||
testMatch: [/mode_parent\/user-profile\/delete-account\.spec\.ts/],
|
||||
},
|
||||
|
||||
{
|
||||
name: 'chromium-tasks-rewards',
|
||||
use: { ...devices['Desktop Chrome'], storageState: STORAGE_STATE },
|
||||
@@ -109,6 +141,8 @@ export default defineConfig({
|
||||
/mode_parent\/task-assignment\//,
|
||||
/mode_parent\/task-activation\//,
|
||||
/mode_parent\/task-modification\//,
|
||||
/mode_parent\/profile-button\//,
|
||||
/mode_parent\/user-profile\//,
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user