diff --git a/backend/src/itemFilters.ts b/backend/src/itemFilters.ts index 3489303..25ae2e7 100644 --- a/backend/src/itemFilters.ts +++ b/backend/src/itemFilters.ts @@ -10,6 +10,10 @@ export interface ItemFilters { minPriceCents: number | null; maxPriceCents: number | null; status: ItemStatus | null; + // Storefront only: "just the items I have favorited". Which customer that + // means is not part of the parsed filter — it comes from the session at build + // time, so a query string can never name someone else's favorites. + favoritesOnly: boolean; } export type ItemStatus = 'available' | 'reserved' | 'sold'; @@ -29,6 +33,12 @@ export interface BuiltFilter { // '10.5' are caller mistakes worth surfacing rather than silently coercing. const NON_NEGATIVE_INTEGER = /^\d+$/; +// Accepts both spellings because these URLs get hand-edited and shared, but +// nothing else: '?favorites=yes' is a mistake worth reporting rather than +// treating as either on or off. +const TRUE_VALUES: readonly string[] = ['1', 'true']; +const FALSE_VALUES: readonly string[] = ['0', 'false']; + function singleValue(value: unknown, name: string): string | null { if (value === undefined || value === null) { return null; @@ -107,12 +117,33 @@ export function parseItemFilters(query: Record): ItemFilters { status = statusRaw as ItemStatus; } - return { categoryId, tagIds, minPriceCents, maxPriceCents, status }; + const favoritesRaw = singleValue(query.favorites, 'favorites'); + let favoritesOnly = false; + if (favoritesRaw !== null && favoritesRaw !== '') { + if (TRUE_VALUES.includes(favoritesRaw)) { + favoritesOnly = true; + } else if (!FALSE_VALUES.includes(favoritesRaw)) { + throw new FilterError('invalid favorites'); + } + } + + return { categoryId, tagIds, minPriceCents, maxPriceCents, status, favoritesOnly }; } // Returns WHERE fragments plus their parameters, with placeholders numbered // from `startIndex` so the caller can splice these in after its own params. -export function buildItemFilterSql(filters: ItemFilters, startIndex: number): BuiltFilter { +// +// `favoritesCustomerId` is required rather than optional so a caller has to say +// whose favorites it means, even when it means nobody's. Both routes already +// reject a favorites filter they cannot satisfy, so reaching the throw below is +// a programming error — but it is here so that a future caller which forgets +// the guard fails loudly instead of quietly ignoring the filter and listing the +// whole catalogue. +export function buildItemFilterSql( + filters: ItemFilters, + startIndex: number, + favoritesCustomerId: number | null +): BuiltFilter { const clauses: string[] = []; const params: unknown[] = []; let next = startIndex; @@ -164,5 +195,17 @@ export function buildItemFilterSql(filters: ItemFilters, startIndex: number): Bu next++; } + if (filters.favoritesOnly) { + if (favoritesCustomerId === null) { + throw new Error('favorites filter requires a customer id'); + } + params.push(favoritesCustomerId); + // EXISTS rather than a join: an item is favorited by a customer at most + // once, but joining would still risk multiplying rows if that ever changed, + // and this reads as the membership test it is. + clauses.push(`EXISTS (SELECT 1 FROM favorites f WHERE f.item_id = i.id AND f.customer_id = $${next})`); + next++; + } + return { clauses, params }; } diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index cd16d85..d83fe32 100755 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -128,7 +128,13 @@ router.get('/items', asyncRoute(async (req: Request, res: Response) => { throw err; } - const { clauses, params } = buildItemFilterSql(filters, 1); + // Favorites belong to a customer, and the admin inventory view is not + // browsing as one. Refused rather than ignored so the mistake is visible. + if (filters.favoritesOnly) { + return res.status(400).json({ error: 'favorites is not a valid inventory filter' }); + } + + const { clauses, params } = buildItemFilterSql(filters, 1, null); const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; const { rows } = await pool.query(`${ADMIN_ITEM_SELECT} ${where} ORDER BY i.created_at DESC`, params); res.json(rows); diff --git a/backend/src/routes/items.ts b/backend/src/routes/items.ts index 7f5c1d8..9958c5d 100755 --- a/backend/src/routes/items.ts +++ b/backend/src/routes/items.ts @@ -19,7 +19,16 @@ router.get('/', asyncRoute(async (req: Request, res: Response) => { throw err; } - const { clauses, params } = buildItemFilterSql(filters, 1); + // 401 rather than an empty list: a signed-out visitor asking for "my + // favorites" has no favorites to be empty of, and answering with [] would + // render as "no items match these filters" — a plausible-looking lie. The + // storefront prompts for sign-in instead of sending this, so reaching here + // means a bookmarked link outlived its session. + if (filters.favoritesOnly && !req.customerId) { + return res.status(401).json({ error: 'sign in to filter by favorites' }); + } + + const { clauses, params } = buildItemFilterSql(filters, 1, req.customerId ?? null); const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; const { rows } = await pool.query(`${PUBLIC_ITEM_SELECT} ${where} ORDER BY i.created_at DESC`, params); res.json(rows); diff --git a/backend/tests/integration/favorites.integration.test.ts b/backend/tests/integration/favorites.integration.test.ts index 0a020fa..127e71a 100644 --- a/backend/tests/integration/favorites.integration.test.ts +++ b/backend/tests/integration/favorites.integration.test.ts @@ -304,3 +304,89 @@ describe('notifying when a favorited item is deleted', () => { expect(rows[0].n).toBe(0); }); }); + +describe('filtering the storefront by favorites', () => { + it('returns only the favorited items', async () => { + const favorited = await createItem('Oak table'); + await createItem('Elm bench'); + const { agent } = await register('filter@example.com'); + await agent.post(`/api/customers/me/favorites/${favorited}`); + + const res = await agent.get('/api/items?favorites=1'); + + expect(res.status).toBe(200); + expect(res.body.map((item: { name: string }) => item.name)).toEqual(['Oak table']); + }); + + it('keeps one customer out of another customer\'s favorites', async () => { + const mine = await createItem('Oak table'); + const theirs = await createItem('Elm bench'); + const { agent: me } = await register('mine@example.com'); + const { agent: them } = await register('theirs@example.com'); + await me.post(`/api/customers/me/favorites/${mine}`); + await them.post(`/api/customers/me/favorites/${theirs}`); + + const res = await me.get('/api/items?favorites=1'); + + expect(res.body.map((item: { name: string }) => item.name)).toEqual(['Oak table']); + }); + + it('still shows a favorite that has sold', async () => { + const itemId = await createItem('Oak table'); + const { agent } = await register('sold@example.com'); + await agent.post(`/api/customers/me/favorites/${itemId}`); + await pool.query(`UPDATE items SET status = 'sold' WHERE id = $1`, [itemId]); + + const res = await agent.get('/api/items?favorites=1'); + + // 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. + expect(res.body.map((item: { name: string }) => item.name)).toEqual(['Oak table']); + }); + + it('combines with the other filters rather than replacing them', async () => { + const cheap = await createItem('Oak table'); + const dear = await createItem('Elm bench'); + await pool.query(`UPDATE items SET price_cents = 90000 WHERE id = $1`, [dear]); + const { agent } = await register('combined@example.com'); + await agent.post(`/api/customers/me/favorites/${cheap}`); + await agent.post(`/api/customers/me/favorites/${dear}`); + + const res = await agent.get('/api/items?favorites=1&max_price=50000'); + + expect(res.body.map((item: { name: string }) => item.name)).toEqual(['Oak table']); + }); + + it('answers 401 rather than an empty list when nobody is signed in', async () => { + await createItem('Oak table'); + + const res = await request(app).get('/api/items?favorites=1'); + + // An empty array would render as "no items match these filters", telling a + // signed-out visitor they have no favorites instead of that we do not know + // who they are. + expect(res.status).toBe(401); + }); + + it('rejects a favorites value that is neither on nor off', async () => { + const { agent } = await register('bogus@example.com'); + + expect((await agent.get('/api/items?favorites=yes')).status).toBe(400); + }); + + it('leaves the catalogue alone when the flag is off', async () => { + await createItem('Oak table'); + await createItem('Elm bench'); + const { agent } = await register('off@example.com'); + + const res = await agent.get('/api/items?favorites=0'); + + expect(res.body).toHaveLength(2); + }); + + it('refuses the favorites filter on the admin inventory', async () => { + const res = await request(app).get('/api/admin/items?favorites=1'); + + expect(res.status).toBe(400); + }); +}); diff --git a/backend/tests/unit/itemFilters.test.ts b/backend/tests/unit/itemFilters.test.ts index 95d1220..3f2a3f3 100644 --- a/backend/tests/unit/itemFilters.test.ts +++ b/backend/tests/unit/itemFilters.test.ts @@ -7,7 +7,8 @@ describe('parseItemFilters', () => { tagIds: [], minPriceCents: null, maxPriceCents: null, - status: null + status: null, + favoritesOnly: false }); }); @@ -85,6 +86,26 @@ describe('parseItemFilters', () => { expect(parseItemFilters({ status: '' }).status).toBeNull(); }); + it('parses the favorites flag in both spellings', () => { + expect(parseItemFilters({ favorites: '1' }).favoritesOnly).toBe(true); + expect(parseItemFilters({ favorites: 'true' }).favoritesOnly).toBe(true); + }); + + it('treats an explicit off value as off', () => { + expect(parseItemFilters({ favorites: '0' }).favoritesOnly).toBe(false); + expect(parseItemFilters({ favorites: 'false' }).favoritesOnly).toBe(false); + expect(parseItemFilters({ favorites: '' }).favoritesOnly).toBe(false); + }); + + it('defaults favorites to off', () => { + expect(parseItemFilters({}).favoritesOnly).toBe(false); + }); + + it('rejects a favorites value that is neither on nor off', () => { + expect(() => parseItemFilters({ favorites: 'yes' })).toThrow(FilterError); + expect(() => parseItemFilters({ favorites: 'mine' })).toThrow(FilterError); + }); + it('rejects a status outside the known set', () => { expect(() => parseItemFilters({ status: 'pending' })).toThrow(FilterError); }); @@ -96,19 +117,19 @@ describe('parseItemFilters', () => { describe('buildItemFilterSql', () => { it('produces no clauses and no params when nothing is filtered', () => { - const built = buildItemFilterSql(parseItemFilters({}), 1); + const built = buildItemFilterSql(parseItemFilters({}), 1, null); expect(built.clauses).toEqual([]); expect(built.params).toEqual([]); }); it('matches a category and all of its descendants', () => { - const built = buildItemFilterSql(parseItemFilters({ category: '4' }), 1); + const built = buildItemFilterSql(parseItemFilters({ category: '4' }), 1, null); expect(built.clauses.join(' ')).toContain('RECURSIVE'); expect(built.params).toEqual([4]); }); it('requires every listed tag rather than any of them', () => { - const built = buildItemFilterSql(parseItemFilters({ tags: '1,2' }), 1); + const built = buildItemFilterSql(parseItemFilters({ tags: '1,2' }), 1, null); // The count of matched tag rows must equal the number of tags requested — // an ANY/IN match alone would return items carrying just one of them. expect(built.clauses.join(' ')).toContain('COUNT(*)'); @@ -116,20 +137,41 @@ describe('buildItemFilterSql', () => { }); it('numbers placeholders from the given starting index', () => { - const built = buildItemFilterSql(parseItemFilters({ category: '4' }), 3); + const built = buildItemFilterSql(parseItemFilters({ category: '4' }), 3, null); expect(built.clauses.join(' ')).toContain('$3'); }); it('filters on status', () => { - const built = buildItemFilterSql(parseItemFilters({ status: 'reserved' }), 1); + const built = buildItemFilterSql(parseItemFilters({ status: 'reserved' }), 1, null); expect(built.clauses.join(' ')).toContain('i.status'); expect(built.params).toEqual(['reserved']); }); + it('restricts to the favorites of the given customer', () => { + const built = buildItemFilterSql(parseItemFilters({ favorites: '1' }), 1, 42); + expect(built.clauses.join(' ')).toContain('EXISTS'); + expect(built.clauses.join(' ')).toContain('favorites f'); + expect(built.params).toEqual([42]); + }); + + it('does not restrict to favorites when the flag is off, even given a customer', () => { + const built = buildItemFilterSql(parseItemFilters({}), 1, 42); + expect(built.clauses).toEqual([]); + expect(built.params).toEqual([]); + }); + + // Both routes reject this before reaching the builder, so it can only happen + // through a new caller that forgot to. Failing loudly beats dropping the + // clause and returning the whole catalogue as if it were someone's favorites. + it('throws rather than ignore a favorites filter with no customer', () => { + expect(() => buildItemFilterSql(parseItemFilters({ favorites: '1' }), 1, null)).toThrow(); + }); + it('continues numbering across multiple filters', () => { const built = buildItemFilterSql( parseItemFilters({ category: '4', min_price: '100', max_price: '900' }), - 1 + 1, + null ); expect(built.params).toEqual([4, 100, 900]); const sql = built.clauses.join(' '); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6ab30bf..c48b21e 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,6 +13,7 @@ import { filtersToSearchParams, hasActiveFilters } from './filters'; +import AuthPromptModal from './customer/AuthPromptModal'; import { useThemeMode } from './theme/ThemeContext'; import { useCustomerAuth } from './customer/CustomerAuthContext'; import { useCart } from './cart/CartContext'; @@ -30,9 +31,10 @@ export default function App() { const [failed, setFailed] = useState(false); const [options, setOptions] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); + const [authModalOpen, setAuthModalOpen] = useState(false); const [searchParams, setSearchParams] = useSearchParams(); const { mode, toggle } = useThemeMode(); - const { customer } = useCustomerAuth(); + const { customer, loading: authLoading } = useCustomerAuth(); const { items: cartItems } = useCart(); const { token } = theme.useToken(); @@ -41,6 +43,12 @@ export default function App() { const filters = useMemo(() => filtersFromSearchParams(searchParams), [searchParams]); const filterKey = filtersToSearchParams(filters).toString(); + // "Only my favorites" needs to know who is asking. Until the session has + // resolved we hold rather than guess: firing the request early would 401 and + // show the outage banner to someone who is in fact signed in. + const awaitingAuth = filters.favoritesOnly && authLoading; + const needsFavoritesAuth = filters.favoritesOnly && !authLoading && !customer; + const applyFilters = useCallback( (next: ItemFilters) => { // replace, not push: dragging a slider shouldn't bury the previous page @@ -68,10 +76,24 @@ export default function App() { }, [filterKey]); useEffect(() => { + if (awaitingAuth) { + setLoading(true); + return; + } + // Prompt instead of requesting. The server would answer 401, and rendering + // that as "no items match these filters" would tell a signed-out visitor + // they have no favorites rather than that we do not know who they are. + if (needsFavoritesAuth) { + setItems([]); + setFailed(false); + setLoading(false); + setAuthModalOpen(true); + return; + } setLoading(true); const timer = setTimeout(load, FILTER_DEBOUNCE_MS); return () => clearTimeout(timer); - }, [load]); + }, [load, awaitingAuth, needsFavoritesAuth]); useEffect(() => { fetchFilterOptions().then(setOptions).catch(() => setOptions(null)); @@ -141,6 +163,11 @@ export default function App() { description="The server didn't return the catalogue. This is usually temporary." action={} /> + ) : needsFavoritesAuth ? ( + + + + ) : !loading && !items.length ? ( + + {/* The same prompt the heart button and Add to Cart use. Signing in + resolves the gate above, and the filter then applies on its own — the + customer never has to set it a second time. */} + setAuthModalOpen(false)} + onSuccess={() => setAuthModalOpen(false)} + /> ); } diff --git a/frontend/src/components/ActiveFilterChips.tsx b/frontend/src/components/ActiveFilterChips.tsx index 2a4fb63..75186f8 100644 --- a/frontend/src/components/ActiveFilterChips.tsx +++ b/frontend/src/components/ActiveFilterChips.tsx @@ -18,6 +18,16 @@ export default function ActiveFilterChips({ options, filters, onChange, onClear const chips: { key: string; label: string; onRemove: () => void }[] = []; + // Listed first so it matches the drawer's ordering, and because it is the + // chip most worth noticing when a customer wonders why the grid looks short. + if (filters.favoritesOnly) { + chips.push({ + key: 'favorites', + label: 'My favorites', + onRemove: () => onChange({ ...filters, favoritesOnly: false }) + }); + } + if (filters.categoryId !== null) { const path = categoryPath(categories, filters.categoryId); // Falls back to the raw id while /api/filters is still loading, so the chip diff --git a/frontend/src/components/FilterDrawer.tsx b/frontend/src/components/FilterDrawer.tsx index c8ad228..351f490 100644 --- a/frontend/src/components/FilterDrawer.tsx +++ b/frontend/src/components/FilterDrawer.tsx @@ -5,6 +5,7 @@ import Tag from 'antd/es/tag'; import Slider from 'antd/es/slider'; import InputNumber from 'antd/es/input-number'; import Empty from 'antd/es/empty'; +import Switch from 'antd/es/switch'; import Grid from 'antd/es/grid'; import type { DataNode } from 'antd/es/tree'; import type { FilterOptions } from '../api'; @@ -81,6 +82,28 @@ export default function FilterDrawer({ } > + {/* First because it is the broadest cut, and because a customer who came + here for their favorites should not have to scroll past the catalogue + controls to find it. Shown to signed-out visitors too: switching it on + prompts them to sign in, which is how they learn favorites exist. */} +
+

+ Favorites +

+ {/* Deliberately not wrapped in a
+

Category diff --git a/frontend/src/filters.ts b/frontend/src/filters.ts index afcd054..eb24c42 100644 --- a/frontend/src/filters.ts +++ b/frontend/src/filters.ts @@ -10,6 +10,10 @@ export interface ItemFilters { // Only the admin Inventory tab sets this; the storefront leaves it null and // shows every status, as it always has. status: ItemStatus | null; + // Storefront only, and only meaningful when signed in. 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 to look at. + favoritesOnly: boolean; } export const EMPTY_FILTERS: ItemFilters = { @@ -17,7 +21,8 @@ export const EMPTY_FILTERS: ItemFilters = { tagIds: [], minPriceCents: null, maxPriceCents: null, - status: null + status: null, + favoritesOnly: false }; // Filters live in the URL so a filtered view can be linked, bookmarked, and @@ -30,6 +35,7 @@ export function filtersToSearchParams(filters: ItemFilters): URLSearchParams { if (filters.minPriceCents !== null) params.set('min_price', String(filters.minPriceCents)); if (filters.maxPriceCents !== null) params.set('max_price', String(filters.maxPriceCents)); if (filters.status !== null) params.set('status', filters.status); + if (filters.favoritesOnly) params.set('favorites', '1'); return params; } @@ -50,12 +56,15 @@ export function filtersFromSearchParams(params: URLSearchParams): ItemFilters { ? rawStatus : null; + const favorites = params.get('favorites'); + return { categoryId: readInt(params.get('category')), tagIds: tags, minPriceCents: readInt(params.get('min_price')), maxPriceCents: readInt(params.get('max_price')), - status + status, + favoritesOnly: favorites === '1' || favorites === 'true' }; } @@ -67,6 +76,7 @@ export function activeFilterCount(filters: ItemFilters): number { count += filters.tagIds.length; if (filters.minPriceCents !== null || filters.maxPriceCents !== null) count++; if (filters.status !== null) count++; + if (filters.favoritesOnly) count++; return count; } diff --git a/frontend/tests/e2e/favorites-filter.spec.ts b/frontend/tests/e2e/favorites-filter.spec.ts new file mode 100644 index 0000000..b47f921 --- /dev/null +++ b/frontend/tests/e2e/favorites-filter.spec.ts @@ -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'); + }); +});