From db6e0a7ce8d6518877cc51d16745112b4c02b767 Mon Sep 17 00:00:00 2001 From: Ryan Kegel Date: Wed, 18 Mar 2026 17:20:31 -0400 Subject: [PATCH] feat: Implement user profile and parent profile button tests - 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. --- .vscode/tasks.json | 21 ++ backend/api/user_api.py | 15 ++ .../vue-app/e2e/.auth/user-temp-parent.json | 35 +++ frontend/vue-app/e2e/.auth/user.json | 10 +- frontend/vue-app/e2e/e2e-constants.ts | 2 + .../profile-button-temporary.spec.ts | 25 ++ .../profile-button/profile-button.spec.ts | 86 +++++++ .../rewards/reward-convert-default.spec.ts | 18 +- .../mode_parent/tasks/penalty-default.spec.ts | 7 +- .../user-profile/change-pin.spec.ts | 181 ++++++++++++++ .../user-profile/delete-account.spec.ts | 116 +++++++++ .../user-profile/profile-editing.spec.ts | 235 ++++++++++++++++++ .../e2e/plans/parent-profile-button.plan.md | 65 +++++ .../vue-app/e2e/plans/user-profile.plan.md | 169 +++++++++++++ frontend/vue-app/playwright.config.ts | 34 +++ 15 files changed, 998 insertions(+), 21 deletions(-) create mode 100644 frontend/vue-app/e2e/.auth/user-temp-parent.json create mode 100644 frontend/vue-app/e2e/mode_parent/profile-button/profile-button-temporary.spec.ts create mode 100644 frontend/vue-app/e2e/mode_parent/profile-button/profile-button.spec.ts create mode 100644 frontend/vue-app/e2e/mode_parent/user-profile/change-pin.spec.ts create mode 100644 frontend/vue-app/e2e/mode_parent/user-profile/delete-account.spec.ts create mode 100644 frontend/vue-app/e2e/mode_parent/user-profile/profile-editing.spec.ts create mode 100644 frontend/vue-app/e2e/plans/parent-profile-button.plan.md create mode 100644 frontend/vue-app/e2e/plans/user-profile.plan.md diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 36fc65c..6f9e8dc 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -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" } ] } \ No newline at end of file diff --git a/backend/api/user_api.py b/backend/api/user_api.py index 8e9e06e..ea22049 100644 --- a/backend/api/user_api.py +++ b/backend/api/user_api.py @@ -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 diff --git a/frontend/vue-app/e2e/.auth/user-temp-parent.json b/frontend/vue-app/e2e/.auth/user-temp-parent.json new file mode 100644 index 0000000..5b53fc3 --- /dev/null +++ b/frontend/vue-app/e2e/.auth/user-temp-parent.json @@ -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}" + } + ] + } + ] +} \ No newline at end of file diff --git a/frontend/vue-app/e2e/.auth/user.json b/frontend/vue-app/e2e/.auth/user.json index 13971a4..a66f7a5 100644 --- a/frontend/vue-app/e2e/.auth/user.json +++ b/frontend/vue-app/e2e/.auth/user.json @@ -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}" } ] } diff --git a/frontend/vue-app/e2e/e2e-constants.ts b/frontend/vue-app/e2e/e2e-constants.ts index 1b86535..66edf02 100644 --- a/frontend/vue-app/e2e/e2e-constants.ts +++ b/frontend/vue-app/e2e/e2e-constants.ts @@ -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' diff --git a/frontend/vue-app/e2e/mode_parent/profile-button/profile-button-temporary.spec.ts b/frontend/vue-app/e2e/mode_parent/profile-button/profile-button-temporary.spec.ts new file mode 100644 index 0000000..74ff88d --- /dev/null +++ b/frontend/vue-app/e2e/mode_parent/profile-button/profile-button-temporary.spec.ts @@ -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() + }) +}) diff --git a/frontend/vue-app/e2e/mode_parent/profile-button/profile-button.spec.ts b/frontend/vue-app/e2e/mode_parent/profile-button/profile-button.spec.ts new file mode 100644 index 0000000..add0564 --- /dev/null +++ b/frontend/vue-app/e2e/mode_parent/profile-button/profile-button.spec.ts @@ -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() + }) +}) diff --git a/frontend/vue-app/e2e/mode_parent/rewards/reward-convert-default.spec.ts b/frontend/vue-app/e2e/mode_parent/rewards/reward-convert-default.spec.ts index 1a0e490..339bc04 100644 --- a/frontend/vue-app/e2e/mode_parent/rewards/reward-convert-default.spec.ts +++ b/frontend/vue-app/e2e/mode_parent/rewards/reward-convert-default.spec.ts @@ -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() }) diff --git a/frontend/vue-app/e2e/mode_parent/tasks/penalty-default.spec.ts b/frontend/vue-app/e2e/mode_parent/tasks/penalty-default.spec.ts index a06e77a..09eb826 100644 --- a/frontend/vue-app/e2e/mode_parent/tasks/penalty-default.spec.ts +++ b/frontend/vue-app/e2e/mode_parent/tasks/penalty-default.spec.ts @@ -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) diff --git a/frontend/vue-app/e2e/mode_parent/user-profile/change-pin.spec.ts b/frontend/vue-app/e2e/mode_parent/user-profile/change-pin.spec.ts new file mode 100644 index 0000000..6f5de95 --- /dev/null +++ b/frontend/vue-app/e2e/mode_parent/user-profile/change-pin.spec.ts @@ -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 { + // 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() + }) +}) diff --git a/frontend/vue-app/e2e/mode_parent/user-profile/delete-account.spec.ts b/frontend/vue-app/e2e/mode_parent/user-profile/delete-account.spec.ts new file mode 100644 index 0000000..b8a07c1 --- /dev/null +++ b/frontend/vue-app/e2e/mode_parent/user-profile/delete-account.spec.ts @@ -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/) + }) +}) diff --git a/frontend/vue-app/e2e/mode_parent/user-profile/profile-editing.spec.ts b/frontend/vue-app/e2e/mode_parent/user-profile/profile-editing.spec.ts new file mode 100644 index 0000000..731d90b --- /dev/null +++ b/frontend/vue-app/e2e/mode_parent/user-profile/profile-editing.spec.ts @@ -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 { + 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 { + 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() + }) +}) diff --git a/frontend/vue-app/e2e/plans/parent-profile-button.plan.md b/frontend/vue-app/e2e/plans/parent-profile-button.plan.md new file mode 100644 index 0000000..b22f2d5 --- /dev/null +++ b/frontend/vue-app/e2e/plans/parent-profile-button.plan.md @@ -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) diff --git a/frontend/vue-app/e2e/plans/user-profile.plan.md b/frontend/vue-app/e2e/plans/user-profile.plan.md new file mode 100644 index 0000000..6a21d0c --- /dev/null +++ b/frontend/vue-app/e2e/plans/user-profile.plan.md @@ -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 diff --git a/frontend/vue-app/playwright.config.ts b/frontend/vue-app/playwright.config.ts index 15359db..2b6c88d 100644 --- a/frontend/vue-app/playwright.config.ts +++ b/frontend/vue-app/playwright.config.ts @@ -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\//, ], },