Files
collective-lists/apps/web/tests/e2e/items.test.ts
Oier Bravo Urtasun 6a02d6d5ab 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>
2026-05-18 12:16:15 +02:00

182 lines
7.1 KiB
TypeScript

/**
* D-series (UI): Shopping item CRUD flows on `/lists/[id]`.
*
* Each test does a full Keycloak login. The seed list ("Weekly shop",
* id bbbb...) is created by supabase/seed.sql and reused read-only by all
* assertions that don't mutate it. Tests that do mutate (add / check / delete)
* use items whose names include the Date.now() stamp so they're unique.
*/
import { test, expect, type Page } from '@playwright/test';
import { USERS } from '../fixtures/users.js';
import { loginAs } from '../fixtures/login.js';
const SEED_LIST_ID = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
const SEED_LIST_PATH = `/lists/${SEED_LIST_ID}`;
const ADD_ITEM_PLACEHOLDER = /add item|añadir producto/i;
async function gotoSeedList(page: Page) {
await page.goto(SEED_LIST_PATH);
// Sidebar collective loaded = we're authenticated and the page has hydrated.
await expect(page.getByRole('button', { name: /Casa García-López/ })).toBeVisible({
timeout: 15_000
});
}
/** Find the row for a given item. The template sets role="listitem" per row. */
function itemRow(page: Page, name: string) {
return page.locator('[role="listitem"]').filter({ hasText: name }).first();
}
test.describe('Shopping items — member (Borja)', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, USERS.borja);
});
test('D-01: can open the seed list and see its items', async ({ page }) => {
await gotoSeedList(page);
// Seed items: Milk, Bread, Eggs, Coffee, Apples. Scope to listitem rows
// so we don't collide with the suggestion chips that share the same text.
await expect(itemRow(page, 'Milk')).toBeVisible({ timeout: 10_000 });
await expect(itemRow(page, 'Bread')).toBeVisible();
await expect(itemRow(page, 'Coffee')).toBeVisible();
});
test('D-02: can add a new item via the sticky form', async ({ page }) => {
await gotoSeedList(page);
const nameInput = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER);
await expect(nameInput).toBeVisible({ timeout: 10_000 });
const itemName = `E2E-item-${Date.now()}`;
await nameInput.fill(itemName);
await nameInput.press('Enter');
await expect(page.getByText(itemName)).toBeVisible({ timeout: 5_000 });
// Reload to confirm the item persisted (not just optimistic).
await page.reload();
await expect(page.getByText(itemName)).toBeVisible({ timeout: 10_000 });
});
test('D-03: double-tapping a row opens the edit overlay (new interaction)', async ({ page }) => {
await gotoSeedList(page);
const nameInput = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER);
await expect(nameInput).toBeVisible({ timeout: 10_000 });
const itemName = `Check-me-${Date.now()}`;
await nameInput.fill(itemName);
await nameInput.press('Enter');
const row = itemRow(page, itemName);
await expect(row).toBeVisible({ timeout: 5_000 });
// Double-tap opens the overlay — the new edit path.
await row.getByRole('button', { name: itemName }).dblclick();
const overlay = page.getByTestId('edit-overlay');
await expect(overlay).toBeVisible({ timeout: 3_000 });
await expect(overlay.getByPlaceholder(/add item|añadir producto/i)).toHaveValue(itemName);
// Close the overlay without saving — X button.
await overlay.getByRole('button', { name: /close|cerrar/i }).click();
await expect(overlay).not.toBeVisible({ timeout: 3_000 });
});
test('D-04: overlay Delete button removes the row with an undo toast', async ({ page }) => {
await gotoSeedList(page);
const nameInput = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER);
await expect(nameInput).toBeVisible({ timeout: 10_000 });
const itemName = `Delete-me-${Date.now()}`;
await nameInput.fill(itemName);
await nameInput.press('Enter');
const row = itemRow(page, itemName);
await expect(row).toBeVisible({ timeout: 5_000 });
// Double-tap → overlay → Delete. (The swipe gesture is a toggle now,
// not a delete; delete lives behind the overlay.)
await row.getByRole('button', { name: itemName }).dblclick();
const overlay = page.getByTestId('edit-overlay');
await expect(overlay).toBeVisible({ timeout: 3_000 });
await overlay.getByRole('button', { name: /^delete$|^eliminar$/i }).click();
// Row gone from the list; toast scoped out via itemRow locator.
await expect(itemRow(page, itemName)).not.toBeVisible({ timeout: 5_000 });
});
test('D-06: frequency suggestions render below the add-item input', async ({ page }) => {
// 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 ?? '';
});
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 });
// 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 }
);
}
});
});
test.describe('Shopping items — guest (David, read-only)', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, USERS.david);
});
test('D-10: David sees seed items but cannot commit a new one', async ({ page }) => {
await gotoSeedList(page);
await expect(itemRow(page, 'Milk')).toBeVisible({ timeout: 15_000 });
// Either the add input is hidden for guests, or typing + Enter does not
// commit a persisted item (RLS blocks the insert → the optimistic row,
// if any, rolls back).
const input = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER);
const visible = await input.isVisible({ timeout: 2_000 }).catch(() => false);
if (visible) {
const name = `Guest-sneaky-${Date.now()}`;
await input.fill(name);
await input.press('Enter');
await page.waitForTimeout(1_500);
await page.reload();
// After reload, nothing persisted on the server should render.
await expect(page.getByText(name)).not.toBeVisible({ timeout: 5_000 });
}
});
});