Files
redefined-designs/frontend/tests/e2e/favorites.spec.ts
T
bermudalamb 91485b6ac1 feat(account): open My Account as a modal over the page behind it (#51)
/account had no header and no links of any kind, so once a customer opened it the only way back to the storefront was the browser's back button or editing the URL.

It is now a modal rendered over whatever the customer was looking at, while staying a real route. Opening it from the header pushes /account and names the current page as the backdrop, so closing returns there with filters intact, and the browser's Back button does the same thing as the close control. Entering /account directly — a bookmark, the link in a verification email, or the redirect after registering — has no page behind it and falls back to the storefront, so closing always lands somewhere real. Keeping it a route means the URL still works: bookmarkable, shareable, and refreshable with the view still open, which the header link and the four post-authentication redirects already depend on.

Deleting an account now clears the session as well. Previously it removed the account server-side and navigated home without touching the auth context, so the header went on offering "My Account" for an account that no longer existed until the next reload. That was always wrong, but the modal makes it visible rather than merely stale, because the storefront is rendered behind and the wrong header is on screen throughout.

The modal body is capped and scrolls, and the order history table scrolls within itself, so the view survives a phone without pushing its own title and close control off-screen.

Also scopes the account switch locator in the favorites spec to the modal, since the storefront now renders behind the account view and has a theme switch of its own, and gives the post-registration wait a realistic timeout — it waits on a bcrypt round-trip rather than a render, and the 5s default was surfacing as a flake on whichever test lost the race under parallel load.

Closes #51
2026-08-18 16:36:20 -05:00

146 lines
6.2 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();
await expect(page).toHaveURL(/\/account/);
}
// 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);
});
});