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

- 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:
2026-03-18 17:20:31 -04:00
parent a9131242a7
commit db6e0a7ce8
15 changed files with 998 additions and 21 deletions

View 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}"
}
]
}
]
}

View File

@@ -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}"
}
]
}

View File

@@ -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'

View File

@@ -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('46 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()
})
})

View File

@@ -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('46 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('46 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()
})
})

View File

@@ -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()
})

View File

@@ -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)

View 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()
})
})

View File

@@ -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/)
})
})

View File

@@ -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()
})
})

View 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)

View 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