/login, /register and /forgot-password rendered bare cards with no site header. They linked to each other and nowhere else, so a customer who clicked Log in from the storefront and changed their mind had no way back except the browser's back button. All four auth routes are now modals over the page the customer was already on, reusing the backdrop-location arrangement from #51: a direct visit or a link from an email opens over the storefront, so closing always lands somewhere real. They remain real routes, because /reset-password links to /login and customers may have bookmarks. Signing in or registering now returns the customer to the page behind, signed in, rather than moving them to /account. Someone who signs in while browsing wants to carry on browsing, and this is already how the cart and favorites prompts behave when they resume an interrupted action. The larger half of this is removing the duplication. Signing in existed twice — as these routes and again inside the prompt shown when a signed-out visitor adds to the cart or favorites something — and the two had already drifted. There were three different wordings of the marketing consent in circulation: the register page's, a shorter one in the prompt, and the string the server actually stores. The server keeps that text verbatim so the consent record says what the customer saw, which none of the three did. Both callers now render one shared AuthForm whose checkbox is the exact string the server records, and a test asserts that wording so it cannot drift again silently. Steps within the auth flow replace rather than push, so switching between tabs or stepping to password recovery leaves the whole detour as a single history entry and closing returns to where it started instead of walking back through every tab that was looked at. The privacy policy link opens in a new tab: following it in place would discard a part-filled signup form, and /privacy still has no way back of its own until #52. Test changes follow from the destination change rather than being incidental. Nineteen assertions across seven specs waited for /account after signing in; they now assert the header shows a signed-in customer, which is the condition actually being waited for. Modal submits are scoped to their dialog, because the storefront behind now offers a Log in button of its own and an unscoped locator matched both. Assertions that follow a server round-trip were given a realistic timeout — the 5s default is too tight for a bcrypt hash plus re-rendering the storefront behind the modal. Verified with 83 end-to-end tests, all passing, and type checking clean. No backend changes. Closes #50
149 lines
6.5 KiB
TypeScript
149 lines
6.5 KiB
TypeScript
import { test, expect, Page } from '@playwright/test';
|
|
|
|
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();
|
|
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);
|
|
});
|
|
});
|