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