feat: filter the storefront by favorited items (#35)
Adds favorites as another dimension of the existing storefront filter rather than a separate view, so it lives in the URL, shows up as a removable chip, and combines with category, tags, and price by AND like everything else. A customer can ask for "my favorites under $500 in Furniture" instead of only "my favorites". Sold favorites are included. The storefront shows sold items everywhere else, and a favorite that has just sold is often exactly what the customer came back to look at after being emailed about it in #34. Hiding them would make items disappear from a list the customer curated themselves. Anyone wanting only what they can still buy can combine the toggle with the status filter. Which customer "my favorites" means comes from the session, never from the query string, so a hand-edited URL cannot name someone else's favorites. A signed-out visitor sees the toggle and gets the same inline register/login prompt the heart button and Add to Cart already use; signing in resolves the gate and the filter applies on its own. A bookmarked favorites link whose session has expired says so rather than rendering an empty grid, which would tell the visitor they have no favorites instead of that we do not know who they are. The API answers 401 for the same reason, and the admin inventory refuses the filter outright rather than ignoring it. The shared SQL builder now requires callers to say whose favorites they mean, even when that is nobody, and throws instead of dropping the clause — a future caller that forgets the guard fails loudly rather than quietly returning the whole catalogue. Verified with 59 unit tests, 134 backend integration tests, and 70 end-to-end tests, all passing, with type checking clean on both sides.
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
|
||||
const PASSWORD = 'supersecret123';
|
||||
// The storefront runs against a shared database that is never reset, so every
|
||||
// name has to be unique to this run or a rerun would match the last one's rows.
|
||||
const RUN = `ff${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
||||
const KEPT = `Kept ${RUN}`;
|
||||
const OTHER = `Other ${RUN}`;
|
||||
// Its own item because this run marks it sold, and the suite is fullyParallel:
|
||||
// mutating an item the other tests read would make them race.
|
||||
const SELLS = `Sells ${RUN}`;
|
||||
|
||||
const uniqueEmail = () => `favfilter-${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' });
|
||||
// Two items, priced apart so one test can prove favorites combines with the
|
||||
// price filter rather than replacing it.
|
||||
for (const [name, price] of [[KEPT, '60'], [OTHER, '900'], [SELLS, '70']] as const) {
|
||||
const res = await api.post('/api/admin/items', {
|
||||
multipart: { name, description: '', price, category_id: '', tags: '[]' }
|
||||
});
|
||||
expect(res.ok()).toBeTruthy();
|
||||
}
|
||||
await api.dispose();
|
||||
});
|
||||
|
||||
async function register(page: 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).toHaveURL(/\/account/);
|
||||
}
|
||||
|
||||
// The session is still resolving for a moment after a remount, and the
|
||||
// favorites filter deliberately waits it out rather than guessing. Waiting for
|
||||
// the account link is what a real customer sees settle.
|
||||
async function gotoStorefrontSignedIn(page: Page) {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('button', { name: 'My Account' })).toBeVisible();
|
||||
}
|
||||
|
||||
async function favorite(page: Page, itemName: string) {
|
||||
await page.getByRole('button', { name: `Add ${itemName} to favorites` }).click();
|
||||
// The alert opt-in is offered on every favorite until it is accepted, so it
|
||||
// is always there to decline. Clicked rather than probed with isVisible():
|
||||
// that check does not wait, so it loses the race with the modal appearing and
|
||||
// leaves it open to block everything the test does next.
|
||||
const decline = page.getByRole('dialog').getByRole('button', { name: 'No thanks' });
|
||||
await decline.click();
|
||||
// Its wrapper goes on intercepting pointer events while it fades out.
|
||||
await expect(decline).toBeHidden();
|
||||
await expect(page.getByRole('button', { name: `Remove ${itemName} from favorites` })).toBeVisible();
|
||||
}
|
||||
|
||||
async function openFilters(page: Page) {
|
||||
await page.getByRole('button', { name: /Filters/ }).click();
|
||||
await expect(favoritesSwitch(page)).toBeVisible();
|
||||
}
|
||||
|
||||
const favoritesSwitch = (page: Page) => page.getByRole('switch', { name: 'Only my favorites' });
|
||||
|
||||
test.describe('Filtering the storefront by favorites', () => {
|
||||
test('narrows the grid to favorited items and puts it in the URL', async ({ page }) => {
|
||||
await register(page);
|
||||
await gotoStorefrontSignedIn(page);
|
||||
await favorite(page, KEPT);
|
||||
|
||||
await openFilters(page);
|
||||
await favoritesSwitch(page).click();
|
||||
// Close the drawer before reading the grid behind it, as the other filter
|
||||
// tests do.
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: `Remove ${KEPT} from favorites` })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: `Add ${OTHER} to favorites` })).toBeHidden();
|
||||
// In the URL so the view can be linked, bookmarked, and reloaded.
|
||||
await expect(page).toHaveURL(/favorites=1/);
|
||||
});
|
||||
|
||||
test('survives a reload, since the URL is the source of truth', async ({ page }) => {
|
||||
await register(page);
|
||||
await gotoStorefrontSignedIn(page);
|
||||
await favorite(page, KEPT);
|
||||
|
||||
await page.goto('/?favorites=1');
|
||||
await expect(page.getByRole('button', { name: `Remove ${KEPT} from favorites` })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: `Add ${OTHER} to favorites` })).toBeHidden();
|
||||
});
|
||||
|
||||
test('shows a removable chip that restores the full catalogue', async ({ page }) => {
|
||||
await register(page);
|
||||
await gotoStorefrontSignedIn(page);
|
||||
await favorite(page, KEPT);
|
||||
|
||||
await page.goto('/?favorites=1');
|
||||
await expect(page.getByRole('button', { name: `Add ${OTHER} to favorites` })).toBeHidden();
|
||||
|
||||
const chips = page.getByRole('group', { name: 'Active filters' });
|
||||
await expect(chips).toContainText('My favorites');
|
||||
await chips.getByRole('button', { name: 'Remove filter My favorites' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: `Add ${OTHER} to favorites` })).toBeVisible();
|
||||
await expect(page).not.toHaveURL(/favorites/);
|
||||
});
|
||||
|
||||
test('combines with the price filter rather than replacing it', async ({ page }) => {
|
||||
await register(page);
|
||||
await gotoStorefrontSignedIn(page);
|
||||
await favorite(page, KEPT);
|
||||
await favorite(page, OTHER);
|
||||
|
||||
// Both are favorited; only one is under the price cap.
|
||||
await page.goto('/?favorites=1&max_price=50000');
|
||||
|
||||
await expect(page.getByRole('button', { name: `Remove ${KEPT} from favorites` })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: `Remove ${OTHER} from favorites` })).toBeHidden();
|
||||
});
|
||||
|
||||
test('prompts a signed-out visitor to sign in, then applies the filter', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await openFilters(page);
|
||||
await favoritesSwitch(page).click();
|
||||
|
||||
// The same inline prompt the heart and Add to Cart use, rather than an
|
||||
// empty grid implying the visitor has no favorites.
|
||||
const prompt = page.getByRole('dialog', { name: /Create an account/ });
|
||||
await expect(prompt).toBeVisible();
|
||||
|
||||
await prompt.getByRole('textbox', { name: 'Email' }).fill(uniqueEmail());
|
||||
await prompt.getByLabel('Password').fill(PASSWORD);
|
||||
await prompt.getByRole('button', { name: 'Create account' }).click();
|
||||
|
||||
// Signing in resolves the gate and the filter applies on its own — the
|
||||
// customer never sets it twice. A brand-new account has no favorites yet.
|
||||
await expect(page.getByText('No items match these filters')).toBeVisible();
|
||||
await expect(page).toHaveURL(/favorites=1/);
|
||||
});
|
||||
|
||||
test('explains itself when a favorites link is opened without a session', async ({ page }) => {
|
||||
// A bookmarked filtered view whose session has since expired. The grid must
|
||||
// not claim there are no matching items, which would read as "you have no
|
||||
// favorites" rather than "we do not know who you are".
|
||||
await page.goto('/?favorites=1');
|
||||
|
||||
await expect(page.getByText('Sign in to see the items you have favorited')).toBeVisible();
|
||||
await expect(page.getByText('No items match these filters')).toBeHidden();
|
||||
});
|
||||
|
||||
test('keeps showing a favorite after it sells', async ({ page, playwright }) => {
|
||||
await register(page);
|
||||
await gotoStorefrontSignedIn(page);
|
||||
await favorite(page, SELLS);
|
||||
|
||||
const api = await playwright.request.newContext({ baseURL: 'http://localhost:5173' });
|
||||
const items = await (await api.get('/api/items')).json();
|
||||
const sells = items.find((item: { name: string }) => item.name === SELLS);
|
||||
expect(await (await api.post(`/api/admin/items/${sells.id}/mark-sold`)).ok()).toBeTruthy();
|
||||
await api.dispose();
|
||||
|
||||
await page.goto('/?favorites=1');
|
||||
|
||||
// Hiding it would make an item the customer curated vanish without
|
||||
// explanation, right after they were emailed to say it had sold.
|
||||
await expect(page.getByRole('button', { name: `Remove ${SELLS} from favorites` })).toBeVisible();
|
||||
// Scoped to this item's cell: the ribbon sits outside the card, and other
|
||||
// sold items from earlier runs are on the same page.
|
||||
await expect(page.locator('.ant-col').filter({ hasText: SELLS })).toContainText('SOLD');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user