/** * C-series (UI): Shopping list CRUD flows — create, complete, trash. * * Live Keycloak login per test (no cached storageState). See fixtures/login.ts * and the comment in $lib/supabase for why the Supabase client uses a * pass-through lock. */ import { test, expect, type Page } from '@playwright/test'; import { USERS } from '../fixtures/users.js'; import { loginAs } from '../fixtures/login.js'; /** Heading for a list — may render as h2 (featured card) or h3 (grid item). */ function listHeading(page: Page, name: string) { return page.getByRole('heading', { name: new RegExp(`^${name}$`) }); } /** * The action menu button inside the card for a specific list. The DOM has one * "List actions" button per active card, so we scope by the card wrapper * (ancestor of the heading) — this avoids the strict-mode ambiguity of picking * from many grid siblings. */ function cardActionsButton(page: Page, name: string) { // The card wrapper is the `
` two levels above // the `

` (or one level above `

` for featured). An xpath that walks // up until it finds the button gives us a stable selector. return page .locator( `xpath=//h2[normalize-space()="${name}"]/ancestor::div[.//button[@aria-label]][1]` + ` | //h3[normalize-space()="${name}"]/ancestor::div[.//button[@aria-label]][1]` ) .first() .getByRole('button', { name: /list actions|acciones de lista/i }); } async function gotoListsClean(page: Page) { await page.goto('/lists'); // The masthead "New list" button is the anchor now (replaces the sticky input). await expect( page.getByRole('button', { name: /new list|nueva lista/i }) ).toBeVisible({ timeout: 15_000 }); } async function createList(page: Page, name: string) { // Fase 18: "New list" now opens a create modal instead of jumping straight // to the detail page with an empty name. Type the name, submit, then // bounce back to /lists so callers see the new card in the grid. await page.getByRole('button', { name: /new list|nueva lista/i }).click(); await expect(page.getByTestId('create-list-modal')).toBeVisible({ timeout: 5_000 }); const input = page.getByTestId('create-list-modal-name'); await input.fill(name); await page.getByTestId('create-list-modal-submit').click(); await expect(page).toHaveURL(/\/lists\/[0-9a-f-]+$/, { timeout: 10_000 }); await page.goto('/lists'); await expect(listHeading(page, name)).toBeVisible({ timeout: 10_000 }); } test.describe('Shopping lists — member (Borja)', () => { test.beforeEach(async ({ page }) => { await loginAs(page, USERS.borja); }); test('C-07: create a new shopping list', async ({ page }) => { await gotoListsClean(page); const listName = `E2E list ${Date.now()}`; await createList(page, listName); // Reload to prove the list persists server-side (not just optimistic). await page.reload(); await expect(listHeading(page, listName)).toBeVisible({ timeout: 15_000 }); }); test('C-11: soft-delete a list — it moves to trash', async ({ page }) => { await gotoListsClean(page); const listName = `Trash me ${Date.now()}`; await createList(page, listName); // Scope the menu button to the card containing our new heading — it's // the nearest ancestor that also has a List actions button. await cardActionsButton(page, listName).click(); await page.getByRole('button', { name: /move to trash|enviar a la papelera/i }).click(); await expect(listHeading(page, listName)).not.toBeVisible({ timeout: 5_000 }); // It shows up under the Trash drawer. await page.getByRole('button', { name: /^trash$|^papelera$/i }).first().click(); await expect(page.getByText(listName)).toBeVisible({ timeout: 5_000 }); }); test('C-12: archive a list — it moves out of the active grid', async ({ page }) => { await gotoListsClean(page); const listName = `Archive me ${Date.now()}`; await createList(page, listName); await cardActionsButton(page, listName).click(); await page.getByRole('button', { name: /^archive$|^archivar$/i }).click(); // Active grid no longer shows the archived list. await expect(listHeading(page, listName)).not.toBeVisible({ timeout: 5_000 }); }); }); test.describe('Shopping lists — guest (David, read-only)', () => { test.beforeEach(async ({ page }) => { await loginAs(page, USERS.david); }); test('C-13: guest sees the seed list (read-only)', async ({ page }) => { await page.goto('/lists'); await expect( page.getByRole('button', { name: /Casa García-López/ }) ).toBeVisible({ timeout: 15_000 }); await expect(listHeading(page, 'Weekly shop')).toBeVisible({ timeout: 15_000 }); // Fase 18: the entry point is the masthead "New list" button which opens // a modal. Guests CAN open the modal (we don't gate it client-side), // but the actual INSERT is blocked by RLS — `shopping_lists_insert` // requires `is_active_member(collective_id)` which excludes role=guest. // Try to drive the modal and confirm no committed row appears. const newListBtn = page.getByRole('button', { name: /new list|nueva lista/i }); if (await newListBtn.isVisible({ timeout: 2_000 }).catch(() => false)) { await newListBtn.click(); const modalVisible = await page .getByTestId('create-list-modal') .isVisible({ timeout: 2_000 }) .catch(() => false); if (modalVisible) { const name = `David sneaky ${Date.now()}`; await page.getByTestId('create-list-modal-name').fill(name); await page.getByTestId('create-list-modal-submit').click(); await page.waitForTimeout(1_500); await page.goto('/lists'); await expect(listHeading(page, name)).not.toBeVisible({ timeout: 2_000 }); } } }); });