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>
155 lines
6.9 KiB
TypeScript
155 lines
6.9 KiB
TypeScript
import { test, expect, Page } from './fixtures';
|
|
|
|
const PASSWORD = 'supersecret123';
|
|
const RUN = `f${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
|
const ITEM = `Favoritable ${RUN}`;
|
|
|
|
const uniqueEmail = () => `fav-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}@example.com`;
|
|
|
|
test.beforeAll(async ({ playwright }) => {
|
|
const api = await playwright.request.newContext({ baseURL: 'http://localhost:5173' });
|
|
const res = await api.post('/api/admin/items', {
|
|
multipart: { name: ITEM, description: '', price: '60', category_id: '', tags: '[]' }
|
|
});
|
|
expect(res.ok()).toBeTruthy();
|
|
// New items are pending; the storefront only lists published ones.
|
|
expect((await api.post(`/api/admin/items/${(await res.json()).id}/mark-available`)).ok()).toBeTruthy();
|
|
await api.dispose();
|
|
});
|
|
|
|
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 });
|
|
}
|
|
|
|
// Going to the storefront remounts the app, so the session is briefly still
|
|
// resolving. The heart deliberately ignores clicks in that window rather than
|
|
// wrongly prompting a signed-in customer to sign in, so wait for the header to
|
|
// show the account link — which is exactly what a real customer sees settle.
|
|
async function gotoStorefrontSignedIn(page: Page) {
|
|
await page.goto('/');
|
|
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible();
|
|
}
|
|
|
|
// The storefront paginates as items accumulate, so find the card by name.
|
|
function heart(page: Page, itemName: string) {
|
|
return page.getByRole('button', { name: new RegExp(`(Add|Remove) ${itemName}`) });
|
|
}
|
|
|
|
test.describe('Favoriting items', () => {
|
|
test('a signed-out visitor is prompted to sign in, and the favorite completes', async ({ page }) => {
|
|
await page.goto('/');
|
|
await heart(page, ITEM).first().click();
|
|
|
|
// Same inline prompt Add to Cart already uses.
|
|
await expect(page.getByRole('dialog')).toBeVisible();
|
|
|
|
const email = uniqueEmail();
|
|
await page.getByRole('dialog').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.getByRole('dialog').getByLabel('Password').fill(PASSWORD);
|
|
await page.getByRole('dialog').getByRole('button', { name: 'Create account' }).click();
|
|
|
|
// The favorite the visitor originally asked for is applied on success.
|
|
await expect(page.getByRole('button', { name: `Remove ${ITEM} from favorites` })).toBeVisible();
|
|
});
|
|
|
|
test('offers the alert opt-in with a reason, and it is not the marketing consent', async ({ page }) => {
|
|
const email = uniqueEmail();
|
|
await register(page, email);
|
|
|
|
await gotoStorefrontSignedIn(page);
|
|
await heart(page, ITEM).first().click();
|
|
|
|
const prompt = page.getByRole('dialog');
|
|
await expect(prompt).toBeVisible();
|
|
await expect(prompt).toContainText('Want to know if this sells?');
|
|
// The reason for asking has to be given, not just the ask.
|
|
await expect(prompt).toContainText(/one of a kind/i);
|
|
await expect(prompt).toContainText(/separate from any marketing/i);
|
|
|
|
await prompt.getByRole('button', { name: 'Yes, email me' }).click();
|
|
await expect(page.getByText('We will let you know')).toBeVisible();
|
|
|
|
const me = await (await page.request.get('/api/customers/me')).json();
|
|
expect(me.favorite_alerts).toBe(true);
|
|
// Accepting item alerts must not sign anyone up for marketing.
|
|
expect(me.marketing_consent).toBe(false);
|
|
});
|
|
|
|
test('declining the opt-in still keeps the favorite', async ({ page }) => {
|
|
const email = uniqueEmail();
|
|
await register(page, email);
|
|
|
|
await gotoStorefrontSignedIn(page);
|
|
await heart(page, ITEM).first().click();
|
|
await page.getByRole('dialog').getByRole('button', { name: 'No thanks' }).click();
|
|
|
|
await expect(page.getByRole('button', { name: `Remove ${ITEM} from favorites` })).toBeVisible();
|
|
|
|
const favorites = await (await page.request.get('/api/customers/me/favorites')).json();
|
|
expect(favorites).toHaveLength(1);
|
|
|
|
const me = await (await page.request.get('/api/customers/me')).json();
|
|
expect(me.favorite_alerts).toBe(false);
|
|
});
|
|
|
|
test('unfavoriting removes it', async ({ page }) => {
|
|
const email = uniqueEmail();
|
|
await register(page, email);
|
|
|
|
await gotoStorefrontSignedIn(page);
|
|
await heart(page, ITEM).first().click();
|
|
await page.getByRole('dialog').getByRole('button', { name: 'No thanks' }).click();
|
|
await expect(page.getByRole('button', { name: `Remove ${ITEM} from favorites` })).toBeVisible();
|
|
|
|
await page.getByRole('button', { name: `Remove ${ITEM} from favorites` }).click();
|
|
await expect(page.getByRole('button', { name: `Add ${ITEM} to favorites` })).toBeVisible();
|
|
|
|
const favorites = await (await page.request.get('/api/customers/me/favorites')).json();
|
|
expect(favorites).toEqual([]);
|
|
});
|
|
|
|
test('favorites survive a reload', async ({ page }) => {
|
|
const email = uniqueEmail();
|
|
await register(page, email);
|
|
|
|
await gotoStorefrontSignedIn(page);
|
|
await heart(page, ITEM).first().click();
|
|
await page.getByRole('dialog').getByRole('button', { name: 'No thanks' }).click();
|
|
await expect(page.getByRole('button', { name: `Remove ${ITEM} from favorites` })).toBeVisible();
|
|
|
|
// Proves it was stored server-side rather than held in component state.
|
|
await page.reload();
|
|
await expect(page.getByRole('button', { name: `Remove ${ITEM} from favorites` })).toBeVisible();
|
|
});
|
|
|
|
test('the account page can turn the alerts off again', async ({ page }) => {
|
|
const email = uniqueEmail();
|
|
await register(page, email);
|
|
await page.request.put('/api/customers/me/favorite-alerts', { data: { enabled: true } });
|
|
|
|
await page.goto('/account');
|
|
// Scoped to the account modal. The storefront renders behind it and has a
|
|
// theme switch of its own, so an unscoped switch locator only picks the
|
|
// right control by DOM accident.
|
|
const account = page.getByRole('dialog', { name: 'My Account' });
|
|
await expect(account.getByText('Email me when an item I favorited is sold')).toBeVisible();
|
|
|
|
await account.getByRole('switch').last().click();
|
|
await expect(page.getByText('Turned off')).toBeVisible();
|
|
|
|
const me = await (await page.request.get('/api/customers/me')).json();
|
|
expect(me.favorite_alerts).toBe(false);
|
|
});
|
|
});
|