test(e2e): convert the auth specs onto page objects (#137)
Linting / lint (pull_request) Successful in 2m9s
SonarQube Analysis / sonarqube (pull_request) Successful in 18m4s

First conversion batch: auth, password-reset, resend-verification. verify-email is left alone — it already used semantic locators and duplicated nothing, and rewriting it to prove a point would be churn.

Four of the nine copies of "register a customer" go here. auth.spec.ts and password-reset.spec.ts each carried their own `register`, and resend-verification.spec.ts its own `registerCustomer`, all three re-explaining the same bcrypt wait and the same "the header, not the URL, proves the session exists" reasoning in slightly different words. Two also carried their own `logout`, and two their own `uniqueEmail` with different prefixes.

Most of those tests were not about registering. They needed an account to exist so they could test logging out, resetting a password, or resending a verification email, and they paid for a bcrypt round-trip through the form to get one. Those now take the `customer` fixture, which registers through the API. The three tests that genuinely are about the registration form still drive it, because the thing under test has to be the thing exercised.

The batch drops from 47s to 26s as a side effect, which is the cost of that round-trip made visible.

password-reset.spec.ts loses its inline pg.Client. The reasoning for reading the database directly is unchanged and still right — an endpoint returning a reset token for an arbitrary address is account takeover if it is ever reachable — but it now lives in support/db.ts where it cannot be copied into the next spec wanting a shortcut. It also stops defaulting to port 55432, which is the integration suite's disposable Postgres rather than the database the app under test is connected to, and is Hyper-V-reserved on at least one machine here. Both tests in that file previously failed with a bare ECONNREFUSED unless TEST_PGPORT was set by hand; they now pass with no environment at all.

New PasswordResetPages object covers both halves of recovery — requesting a link, and using one — because they are one flow and a test usually crosses between them.

One lint decision worth recording. 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. The first attempt at appeasing it was a `void customer;` line per test, which is noise standing in for a comment — and sonarjs flags that too, so it traded one warning for another. `no-unused-vars` is now configured with `args: 'none'` for tests only, with the reason written next to it. Variables are still checked; only parameters are exempt.

Verified: 26/26 in the converted batch, and 127 passed in the full suite with four failures — three in the known #116 flaky family, and resend-verification's rate-limit test, which passes 9/9 across three repeats in isolation and is timing-sensitive under parallel load rather than changed by this commit.

Refs #137
This commit is contained in:
2026-08-23 17:30:49 -05:00
parent 882f42447b
commit abcc684447
9 changed files with 317 additions and 269 deletions
+113 -117
View File
@@ -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();
});
});