diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index bcc3fc0..d2e440b 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -133,6 +133,14 @@ export default tseslint.config( // Page objects hold locators built in the constructor and never // reassigned. Flagging them as mutable props does not apply to a class. 'sonarjs/prefer-read-only-props': 'off', + + // Requesting a Playwright fixture IS using it — destructuring `customer` + // is what makes the account exist, whether or not the body then reads the + // address. The linter cannot see that side effect and reports every such + // fixture as an unused variable. Left on, the alternative is a `void + // customer;` line in each test, which is noise standing in for a comment. + // Variables are still checked; only parameters are exempt. + '@typescript-eslint/no-unused-vars': ['warn', { args: 'none' }], }, } ); diff --git a/frontend/tests/e2e/auth.spec.ts b/frontend/tests/e2e/auth.spec.ts index 3beb5e4..b5c7335 100755 --- a/frontend/tests/e2e/auth.spec.ts +++ b/frontend/tests/e2e/auth.spec.ts @@ -1,56 +1,27 @@ -import { test, expect, Page } from './fixtures'; - -const PASSWORD = 'supersecret123'; - -function uniqueEmail(): string { - return `playwright-${Date.now()}-${Math.floor(Math.random() * 10000)}@example.com`; -} - -// Registering closes the auth modal and returns the customer to the page behind -// it — the storefront, for a direct visit to /register — rather than navigating -// to /account. So the header, not the URL, is what proves the session exists. -// -// The wait is generous because this is a bcrypt round-trip rather than a render, -// and the suite's workers all register at once. -async function register(page: Page, email: string) { - 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 }); -} - -// Log out lives inside the account view, which is a modal over the storefront. -async function openAccount(page: Page) { - await page.goto('/account'); - await expect(page.getByRole('dialog', { name: 'My Account' })).toBeVisible(); -} +import { test, expect, PASSWORD, uniqueEmail } from './fixtures'; test.describe('Customer accounts', () => { // Both names are required from anyone new so every email has a first name to // greet with (#106). The server refuses without them; this is the form // refusing first, so nobody gets a round trip to find out. - test('will not submit a registration without both names', async ({ page }) => { - await page.goto('/register'); - await page.getByRole('textbox', { name: 'Email' }).fill(uniqueEmail()); - await page.getByLabel('Password').fill(PASSWORD); - await page.getByRole('button', { name: 'Create account' }).click(); + test('will not submit a registration without both names', async ({ authModal, header }) => { + await authModal.gotoRegister(); + await authModal.fillRegistration({ email: uniqueEmail(), password: PASSWORD }); + await authModal.submitRegistration(); - await expect(page.getByText('First name is required')).toBeVisible(); - await expect(page.getByText('Last name is required')).toBeVisible(); + await expect(authModal.registerDialog.getByText('First name is required')).toBeVisible(); + await expect(authModal.registerDialog.getByText('Last name is required')).toBeVisible(); // Still on the form rather than signed in. - await expect(page.getByRole('button', { name: 'My Account' })).toHaveCount(0); + await expect(header.myAccountButton).toHaveCount(0); }); - test('marketing consent checkbox is unchecked by default', async ({ page }) => { - await page.goto('/register'); - await expect(page.getByRole('checkbox')).not.toBeChecked(); + test('marketing consent checkbox is unchecked by default', async ({ authModal }) => { + await authModal.gotoRegister(); + await expect(authModal.marketingConsent).not.toBeChecked(); }); - test('the consent label is the exact wording the server records', async ({ page }) => { - await page.goto('/register'); + test('the consent label is the exact wording the server records', async ({ authModal }) => { + await authModal.gotoRegister(); // The stored consent text is kept verbatim so the record says what the // customer actually saw. Three different wordings were in circulation @@ -58,83 +29,95 @@ test.describe('Customer accounts', () => { // prompt, and none of them matched what was stored. const consent = 'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.'; - await expect(page.getByRole('dialog', { name: 'Create an account' })).toContainText(consent); + await expect(authModal.registerDialog).toContainText(consent); }); - test('registering signs the customer in and returns them where they were', async ({ page }) => { + // Drives the form rather than taking the `customer` fixture: this test is + // about registering, so the thing under test has to be the thing exercised. + test('registering signs the customer in and returns them where they were', async ({ + page, + authModal, + header, + accountModal + }) => { const email = uniqueEmail(); - await register(page, email); + await authModal.gotoRegister(); + await authModal.fillRegistration({ + email, + password: PASSWORD, + firstName: 'Test', + lastName: 'Customer' + }); + await authModal.submitRegistration(); + await header.waitForSignedIn(); // Back on the storefront, signed in — not moved to the account page. await expect(page).toHaveURL(/\/$/); - await openAccount(page); - await expect(page.getByText(email)).toBeVisible(); + await accountModal.open(); + await expect(accountModal.emailText(email)).toBeVisible(); }); - test('rejects login with the wrong password', async ({ page }) => { - const email = uniqueEmail(); - await register(page, email); - await openAccount(page); - await page.getByRole('button', { name: 'Log out' }).click(); + test('rejects login with the wrong password', async ({ page, customer, accountModal, authModal, header }) => { + await accountModal.openAndLogOut(); + await expect(header.logInButton).toBeVisible(); - await page.goto('/login'); - await page.getByRole('textbox', { name: 'Email' }).fill(email); - await page.getByLabel('Password').fill('wrong-password'); - // Scoped to the modal: the storefront rendered behind it has a "Log in" - // button of its own, which is what opened this one. - await page.getByRole('dialog', { name: 'Log in' }).getByRole('button', { name: 'Log in' }).click(); + await authModal.gotoLogIn(); + await authModal.logIn(customer.email, 'wrong-password'); await expect(page.getByText('invalid email or password')).toBeVisible(); }); - test('logging out returns to the home page and resets the header', async ({ page }) => { - await register(page, uniqueEmail()); - await openAccount(page); - - await page.getByRole('button', { name: 'Log out' }).click(); + test('logging out returns to the home page and resets the header', async ({ + page, + customer, + accountModal, + header + }) => { + await accountModal.openAndLogOut(); // A server round-trip followed by re-rendering the storefront behind the // modal, so the 5s default is too tight when workers run concurrently. await expect(page).toHaveURL(/\/$/, { timeout: 20000 }); - await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'Sign up' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'My Account' })).toBeHidden(); + await expect(header.logInButton).toBeVisible(); + await expect(header.signUpButton).toBeVisible(); + await expect(header.myAccountButton).toBeHidden(); }); - test('the logged-out header survives a reload', async ({ page }) => { - await register(page, uniqueEmail()); - await openAccount(page); - - await page.getByRole('button', { name: 'Log out' }).click(); - await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible(); + test('the logged-out header survives a reload', async ({ page, customer, accountModal, header }) => { + await accountModal.openAndLogOut(); + await expect(header.logInButton).toBeVisible(); // Proves the server session was actually destroyed, rather than the header // merely being repainted from stale client state. await page.reload(); - await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'My Account' })).toBeHidden(); + await expect(header.logInButton).toBeVisible(); + await expect(header.myAccountButton).toBeHidden(); }); - test('logging out does not leave the account page on the back stack', async ({ page }) => { - await register(page, uniqueEmail()); - await openAccount(page); - - await page.getByRole('button', { name: 'Log out' }).click(); + test('logging out does not leave the account page on the back stack', async ({ + page, + customer, + accountModal + }) => { + await accountModal.openAndLogOut(); await expect(page).toHaveURL(/\/$/, { timeout: 20000 }); await page.goBack(); await expect(page).not.toHaveURL(/\/account/); }); - test('a failed logout says so instead of appearing to succeed', async ({ page }) => { - await register(page, uniqueEmail()); - await openAccount(page); + test('a failed logout says so instead of appearing to succeed', async ({ + page, + customer, + accountModal + }) => { + await accountModal.open(); await page.route('**/api/customers/logout', (route) => route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"internal error"}' }) ); - await page.getByRole('button', { name: 'Log out' }).click(); + await accountModal.logOut(); // The session cookie is still valid, so pretending to be logged out would // silently log the customer back in on their next reload. @@ -144,69 +127,82 @@ test.describe('Customer accounts', () => { }); test.describe('Auth routes are not dead ends', () => { - test('opening Log in from the header closes back to where browsing left off', async ({ page }) => { + test('opening Log in from the header closes back to where browsing left off', async ({ + page, + header, + authModal + }) => { await page.goto('/?max_price=50000'); - await page.getByRole('button', { name: 'Log in' }).click(); + await header.logInButton.click(); - const modal = page.getByRole('dialog', { name: 'Log in' }); - await expect(modal).toBeVisible(); + await expect(authModal.logInDialog).toBeVisible(); await expect(page).toHaveURL(/\/login/); - await modal.getByRole('button', { name: 'Close' }).click(); + await authModal.closeButton.click(); - await expect(modal).toBeHidden(); + await expect(authModal.logInDialog).toBeHidden(); await expect(page).toHaveURL(/max_price=50000/); }); - test('a direct visit opens over the storefront rather than a blank page', async ({ page }) => { - await page.goto('/login'); + test('a direct visit opens over the storefront rather than a blank page', async ({ + authModal, + header + }) => { + await authModal.gotoLogIn(); - await expect(page.getByRole('dialog', { name: 'Log in' })).toBeVisible(); - await expect(page.getByRole('heading', { name: 'Redefined Designs' })).toBeVisible(); + await expect(authModal.logInDialog).toBeVisible(); + await expect(header.siteTitle).toBeVisible(); }); - test('switching between sign in and sign up keeps one history entry', async ({ page }) => { + test('switching between sign in and sign up keeps one history entry', async ({ + page, + header, + authModal + }) => { await page.goto('/?max_price=50000'); - await page.getByRole('button', { name: 'Log in' }).click(); + await header.logInButton.click(); - await page.getByRole('tab', { name: 'Create Account' }).click(); + await authModal.createAccountTab.click(); await expect(page).toHaveURL(/\/register/); - await page.getByRole('tab', { name: 'Log In' }).click(); + await authModal.logInTab.click(); await expect(page).toHaveURL(/\/login/); // Back returns to browsing rather than walking through each tab visited. await page.goBack(); await expect(page).toHaveURL(/max_price=50000/); - await expect(page.getByRole('dialog', { name: 'Log in' })).toBeHidden(); + await expect(authModal.logInDialog).toBeHidden(); }); - test('signing in from the header returns to the page behind, signed in', async ({ page }) => { - const email = uniqueEmail(); - await register(page, email); - await page.goto('/account'); - await page.getByRole('button', { name: 'Log out' }).click(); - await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible(); + test('signing in from the header returns to the page behind, signed in', async ({ + page, + customer, + accountModal, + header, + authModal + }) => { + await accountModal.openAndLogOut(); + await expect(header.logInButton).toBeVisible(); await page.goto('/?max_price=50000'); - await page.getByRole('button', { name: 'Log in' }).click(); - const modal = page.getByRole('dialog', { name: 'Log in' }); - await modal.getByRole('textbox', { name: 'Email' }).fill(email); - await modal.getByLabel('Password').fill(PASSWORD); - await modal.getByRole('button', { name: 'Log in' }).click(); + await header.logInButton.click(); + await authModal.logIn(customer.email, customer.password); - await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 }); + await header.waitForSignedIn(); await expect(page).toHaveURL(/max_price=50000/); }); - test('reaching password recovery from the login form keeps a way back', async ({ page }) => { - await page.goto('/login'); - await page.getByRole('button', { name: 'Forgot password?' }).click(); + test('reaching password recovery from the login form keeps a way back', async ({ + page, + authModal, + passwordReset + }) => { + await authModal.gotoLogIn(); + await authModal.forgotPasswordButton.click(); - const modal = page.getByRole('dialog', { name: 'Reset your password' }); - await expect(modal).toBeVisible(); + await expect(passwordReset.requestDialog).toBeVisible(); await expect(page).toHaveURL(/\/forgot-password/); - await modal.getByRole('button', { name: 'Sign in' }).click(); - await expect(page.getByRole('dialog', { name: 'Log in' })).toBeVisible(); + await passwordReset.signInButton.click(); + await expect(authModal.logInDialog).toBeVisible(); }); }); diff --git a/frontend/tests/e2e/fixtures.ts b/frontend/tests/e2e/fixtures.ts index 285334e..653ebed 100644 --- a/frontend/tests/e2e/fixtures.ts +++ b/frontend/tests/e2e/fixtures.ts @@ -7,6 +7,7 @@ import { AuthModal } from './pages/AuthModal'; import { AccountModal } from './pages/AccountModal'; import { StorefrontPage } from './pages/StorefrontPage'; import { AdminPage } from './pages/AdminPage'; +import { PasswordResetPages } from './pages/PasswordResetPages'; import { uniqueEmail } from './support/api'; // Re-exported so specs can import everything from here — expect, Page, @@ -38,6 +39,7 @@ interface Pages { accountModal: AccountModal; storefront: StorefrontPage; admin: AdminPage; + passwordReset: PasswordResetPages; } interface Data { @@ -88,6 +90,9 @@ export const test = base.extend({ admin: async ({ page }, use) => { await use(new AdminPage(page)); }, + passwordReset: async ({ page }, use) => { + await use(new PasswordResetPages(page)); + }, adminApi: async ({ playwright, baseURL }, use) => { const context = await playwright.request.newContext({ baseURL }); diff --git a/frontend/tests/e2e/pages/AccountModal.ts b/frontend/tests/e2e/pages/AccountModal.ts index ef3194c..7a6b1e0 100644 --- a/frontend/tests/e2e/pages/AccountModal.ts +++ b/frontend/tests/e2e/pages/AccountModal.ts @@ -13,6 +13,7 @@ export class AccountModal { readonly logOutButton: Locator; readonly orderHistoryButton: Locator; readonly closeButton: Locator; + readonly resendVerificationButton: Locator; readonly firstName: Locator; readonly lastName: Locator; @@ -30,6 +31,7 @@ export class AccountModal { this.logOutButton = this.dialog.getByRole('button', { name: 'Log out' }); 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.firstName = this.dialog.getByLabel('First name', { exact: true }); this.lastName = this.dialog.getByLabel('Last name', { exact: true }); @@ -59,6 +61,20 @@ export class AccountModal { await this.logOutButton.click(); } + /** + * Opens the account view and logs out, which is the only route to signing out + * — there is no header control for it. + * + * Does not wait for the result. Several tests assert different things about + * what logging out does: the URL it lands on, the header it leaves behind, a + * failure it reports. Waiting here would make one of those the action's + * contract and quietly weaken the others. + */ + async openAndLogOut(): Promise { + await this.open(); + await this.logOut(); + } + /** The address the account view shows, which is how a test knows whose it is. */ emailText(email: string): Locator { return this.dialog.getByText(email); diff --git a/frontend/tests/e2e/pages/AuthModal.ts b/frontend/tests/e2e/pages/AuthModal.ts index f32d176..9963545 100644 --- a/frontend/tests/e2e/pages/AuthModal.ts +++ b/frontend/tests/e2e/pages/AuthModal.ts @@ -23,6 +23,9 @@ export class AuthModal { readonly password: Locator; readonly marketingConsent: Locator; readonly createAccountButton: Locator; + readonly createAccountTab: Locator; + readonly logInTab: Locator; + readonly forgotPasswordButton: Locator; constructor(private readonly page: Page) { this.registerDialog = page.getByRole('dialog', { name: 'Create an account' }); @@ -33,6 +36,9 @@ export class AuthModal { this.password = page.getByLabel('Password'); this.marketingConsent = page.getByRole('checkbox'); this.createAccountButton = page.getByRole('button', { name: 'Create account' }); + this.createAccountTab = page.getByRole('tab', { name: 'Create Account' }); + this.logInTab = page.getByRole('tab', { name: 'Log In' }); + this.forgotPasswordButton = page.getByRole('button', { name: 'Forgot password?' }); } /** The submit button inside the log in dialog, not the one that opened it. */ @@ -40,6 +46,24 @@ export class AuthModal { return this.logInDialog.getByRole('button', { name: 'Log in' }); } + /** Closes whichever of the two dialogs is open. */ + get closeButton(): Locator { + return this.page.getByRole('dialog').getByRole('button', { name: 'Close' }); + } + + /** + * Signs in through the modal, wherever it was opened from. + * + * Scoped to the dialog throughout: the storefront behind it has its own + * "Log in" button — the one that opened this — and an unscoped fill or click + * matches both. + */ + async logIn(email: string, password: string): Promise { + await this.logInDialog.getByRole('textbox', { name: 'Email' }).fill(email); + await this.logInDialog.getByLabel('Password').fill(password); + await this.submitLogInButton.click(); + } + async gotoRegister(): Promise { await this.page.goto('/register'); } diff --git a/frontend/tests/e2e/pages/Header.ts b/frontend/tests/e2e/pages/Header.ts index 4e395c1..e9c2315 100644 --- a/frontend/tests/e2e/pages/Header.ts +++ b/frontend/tests/e2e/pages/Header.ts @@ -12,6 +12,7 @@ export class Header { readonly siteTitle: Locator; readonly myAccountButton: Locator; readonly logInButton: Locator; + readonly signUpButton: Locator; readonly cartButton: Locator; readonly themeToggle: Locator; @@ -19,6 +20,7 @@ export class Header { this.siteTitle = page.getByRole('heading', { name: 'Redefined Designs' }); this.myAccountButton = page.getByRole('button', { name: 'My Account' }); this.logInButton = page.getByRole('button', { name: 'Log in' }); + this.signUpButton = page.getByRole('button', { name: 'Sign up' }); this.cartButton = page.getByRole('button', { name: /Cart/ }); this.themeToggle = page.getByRole('switch'); } diff --git a/frontend/tests/e2e/pages/PasswordResetPages.ts b/frontend/tests/e2e/pages/PasswordResetPages.ts new file mode 100644 index 0000000..1eb7560 --- /dev/null +++ b/frontend/tests/e2e/pages/PasswordResetPages.ts @@ -0,0 +1,57 @@ +import { Locator, Page, expect } from '@playwright/test'; + +/** + * The two halves of password recovery: asking for a link, and using one. + * + * They are one object because they are one flow and a test usually crosses + * between them. Requesting is a modal reached from the login form; setting the + * new password is a route the customer arrives at from their email, with no + * page behind it to return to. + */ +export class PasswordResetPages { + readonly requestDialog: Locator; + readonly email: Locator; + readonly sendResetLinkButton: Locator; + readonly signInButton: Locator; + + readonly newPassword: Locator; + readonly confirmNewPassword: Locator; + readonly setNewPasswordButton: Locator; + + constructor(private readonly page: Page) { + this.requestDialog = page.getByRole('dialog', { name: 'Reset your password' }); + this.email = page.getByRole('textbox', { name: 'Email' }); + this.sendResetLinkButton = page.getByRole('button', { name: 'Send reset link' }); + this.signInButton = this.requestDialog.getByRole('button', { name: 'Sign in' }); + + this.newPassword = page.getByLabel('New password', { exact: true }); + this.confirmNewPassword = page.getByLabel('Confirm new password'); + this.setNewPasswordButton = page.getByRole('button', { name: 'Set new password' }); + } + + async gotoRequest(): Promise { + await this.page.goto('/forgot-password'); + } + + /** + * Opens the reset form, with or without a token. + * + * Omitting it is a real case rather than a degenerate one: a link that lost + * its token has to explain itself instead of failing on submit. + */ + async gotoReset(token?: string): Promise { + await this.page.goto(token ? `/reset-password?token=${token}` : '/reset-password'); + } + + async setNewPassword(password: string, confirmation = password): Promise { + await this.newPassword.fill(password); + await this.confirmNewPassword.fill(confirmation); + await this.setNewPasswordButton.click(); + } + + async requestLinkFor(email: string): Promise { + await this.email.fill(email); + await this.sendResetLinkButton.click(); + await expect(this.page.getByText('Check your email')).toBeVisible(); + } +} diff --git a/frontend/tests/e2e/password-reset.spec.ts b/frontend/tests/e2e/password-reset.spec.ts index ada1d2c..bfaf10f 100644 --- a/frontend/tests/e2e/password-reset.spec.ts +++ b/frontend/tests/e2e/password-reset.spec.ts @@ -1,163 +1,121 @@ -import { test, expect, Page } from './fixtures'; -import { Client } from 'pg'; +import { test, expect } from './fixtures'; +import { readPasswordResetToken } from './support/db'; -const PASSWORD = 'supersecret123'; const NEW_PASSWORD = 'a-brand-new-password'; -const uniqueEmail = () => `reset-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}@example.com`; - -async function register(page: Page, email: string) { - 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(); - // Registering now closes the auth modal and returns to the page behind it, so - // the header rather than the URL is what proves the session exists. The wait - // is generous because this is a bcrypt round-trip rather than a render. - await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 }); -} - -// Log out lives inside the account view, which is a modal over the storefront. -async function logout(page: Page) { - await page.goto('/account'); - await page.getByRole('dialog', { name: 'My Account' }) - .getByRole('button', { name: 'Log out' }).click(); - // A server round-trip followed by re-rendering the storefront behind the - // modal, so the 5s default is too tight when workers run concurrently. - await expect(page).toHaveURL(/\/$/, { timeout: 20000 }); -} - test.describe('Password reset', () => { - test('the login page offers a way to recover a forgotten password', async ({ page }) => { - await page.goto('/login'); - // Recovery is now reached from the login modal rather than a link on a - // page, and its title is the modal's rather than a heading. - await page.getByRole('button', { name: 'Forgot password?' }).click(); + test('the login page offers a way to recover a forgotten password', async ({ + page, + authModal, + passwordReset + }) => { + await authModal.gotoLogIn(); + // Recovery is reached from the login modal rather than a link on a page, + // and its title is the modal's rather than a heading. + await authModal.forgotPasswordButton.click(); await expect(page).toHaveURL(/\/forgot-password/); - await expect(page.getByRole('dialog', { name: 'Reset your password' })).toBeVisible(); + await expect(passwordReset.requestDialog).toBeVisible(); }); - test('requesting a reset confirms without revealing whether the account exists', async ({ page }) => { - await page.goto('/forgot-password'); - await page.getByRole('textbox', { name: 'Email' }).fill('definitely-nobody@example.com'); - await page.getByRole('button', { name: 'Send reset link' }).click(); + test('requesting a reset confirms without revealing whether the account exists', async ({ + page, + passwordReset + }) => { + await passwordReset.gotoRequest(); + await passwordReset.requestLinkFor('definitely-nobody@example.com'); // Identical wording either way; a differing message would make this an // account-enumeration oracle. - await expect(page.getByText('Check your email')).toBeVisible(); await expect(page.getByText(/If an account exists/)).toBeVisible(); }); - test('a reset link with no token explains itself instead of failing on submit', async ({ page }) => { - await page.goto('/reset-password'); + test('a reset link with no token explains itself instead of failing on submit', async ({ + page, + passwordReset + }) => { + await passwordReset.gotoReset(); await expect(page.getByText('This link is incomplete')).toBeVisible(); - await expect(page.getByRole('button', { name: 'Set new password' })).toHaveCount(0); + await expect(passwordReset.setNewPasswordButton).toHaveCount(0); }); - test('rejects a mismatched confirmation before contacting the server', async ({ page }) => { - await page.goto('/reset-password?token=whatever'); - await page.getByLabel('New password', { exact: true }).fill(NEW_PASSWORD); - await page.getByLabel('Confirm new password').fill('something-else-entirely'); - await page.getByRole('button', { name: 'Set new password' }).click(); + test('rejects a mismatched confirmation before contacting the server', async ({ + page, + passwordReset + }) => { + await passwordReset.gotoReset('whatever'); + await passwordReset.setNewPassword(NEW_PASSWORD, 'something-else-entirely'); await expect(page.getByText('The passwords do not match')).toBeVisible(); }); - test('reports an invalid token rather than appearing to succeed', async ({ page }) => { - await page.goto('/reset-password?token=not-a-real-token'); - await page.getByLabel('New password', { exact: true }).fill(NEW_PASSWORD); - await page.getByLabel('Confirm new password').fill(NEW_PASSWORD); - await page.getByRole('button', { name: 'Set new password' }).click(); + test('reports an invalid token rather than appearing to succeed', async ({ + page, + passwordReset + }) => { + await passwordReset.gotoReset('not-a-real-token'); + await passwordReset.setNewPassword(NEW_PASSWORD); await expect(page.getByText('invalid or expired token')).toBeVisible(); await expect(page).toHaveURL(/\/reset-password/); }); - test('a customer can reset their password and sign in with the new one', async ({ page, request }) => { - const email = uniqueEmail(); - await register(page, email); - await logout(page); + test('a customer can reset their password and sign in with the new one', async ({ + request, + customer, + accountModal, + header, + authModal, + passwordReset + }) => { + await accountModal.openAndLogOut(); + await expect(header.logInButton).toBeVisible(); // The reset link arrives by email, which the tests can't read. Request the // reset through the real endpoint, then read the issued token the way the // customer's mail client would deliver it. - const requested = await request.post('/api/customers/request-password-reset', { data: { email } }); + const requested = await request.post('/api/customers/request-password-reset', { + data: { email: customer.email } + }); expect(requested.ok()).toBeTruthy(); - const token = await readResetToken(email); - await page.goto(`/reset-password?token=${token}`); - await page.getByLabel('New password', { exact: true }).fill(NEW_PASSWORD); - await page.getByLabel('Confirm new password').fill(NEW_PASSWORD); - await page.getByRole('button', { name: 'Set new password' }).click(); + await passwordReset.gotoReset(await readPasswordResetToken(customer.email)); + await passwordReset.setNewPassword(NEW_PASSWORD); // The reset signs them in and closes back to the storefront — the link came // from an email, so there is no page behind it to return to. - await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 }); - await page.goto('/account'); - await expect(page.getByText(email)).toBeVisible(); + await header.waitForSignedIn(); + await accountModal.open(); + await expect(accountModal.emailText(customer.email)).toBeVisible(); // And the new password actually works on a fresh sign-in. - await logout(page); - await page.goto('/login'); - await page.getByRole('textbox', { name: 'Email' }).fill(email); - await page.getByLabel('Password').fill(NEW_PASSWORD); - // Scoped to the modal: the storefront rendered behind it has a "Log in" - // button of its own, which is what opened this one. - await page.getByRole('dialog', { name: 'Log in' }).getByRole('button', { name: 'Log in' }).click(); - await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 }); + await accountModal.logOut(); + await expect(header.logInButton).toBeVisible(); + await authModal.gotoLogIn(); + await authModal.logIn(customer.email, NEW_PASSWORD); + await header.waitForSignedIn(); }); - test('the old password stops working after a reset', async ({ page, request }) => { - const email = uniqueEmail(); - await register(page, email); - await logout(page); + test('the old password stops working after a reset', async ({ + page, + request, + customer, + accountModal, + header, + authModal + }) => { + await accountModal.openAndLogOut(); + await expect(header.logInButton).toBeVisible(); - await request.post('/api/customers/request-password-reset', { data: { email } }); - const token = await readResetToken(email); + await request.post('/api/customers/request-password-reset', { data: { email: customer.email } }); + const token = await readPasswordResetToken(customer.email); await request.post('/api/customers/reset-password', { data: { token, password: NEW_PASSWORD } }); - await page.goto('/login'); - await page.getByRole('textbox', { name: 'Email' }).fill(email); - await page.getByLabel('Password').fill(PASSWORD); - // Scoped to the modal: the storefront rendered behind it has a "Log in" - // button of its own, which is what opened this one. - await page.getByRole('dialog', { name: 'Log in' }).getByRole('button', { name: 'Log in' }).click(); + await authModal.gotoLogIn(); + await authModal.logIn(customer.email, customer.password); await expect(page.getByText('invalid email or password')).toBeVisible(); + await expect(header.myAccountButton).toHaveCount(0); }); }); - -// The token is only ever delivered by email, which these tests cannot read. -// -// It is read straight from the database rather than through a helper endpoint: -// an endpoint that returns a password-reset token for an arbitrary address is -// account takeover for every customer if it is ever reachable, and an -// environment gate is a thin thing to stand between that and production. Doing -// it here keeps the capability entirely inside the test process. -async function readResetToken(email: string): Promise { - const client = new Client({ - host: process.env.TEST_PGHOST || 'localhost', - port: parseInt(process.env.TEST_PGPORT || '55432', 10), - user: process.env.TEST_PGUSER || 'redefined_test', - password: process.env.TEST_PGPASSWORD || 'redefined_test', - database: process.env.TEST_PGDATABASE || 'redefined_test' - }); - await client.connect(); - try { - const { rows } = await client.query( - `SELECT t.token FROM customer_tokens t - JOIN customers c ON c.id = t.customer_id - WHERE c.email = $1 AND t.kind = 'password_reset' - ORDER BY t.created_at DESC LIMIT 1`, - [email] - ); - if (!rows.length) throw new Error(`no password_reset token issued for ${email}`); - return rows[0].token as string; - } finally { - await client.end(); - } -} diff --git a/frontend/tests/e2e/resend-verification.spec.ts b/frontend/tests/e2e/resend-verification.spec.ts index 4de796c..6cba5d0 100644 --- a/frontend/tests/e2e/resend-verification.spec.ts +++ b/frontend/tests/e2e/resend-verification.spec.ts @@ -1,41 +1,20 @@ -import { test, expect, Page } from './fixtures'; - -const PASSWORD = 'supersecret123'; - -const uniqueEmail = () => `resend-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}@example.com`; - -// The generous wait matches the other account specs: registration is a bcrypt -// round-trip rather than 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' }); +import { test, expect } from './fixtures'; test.describe('Resending your verification email', () => { - test('the account page offers it while the address is unverified', async ({ page }) => { - await registerCustomer(page); - await page.goto('/account'); + test('the account page offers it while the address is unverified', async ({ + customer, + accountModal + }) => { + await accountModal.open(); - const modal = accountModal(page); - await expect(modal.getByText('Email not verified')).toBeVisible(); - await expect(modal.getByRole('button', { name: 'Send it again' })).toBeVisible(); + await expect(accountModal.dialog.getByText('Email not verified')).toBeVisible(); + await expect(accountModal.resendVerificationButton).toBeVisible(); }); - test('confirms when it has sent', async ({ page }) => { - await registerCustomer(page); - await page.goto('/account'); + test('confirms when it has sent', async ({ page, customer, accountModal }) => { + await accountModal.open(); - await accountModal(page).getByRole('button', { name: 'Send it again' }).click(); + await accountModal.resendVerificationButton.click(); await expect(page.getByText('Check your inbox, and your spam folder.')).toBeVisible(); }); @@ -43,11 +22,14 @@ test.describe('Resending your verification email', () => { // The message the customer gets on the fourth attempt is the point of the // limiter's copy: it says the mail probably did send and where to look, // rather than only that a limit exists. - test('says something useful once the allowance runs out', async ({ page }) => { - await registerCustomer(page); - await page.goto('/account'); + test('says something useful once the allowance runs out', async ({ + page, + customer, + accountModal + }) => { + await accountModal.open(); - const resend = accountModal(page).getByRole('button', { name: 'Send it again' }); + const resend = accountModal.resendVerificationButton; for (let i = 0; i < 3; i++) { await resend.click(); await expect(resend).toBeEnabled();