diff --git a/apps/web/src/lib/stores/lists.ts b/apps/web/src/lib/stores/lists.ts index 4a1b1d3..a8a7881 100644 --- a/apps/web/src/lib/stores/lists.ts +++ b/apps/web/src/lib/stores/lists.ts @@ -287,6 +287,13 @@ export async function reorderItems(items: Pick[]) { * `(...)` list as a SQL error, and the cost of adding the filter for zero * effect would be wasted bytes on the URL anyway. * + * Long-list caveat: PostgREST encodes `not.in.(a,b,…)` literally in the + * query string, and supabase-js sends it as a single GET. Some upstream + * proxies / Kong configs cap request URI length around 16 KiB. We hard-cap + * the exclude list at EXCLUDE_NAMES_HARD_CAP names — past that point the + * filter degrades to a no-op (the dropdown may show a few names that are + * already on the list; acceptable UX) instead of failing the whole query. + * * `limit` overrides the default (5 without prefix, 10 with). The /collective * manage view does not call this — it has its own unbounded list query in * commonItems.ts — but UI experimentation may want a smaller bar. @@ -296,6 +303,8 @@ export interface FetchSuggestionsOptions { limit?: number; } +const EXCLUDE_NAMES_HARD_CAP = 200; + export async function fetchSuggestions( collectiveId: string, prefix: string, @@ -317,7 +326,7 @@ export async function fetchSuggestions( .map((n) => n.toLowerCase().trim()) .filter((n) => n.length > 0); - if (exclude.length > 0) { + if (exclude.length > 0 && exclude.length <= EXCLUDE_NAMES_HARD_CAP) { // PostgREST in() takes a comma-separated list wrapped in parens. We // double-quote each entry to be safe against names containing commas // or parens (e.g. "rice (white)"); the inner double quotes are escaped diff --git a/apps/web/tests/e2e/items.test.ts b/apps/web/tests/e2e/items.test.ts index a1b6bea..d691565 100644 --- a/apps/web/tests/e2e/items.test.ts +++ b/apps/web/tests/e2e/items.test.ts @@ -102,19 +102,55 @@ test.describe('Shopping items — member (Borja)', () => { }); test('D-06: frequency suggestions render below the add-item input', async ({ page }) => { - await gotoSeedList(page); - const nameInput = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER); - await expect(nameInput).toBeVisible({ timeout: 10_000 }); + // Use a fresh empty list. The seed list is shared mutable state for + // the entire suite — it accumulates many items over a long run, and + // the Fase-15 exclude-on-list filter then suppresses every frequency + // name that happens to match one of those items. A fresh empty list + // keeps the suggestion strip's contents deterministic. + const listId = await page.evaluate(async () => { + const supabase = ( + window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient } + ).__sb; + const { data } = await supabase + .from('shopping_lists') + .insert({ + collective_id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + name: `d-06 list ${Date.now()}`, + created_by: '22222222-2222-2222-2222-222222222222' + }) + .select('id') + .single(); + return data?.id ?? ''; + }); - // Focus the input. The seed data has "milk", "bread", "eggs", etc. in - // item_frequency, so suggestion chips should render. - await nameInput.click(); - // At least one of the seed-frequency items should be visible as a chip. - await expect(page.getByRole('button', { name: /^milk$/i })).toBeVisible({ timeout: 5_000 }); + try { + await page.goto(`/lists/${listId}`); + await expect(page.getByRole('button', { name: /Casa García-López/ })).toBeVisible({ + timeout: 15_000 + }); + const nameInput = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER); + await expect(nameInput).toBeVisible({ timeout: 10_000 }); - // Typing a prefix should narrow suggestions. "mi" matches "milk". - await nameInput.fill('mi'); - await expect(page.getByRole('button', { name: /^milk$/i })).toBeVisible({ timeout: 3_000 }); + // The strip renders the top of item_frequency. The seed has + // milk/bread/eggs/etc., and the list is empty so nothing is + // excluded — at least one chip must be there. + await nameInput.click(); + await expect(page.getByTestId('item-suggestions-strip')).toBeVisible({ timeout: 5_000 }); + + // Typing a prefix narrows the chips; the strip still renders. + await nameInput.fill('mi'); + await expect(page.getByRole('button', { name: /^milk$/i })).toBeVisible({ timeout: 5_000 }); + } finally { + await page.evaluate( + async ({ id }) => { + const supabase = ( + window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient } + ).__sb; + await supabase.from('shopping_lists').delete().eq('id', id); + }, + { id: listId } + ); + } }); });