Files
redefined-designs/frontend/tests/e2e/auth.spec.ts
T
bermudalambandClaude Opus 5 b287c07747
Tests / lint (pull_request) Successful in 1m38s
Tests / backend-unit (pull_request) Successful in 1m44s
Tests / frontend-e2e (pull_request) Failing after 9m50s
SonarQube Analysis / sonarqube (pull_request) Failing after 11m46s
feat: capture first and last name so emails can greet informally (#106)
Registration collected one optional Name, so every greeting had only a whole name to use: "Hi Thom Lamb," rather than "Hi Thom,". Both parts are now captured, and the cart reminder greets by first name.

Both are required of anyone new, refused individually rather than as a single "name is required" so a form that filled one and not the other is told which.

The columns are nullable even so, and that is deliberate. Marking them NOT NULL would mean backfilling legacy rows with empty strings, which asserts that every customer has a name — untrue of anyone who registered while the field was optional. The table records what is actually the case; the rule that new registrations must supply both lives in the route, where a missing field can produce a message naming it.

The backfill splits on the first space, and it is lossy in a way no version of this avoids. "Thom Lamb" becomes Thom and Lamb; "Mary Jane Smith" gets a last name of "Jane Smith"; names that are not two parts fare worse. It was chosen over leaving the columns empty because nothing currently lets a customer correct their own name — PUT /api/customers/me exists but no frontend calls it — so empty would have meant permanently unpersonalised for every existing customer. The migration says so, so nobody later reads backfilled values as data the customer supplied in that shape.

Verified against a seeded database rather than reasoned about, because this is the part that cannot be covered by the suite: migrations run in globalSetup before any test, and the old column is gone afterwards. Six representative rows through the real migration gave Thom/Lamb, Mary/"Jane Smith", Cher/null, "  Padded  Name  " trimmed to Padded/Name, and null and whitespace-only names left as null on both. The down migration rejoins the parts and returns all six to their original strings.

The old column is dropped rather than kept alongside, so there is one source of truth instead of two that drift.

The admin keeps receiving a single composed display name. It only ever shows one — the list cell and the drawer title — and never edits one, so giving it both parts plus the joining logic would be work for no reader.

Churn was the bulk of this: 14 backend registrations and 10 end-to-end registration forms. A first attempt at the backend fixtures also added names to login and password-reset payloads, which would still have passed since the server ignores unknown fields, but a login test implying login takes a name is a small lie; that was reverted and redone against register calls only.

Verified: 172 unit, 183 integration and 95 end-to-end passing, lint unchanged at 4 backend and 27 frontend warnings.

Not covered: the cart reminder itself, which runs from a cron and had no test before this either. The greeting change is a one-line substitution in that query.

Refs #106
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:23:13 -05:00

213 lines
9.2 KiB
TypeScript
Executable File

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();
}
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();
await expect(page.getByText('First name is required')).toBeVisible();
await expect(page.getByText('Last name is required')).toBeVisible();
// Still on the form rather than signed in.
await expect(page.getByRole('button', { name: 'My Account' })).toHaveCount(0);
});
test('marketing consent checkbox is unchecked by default', async ({ page }) => {
await page.goto('/register');
await expect(page.getByRole('checkbox')).not.toBeChecked();
});
test('the consent label is the exact wording the server records', async ({ page }) => {
await page.goto('/register');
// The stored consent text is kept verbatim so the record says what the
// customer actually saw. Three different wordings were in circulation
// before the sign-in form was shared between the routes and the cart
// 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);
});
test('registering signs the customer in and returns them where they were', async ({ page }) => {
const email = uniqueEmail();
await register(page, email);
// 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();
});
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();
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 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();
// 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();
});
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();
// 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();
});
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();
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);
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();
// The session cookie is still valid, so pretending to be logged out would
// silently log the customer back in on their next reload.
await expect(page.getByText(/couldn't log out/i)).toBeVisible();
await expect(page).toHaveURL(/\/account/);
});
});
test.describe('Auth routes are not dead ends', () => {
test('opening Log in from the header closes back to where browsing left off', async ({ page }) => {
await page.goto('/?max_price=50000');
await page.getByRole('button', { name: 'Log in' }).click();
const modal = page.getByRole('dialog', { name: 'Log in' });
await expect(modal).toBeVisible();
await expect(page).toHaveURL(/\/login/);
await modal.getByRole('button', { name: 'Close' }).click();
await expect(modal).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');
await expect(page.getByRole('dialog', { name: 'Log in' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Redefined Designs' })).toBeVisible();
});
test('switching between sign in and sign up keeps one history entry', async ({ page }) => {
await page.goto('/?max_price=50000');
await page.getByRole('button', { name: 'Log in' }).click();
await page.getByRole('tab', { name: 'Create Account' }).click();
await expect(page).toHaveURL(/\/register/);
await page.getByRole('tab', { name: 'Log In' }).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();
});
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();
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 expect(page.getByRole('button', { name: 'My Account' })).toBeVisible({ timeout: 20000 });
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();
const modal = page.getByRole('dialog', { name: 'Reset your password' });
await expect(modal).toBeVisible();
await expect(page).toHaveURL(/\/forgot-password/);
await modal.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('dialog', { name: 'Log in' })).toBeVisible();
});
});