fix(fase-15): cap excludeNames at 200 + update D-06 for excluded suggestions

Two regressions surfaced when running the full e2e suite against the
shared dev DB (which accumulates hundreds of items in the seed list
over the lifetime of the test stack):

  - fetchSuggestions URL ballooned past Kong's ~16 KiB request URI cap
    when excludeNames carried 300+ names, freezing the query and
    breaking the realtime + reorder-items + section-visibility tests
    (which co-locate two browser contexts hitting the same list). Cap
    at 200 names; past that the filter degrades to a no-op (the
    dropdown may surface a few names already on the list, which is
    fine UX for a long-running shared list).
  - items.test.ts D-06 (frequency suggestions visible) was asserting
    on "milk" which is in the seed list — Fase 15 correctly suppresses
    it now. Route the test through a freshly-created empty list so
    the strip is deterministic.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 12:16:15 +02:00
parent a3fb423596
commit 6a02d6d5ab
2 changed files with 57 additions and 12 deletions

View File

@@ -287,6 +287,13 @@ export async function reorderItems(items: Pick<ShoppingItem, 'id'>[]) {
* `(...)` list as a SQL error, and the cost of adding the filter for zero * `(...)` list as a SQL error, and the cost of adding the filter for zero
* effect would be wasted bytes on the URL anyway. * 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 * `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 * manage view does not call this — it has its own unbounded list query in
* commonItems.ts — but UI experimentation may want a smaller bar. * commonItems.ts — but UI experimentation may want a smaller bar.
@@ -296,6 +303,8 @@ export interface FetchSuggestionsOptions {
limit?: number; limit?: number;
} }
const EXCLUDE_NAMES_HARD_CAP = 200;
export async function fetchSuggestions( export async function fetchSuggestions(
collectiveId: string, collectiveId: string,
prefix: string, prefix: string,
@@ -317,7 +326,7 @@ export async function fetchSuggestions(
.map((n) => n.toLowerCase().trim()) .map((n) => n.toLowerCase().trim())
.filter((n) => n.length > 0); .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 // PostgREST in() takes a comma-separated list wrapped in parens. We
// double-quote each entry to be safe against names containing commas // double-quote each entry to be safe against names containing commas
// or parens (e.g. "rice (white)"); the inner double quotes are escaped // or parens (e.g. "rice (white)"); the inner double quotes are escaped

View File

@@ -102,19 +102,55 @@ test.describe('Shopping items — member (Borja)', () => {
}); });
test('D-06: frequency suggestions render below the add-item input', async ({ page }) => { test('D-06: frequency suggestions render below the add-item input', async ({ page }) => {
await gotoSeedList(page); // Use a fresh empty list. The seed list is shared mutable state for
const nameInput = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER); // the entire suite — it accumulates many items over a long run, and
await expect(nameInput).toBeVisible({ timeout: 10_000 }); // 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 try {
// item_frequency, so suggestion chips should render. await page.goto(`/lists/${listId}`);
await nameInput.click(); await expect(page.getByRole('button', { name: /Casa García-López/ })).toBeVisible({
// At least one of the seed-frequency items should be visible as a chip. timeout: 15_000
await expect(page.getByRole('button', { name: /^milk$/i })).toBeVisible({ timeout: 5_000 }); });
const nameInput = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER);
await expect(nameInput).toBeVisible({ timeout: 10_000 });
// Typing a prefix should narrow suggestions. "mi" matches "milk". // The strip renders the top of item_frequency. The seed has
await nameInput.fill('mi'); // milk/bread/eggs/etc., and the list is empty so nothing is
await expect(page.getByRole('button', { name: /^milk$/i })).toBeVisible({ timeout: 3_000 }); // 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 }
);
}
}); });
}); });