An item used to be live on the storefront the instant it was created. Now it starts pending, and a customer sees it only once it is published. The migration changes the column default and nothing else. Backfilling would un-publish the entire live catalogue, which is the one thing it must not do. Hiding a pending item took four separate changes, not one, and that is the part worth knowing. The storefront's item routes had no status filter at all — sold items are listed and rendered with a Sold badge deliberately — so pending could not be expressed as one more optional filter. GET /api/items now carries an exclusion the caller cannot opt out of; GET /api/items/:id carries the same, because hiding an item from the list while still serving it by id would leave it reachable to anyone who kept a link; and GET /api/filters excludes pending from both aggregates it computes. That last one is the least obvious: a pending item would have inflated its tags' counts, so a customer would read "Rare (1)", filter by it, and be told nothing matches — and its price would have stretched the slider to a range no visible item occupies. The tag count is computed over the joined items rather than filtered with a WHERE. A WHERE would have dropped the row for a tag whose only item is pending, and the tag would have vanished from the drawer instead of showing zero. There is a test for exactly that, because the first version of this query had that bug. parseItemFilters is shared by the storefront and admin routes, so 'pending' parses on both. The public route refuses it explicitly rather than answering with an empty list, which would read as "no items match" instead of "you may not ask that". The storefront's URL reader is deliberately left not accepting it either, with a comment saying so, since a request guaranteed to fail is not worth constructing. Publishing is the existing mark-available: same transition, same UPDATE, so the admin UI labels that button "Publish" when the item is pending rather than adding a second endpoint that does the same thing. Unpublish is new and is not symmetrical — it is refused for a reserved item, which someone is holding in their cart right now, and for a sold one, which is a record of something that happened rather than a draft. Both refusals name their reason, and the buttons are hidden in those states so the refusal is not how you find out. Changing a column default has reach, and it surfaced eight test fixtures that silently depended on it. Each is now explicit about the status it wants rather than inheriting one — better practice regardless, and immune to the next default change. Two tests also used 'pending' as their example of an *unknown* status; both would have quietly become tautologies, so they now use one that is genuinely unknown. Verified: 98 unit, 160 integration and 94 end-to-end passing, the last on a freshly created container. One earlier run showed a single failure in favorites.spec.ts; it passes in isolation and on a clean container, and is the cross-spec interference already recorded against the suite rather than anything from this change. Refs #90 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
151 lines
6.6 KiB
TypeScript
151 lines
6.6 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.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('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);
|
|
});
|
|
});
|