diff --git a/frontend/tests/e2e/account-details.spec.ts b/frontend/tests/e2e/account-details.spec.ts index a30384d..2cd8abf 100644 --- a/frontend/tests/e2e/account-details.spec.ts +++ b/frontend/tests/e2e/account-details.spec.ts @@ -1,77 +1,40 @@ -import { test, expect, Page } from './fixtures'; +import { test, expect, uniqueEmail } from './fixtures'; -const PASSWORD = 'supersecret123'; - -const uniqueEmail = () => `details-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}@example.com`; - -// Same generous wait as the other account specs: registration is a bcrypt -// round-trip, not a render, and runs past Playwright's 5s default when the -// suite's workers all register at once. -async function registerCustomer(page: Page): Promise { - const email = uniqueEmail(); - await page.goto('/register'); - await page.getByRole('textbox', { name: 'Email' }).fill(email); - await page.getByRole('textbox', { name: 'First name' }).fill('Test'); - await page.getByRole('textbox', { name: 'Last name' }).fill('Customer'); - await page.getByLabel('Password').fill(PASSWORD); - await page.getByRole('button', { name: 'Create account' }).click(); - await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 }); - return email; -} - -const accountModal = (page: Page) => page.getByRole('dialog', { name: 'My Account' }); - -async function signIn(page: Page, email: string, password: string) { - await page.goto('/login'); - await page.getByRole('textbox', { name: 'Email' }).fill(email); - await page.getByLabel('Password').fill(password); - await page.getByRole('dialog', { name: 'Log in' }).getByRole('button', { name: 'Log in' }).click(); -} +const NEW_PASSWORD = 'a-brand-new-password'; test.describe('Managing your own details', () => { - test('saves a new name, and it survives a reload', async ({ page }) => { - await registerCustomer(page); - await page.goto('/account'); - - const modal = accountModal(page); - await modal.getByLabel('First name', { exact: true }).fill('Ada'); - await modal.getByLabel('Last name', { exact: true }).fill('Lovelace'); - await modal.getByRole('button', { name: 'Save name' }).click(); + test('saves a new name, and it survives a reload', async ({ page, customer, accountModal }) => { + await accountModal.open(); + await accountModal.saveName('Ada', 'Lovelace'); await expect(page.getByText('Name updated')).toBeVisible(); // Reloaded rather than re-read from component state, which would pass even // if nothing had been persisted. await page.reload(); - await expect(accountModal(page).getByLabel('First name', { exact: true })).toHaveValue('Ada'); - await expect(accountModal(page).getByLabel('Last name', { exact: true })).toHaveValue('Lovelace'); + await expect(accountModal.firstName).toHaveValue('Ada'); + await expect(accountModal.lastName).toHaveValue('Lovelace'); }); - test('refuses to save a blank name', async ({ page }) => { - await registerCustomer(page); - await page.goto('/account'); + test('refuses to save a blank name', async ({ customer, accountModal }) => { + await accountModal.open(); + await accountModal.saveName(''); - const modal = accountModal(page); - await modal.getByLabel('First name', { exact: true }).fill(''); - await modal.getByRole('button', { name: 'Save name' }).click(); - - await expect(modal.getByText('First name is required')).toBeVisible(); + await expect(accountModal.dialog.getByText('First name is required')).toBeVisible(); }); // The assertion that matters for a password change: not that the form said // something reassuring, but that the old credential has actually stopped // opening the account. - test('changes the password, leaving the old one dead and the new one working', async ({ page }) => { - const email = await registerCustomer(page); - const newPassword = 'a-brand-new-password'; - await page.goto('/account'); - - const modal = accountModal(page); - await modal.getByRole('button', { name: 'Change your password' }).click(); - await modal.getByLabel('Current password', { exact: true }).fill(PASSWORD); - await modal.getByLabel('New password', { exact: true }).fill(newPassword); - await modal.getByLabel('Confirm new password', { exact: true }).fill(newPassword); - await modal.getByRole('button', { name: 'Change password' }).click(); + test('changes the password, leaving the old one dead and the new one working', async ({ + page, + customer, + accountModal, + authModal, + header + }) => { + await accountModal.open(); + await accountModal.changePassword(customer.password, NEW_PASSWORD); await expect(page.getByText('Password changed. Other devices have been signed out.')).toBeVisible(); @@ -80,63 +43,53 @@ test.describe('Managing your own details', () => { // same modal is otherwise also a match. await expect(page.getByRole('button', { name: 'My Account', exact: true })).toBeVisible(); - // Logging out from inside the open modal, which is where the control lives. - await page.getByRole('button', { name: 'Log out' }).click(); + await accountModal.logOut(); await expect(page).toHaveURL(/\/$/, { timeout: 20000 }); - await signIn(page, email, PASSWORD); + await authModal.gotoLogIn(); + await authModal.logIn(customer.email, customer.password); await expect(page.getByText('invalid email or password')).toBeVisible(); - await signIn(page, email, newPassword); - await expect(page.getByRole('button', { name: 'My Account', exact: true })) - .toBeVisible({ timeout: 20000 }); + await authModal.logIn(customer.email, NEW_PASSWORD); + await header.waitForSignedIn(); }); - test('refuses a password change when the current password is wrong', async ({ page }) => { - await registerCustomer(page); - await page.goto('/account'); + test('refuses a password change when the current password is wrong', async ({ + customer, + accountModal + }) => { + await accountModal.open(); + await accountModal.changePassword('not-the-password', 'another-password'); - const modal = accountModal(page); - await modal.getByRole('button', { name: 'Change your password' }).click(); - await modal.getByLabel('Current password', { exact: true }).fill('not-the-password'); - await modal.getByLabel('New password', { exact: true }).fill('another-password'); - await modal.getByLabel('Confirm new password', { exact: true }).fill('another-password'); - await modal.getByRole('button', { name: 'Change password' }).click(); - - await expect(modal.getByText('current password is incorrect')).toBeVisible(); + await expect(accountModal.dialog.getByText('current password is incorrect')).toBeVisible(); }); - test('changes the email address and marks it unverified again', async ({ page }) => { - await registerCustomer(page); + test('changes the email address and marks it unverified again', async ({ + page, + customer, + accountModal + }) => { const nextEmail = uniqueEmail(); - await page.goto('/account'); - - const modal = accountModal(page); - await modal.getByRole('button', { name: 'Change your email address' }).click(); - await modal.getByLabel('New email address', { exact: true }).fill(nextEmail); - await modal.getByLabel('Your password', { exact: true }).fill(PASSWORD); - await modal.getByRole('button', { name: 'Change email' }).click(); + await accountModal.open(); + await accountModal.changeEmail(nextEmail, customer.password); await expect(page.getByText('Check the new address for a verification link.')).toBeVisible(); - await expect(modal).toContainText(nextEmail); + await expect(accountModal.dialog).toContainText(nextEmail); // A changed address is unverified by definition, and the account view has // to say so or the customer has no way to know a link is waiting. - await expect(modal.getByText('Email not verified')).toBeVisible(); + await expect(accountModal.notVerifiedNotice).toBeVisible(); }); // A live session is not enough to move the address a password reset goes to, // which is the whole reason the field is there. - test('refuses an email change when the password is wrong, and keeps the old address', async ({ page }) => { - const email = await registerCustomer(page); - await page.goto('/account'); + test('refuses an email change when the password is wrong, and keeps the old address', async ({ + customer, + accountModal + }) => { + await accountModal.open(); + await accountModal.changeEmail(uniqueEmail(), 'not-the-password'); - const modal = accountModal(page); - await modal.getByRole('button', { name: 'Change your email address' }).click(); - await modal.getByLabel('New email address', { exact: true }).fill(uniqueEmail()); - await modal.getByLabel('Your password', { exact: true }).fill('not-the-password'); - await modal.getByRole('button', { name: 'Change email' }).click(); - - await expect(modal.getByText('current password is incorrect')).toBeVisible(); - await expect(modal).toContainText(email); + await expect(accountModal.dialog.getByText('current password is incorrect')).toBeVisible(); + await expect(accountModal.dialog).toContainText(customer.email); }); }); diff --git a/frontend/tests/e2e/account-modal.spec.ts b/frontend/tests/e2e/account-modal.spec.ts index 0b277cb..34327ba 100644 --- a/frontend/tests/e2e/account-modal.spec.ts +++ b/frontend/tests/e2e/account-modal.spec.ts @@ -1,136 +1,116 @@ -import { test, expect, Page } from './fixtures'; - -const PASSWORD = 'supersecret123'; - -const uniqueEmail = () => `account-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}@example.com`; - -// Registering closes the auth modal and returns to the storefront, signed in. -// Returns the address so a test can assert the right account is shown. -// -// The wait is generous because registration is a bcrypt round-trip rather than -// a render: about half a second unloaded, and past Playwright's 5s default when -// the suite's workers all register at once. -async function registerCustomer(page: Page): Promise { - const email = uniqueEmail(); - await page.goto('/register'); - await page.getByRole('textbox', { name: 'Email' }).fill(email); - await page.getByRole('textbox', { name: 'First name' }).fill('Test'); - await page.getByRole('textbox', { name: 'Last name' }).fill('Customer'); - await page.getByLabel('Password').fill(PASSWORD); - await page.getByRole('button', { name: 'Create account' }).click(); - await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 }); - return email; -} - -const accountModal = (page: Page) => page.getByRole('dialog', { name: 'My Account' }); - -async function closeAccount(page: Page) { - await accountModal(page).getByRole('button', { name: 'Close' }).click(); - await expect(accountModal(page)).toBeHidden(); -} +import { test, expect } from './fixtures'; test.describe('My Account opens as a modal', () => { - test('opens over the storefront and closes back to it, filters and all', async ({ page }) => { - await registerCustomer(page); - + test('opens over the storefront and closes back to it, filters and all', async ({ + page, + customer, + accountModal, + header + }) => { // A filtered view, to prove closing restores where the customer actually // was rather than a bare storefront. await page.goto('/?max_price=50000'); - await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible(); + await header.waitForSignedIn(); - await page.getByRole('button', { name: 'My Account' }).click(); - await expect(accountModal(page)).toBeVisible(); + await accountModal.openFromHeader(); // Still a real, linkable URL rather than hidden view state. await expect(page).toHaveURL(/\/account/); - await closeAccount(page); + await accountModal.close(); await expect(page).toHaveURL(/max_price=50000/); }); - test('the browser back button closes it, the same as the close control', async ({ page }) => { - await registerCustomer(page); - + test('the browser back button closes it, the same as the close control', async ({ + page, + customer, + accountModal, + header + }) => { await page.goto('/?max_price=50000'); - await page.getByRole('button', { name: 'My Account' }).click(); - await expect(accountModal(page)).toBeVisible(); + await header.waitForSignedIn(); + await accountModal.openFromHeader(); await page.goBack(); - await expect(accountModal(page)).toBeHidden(); + await expect(accountModal.dialog).toBeHidden(); await expect(page).toHaveURL(/max_price=50000/); }); - test('a direct visit renders the storefront behind it, so closing lands somewhere real', async ({ page }) => { - await registerCustomer(page); - + test('a direct visit renders the storefront behind it, so closing lands somewhere real', async ({ + page, + customer, + accountModal, + header + }) => { // A bookmark, or the link in a verification email. There is no page behind // in this case, which is what used to make /account a dead end. - await page.goto('/account'); + await accountModal.open(); - await expect(accountModal(page)).toBeVisible(); - await expect(page.getByRole('heading', { name: 'Redefined Designs' })).toBeVisible(); + await expect(header.siteTitle).toBeVisible(); - await closeAccount(page); + await accountModal.close(); await expect(page).toHaveURL(/\/$/); - await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible(); + await expect(header.myAccountButton).toBeVisible(); }); - test('survives a reload, since it is a route rather than view state', async ({ page }) => { - const email = await registerCustomer(page); - + test('survives a reload, since it is a route rather than view state', async ({ + page, + customer, + accountModal, + header + }) => { await page.goto('/?max_price=50000'); - await page.getByRole('button', { name: 'My Account' }).click(); - await expect(accountModal(page)).toBeVisible(); + await header.waitForSignedIn(); + await accountModal.openFromHeader(); await page.reload(); - await expect(accountModal(page)).toBeVisible(); - await expect(accountModal(page)).toContainText(email); + await expect(accountModal.dialog).toBeVisible(); + await expect(accountModal.dialog).toContainText(customer.email); }); - test('shows the signed-in account and its settings', async ({ page }) => { - const email = await registerCustomer(page); + test('shows the signed-in account and its settings', async ({ customer, accountModal }) => { + await accountModal.open(); - await page.goto('/account'); - - const modal = accountModal(page); - await expect(modal).toContainText(email); + await expect(accountModal.dialog).toContainText(customer.email); // The orders table lives at /orders now. What the account view still owes // the customer is a way to reach it. - await expect(modal.getByRole('button', { name: 'View order history' })).toBeVisible(); + await expect(accountModal.orderHistoryButton).toBeVisible(); // Scoped to the modal: the storefront behind it has a theme switch of its // own, so an unscoped switch locator would be ambiguous. - await expect(modal.getByRole('switch')).toHaveCount(2); + await expect(accountModal.themeSwitches).toHaveCount(2); }); - test('deleting the account does not leave the page behind it looking signed in', async ({ page }) => { - await registerCustomer(page); - await page.goto('/account'); - - await accountModal(page).getByRole('button', { name: 'Delete my account' }).click(); - await page.getByRole('dialog', { name: 'Delete your account?' }) - .getByRole('button', { name: 'Delete my account' }).click(); + test('deleting the account does not leave the page behind it looking signed in', async ({ + page, + customer, + accountModal, + header + }) => { + await accountModal.open(); + await accountModal.deleteAccount(); // The storefront is rendered behind the modal, so a session left in place // would visibly go on offering My Account for an account that is gone. - await expect(page.getByRole('button', { name: 'Sign up' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'My Account' })).toBeHidden(); + await expect(header.signUpButton).toBeVisible(); + await expect(header.myAccountButton).toBeHidden(); await expect(page).toHaveURL(/\/$/); }); - test('stays usable on a phone, with the close control in reach', async ({ page }) => { - await registerCustomer(page); + test('stays usable on a phone, with the close control in reach', async ({ + page, + customer, + accountModal + }) => { await page.setViewportSize({ width: 390, height: 664 }); - await page.goto('/account'); + await accountModal.open(); - const modal = accountModal(page); - await expect(modal).toBeVisible(); // The view is taller than the viewport, so the body scrolls rather than // pushing the title and close control off-screen. - await expect(modal.getByRole('button', { name: 'Close' })).toBeInViewport(); - await expect(modal.getByRole('button', { name: 'View order history' })).toBeVisible(); + await expect(accountModal.closeButton).toBeInViewport(); + await expect(accountModal.orderHistoryButton).toBeVisible(); - await closeAccount(page); + await accountModal.close(); await expect(page).toHaveURL(/\/$/); }); }); diff --git a/frontend/tests/e2e/pages/AccountModal.ts b/frontend/tests/e2e/pages/AccountModal.ts index 7a6b1e0..63c8002 100644 --- a/frontend/tests/e2e/pages/AccountModal.ts +++ b/frontend/tests/e2e/pages/AccountModal.ts @@ -4,9 +4,11 @@ import { Locator, Page, expect } from '@playwright/test'; * The customer's own account view, which is a modal over the storefront rather * than a page of its own — /account renders the storefront with this on top. * - * Fields are scoped to the dialog. Several of them ("Password", "First name") - * share a label with the auth modal, and the two can both be in the DOM while - * one is closing. + * Everything is scoped to the dialog. Several controls have a twin on the page + * behind it: the storefront has its own theme switch, and "Password" and "First + * name" share a label with the auth modal, which can be in the DOM while one of + * the two is closing. An unscoped locator matches both and fails on strict mode, + * which is a confusing way to learn that a modal is a modal. */ export class AccountModal { readonly dialog: Locator; @@ -14,17 +16,26 @@ export class AccountModal { readonly orderHistoryButton: Locator; readonly closeButton: Locator; readonly resendVerificationButton: Locator; + readonly themeSwitches: Locator; + readonly notVerifiedNotice: Locator; readonly firstName: Locator; readonly lastName: Locator; readonly saveNameButton: Locator; + readonly changePasswordDisclosure: Locator; readonly currentPassword: Locator; readonly newPassword: Locator; readonly confirmNewPassword: Locator; + readonly submitPasswordChangeButton: Locator; + readonly changeEmailDisclosure: Locator; readonly newEmail: Locator; readonly passwordForEmailChange: Locator; + readonly submitEmailChangeButton: Locator; + + readonly deleteAccountButton: Locator; + readonly confirmDeleteDialog: Locator; constructor(private readonly page: Page) { this.dialog = page.getByRole('dialog', { name: 'My Account' }); @@ -32,17 +43,26 @@ export class AccountModal { this.orderHistoryButton = this.dialog.getByRole('button', { name: 'View order history' }); this.closeButton = this.dialog.getByRole('button', { name: 'Close' }); this.resendVerificationButton = this.dialog.getByRole('button', { name: 'Send it again' }); + this.themeSwitches = this.dialog.getByRole('switch'); + this.notVerifiedNotice = this.dialog.getByText('Email not verified'); this.firstName = this.dialog.getByLabel('First name', { exact: true }); this.lastName = this.dialog.getByLabel('Last name', { exact: true }); this.saveNameButton = this.dialog.getByRole('button', { name: 'Save name' }); + this.changePasswordDisclosure = this.dialog.getByRole('button', { name: 'Change your password' }); this.currentPassword = this.dialog.getByLabel('Current password', { exact: true }); this.newPassword = this.dialog.getByLabel('New password', { exact: true }); this.confirmNewPassword = this.dialog.getByLabel('Confirm new password', { exact: true }); + this.submitPasswordChangeButton = this.dialog.getByRole('button', { name: 'Change password' }); + this.changeEmailDisclosure = this.dialog.getByRole('button', { name: 'Change your email address' }); this.newEmail = this.dialog.getByLabel('New email address', { exact: true }); this.passwordForEmailChange = this.dialog.getByLabel('Your password', { exact: true }); + this.submitEmailChangeButton = this.dialog.getByRole('button', { name: 'Change email' }); + + this.deleteAccountButton = this.dialog.getByRole('button', { name: 'Delete my account' }); + this.confirmDeleteDialog = page.getByRole('dialog', { name: 'Delete your account?' }); } /** @@ -51,10 +71,26 @@ export class AccountModal { * The wait is the action's contract rather than an assertion: everything a * caller does next is scoped to this dialog, and a locator resolved before it * exists finds nothing. + * + * The timeout is generous for the same reason the header's is. Arriving here + * means booting the app and resolving the session against the server, and the + * 5s default is comfortably beaten on an idle machine and missed on a loaded + * one — the recipe for a test that fails only when the suite is busy. */ async open(): Promise { await this.page.goto('/account'); - await expect(this.dialog).toBeVisible(); + await expect(this.dialog).toBeVisible({ timeout: 20000 }); + } + + /** Opens it from the header, from wherever the customer was browsing. */ + async openFromHeader(): Promise { + await this.page.getByRole('button', { name: 'My Account' }).click(); + await expect(this.dialog).toBeVisible({ timeout: 20000 }); + } + + async close(): Promise { + await this.closeButton.click(); + await expect(this.dialog).toBeHidden(); } async logOut(): Promise { @@ -75,6 +111,33 @@ export class AccountModal { await this.logOut(); } + async saveName(firstName: string, lastName?: string): Promise { + await this.firstName.fill(firstName); + if (lastName !== undefined) await this.lastName.fill(lastName); + await this.saveNameButton.click(); + } + + /** Both password fields sit behind a disclosure, so it has to be opened first. */ + async changePassword(current: string, next: string): Promise { + await this.changePasswordDisclosure.click(); + await this.currentPassword.fill(current); + await this.newPassword.fill(next); + await this.confirmNewPassword.fill(next); + await this.submitPasswordChangeButton.click(); + } + + async changeEmail(newEmail: string, password: string): Promise { + await this.changeEmailDisclosure.click(); + await this.newEmail.fill(newEmail); + await this.passwordForEmailChange.fill(password); + await this.submitEmailChangeButton.click(); + } + + async deleteAccount(): Promise { + await this.deleteAccountButton.click(); + await this.confirmDeleteDialog.getByRole('button', { name: 'Delete my account' }).click(); + } + /** The address the account view shows, which is how a test knows whose it is. */ emailText(email: string): Locator { return this.dialog.getByText(email);