/** * LTF-series (UI): Shopping list title flow — required title, default prefill, * auto-numbered `#N`, admin catalog CRUD. Fase 18. * * Live Keycloak login per test (no cached storageState). The admin specs run * as Ana; the read-only spec runs as Borja (member). All specs land on a * fresh modal each time, so we don't need to reset DB state between cases — * we tag created rows with a unique timestamp suffix. */ import { test, expect, type Page } from '@playwright/test'; import { USERS } from '../fixtures/users.js'; import { loginAs } from '../fixtures/login.js'; /** Click "New list" on the masthead and wait for the modal to mount. */ async function openCreateListModal(page: Page) { await page.getByRole('button', { name: /new list|nueva lista/i }).click(); await expect(page.getByTestId('create-list-modal')).toBeVisible({ timeout: 5_000 }); } /** * Set `collectives.default_list_title` by driving the in-page Supabase * client directly. Faster + more deterministic than driving the manage UI, * which lives in a separate spec (LTF-02 / LTF-04). */ async function setDefaultListTitle(page: Page, title: string) { // Land on any auth-gated route so __sb is exposed and the active // collective is loaded. await page.goto('/lists'); await page.waitForFunction( () => Boolean((window as unknown as { __sb?: unknown }).__sb), null, { timeout: 10_000 } ); await page.evaluate(async (value) => { const sb = ( window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient } ).__sb; await sb .from('collectives') .update({ default_list_title: value && value.length > 0 ? value : null }) .eq('id', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'); }, title); // Force a reload so the root layout's `loadUserCollectives` hydrates // `$currentCollective.default_list_title` with the updated value before // the modal opens. await page.reload(); await expect( page.getByRole('button', { name: /new list|nueva lista/i }) ).toBeVisible({ timeout: 15_000 }); } test.describe('Shopping list title flow — admin (Ana)', () => { test.beforeEach(async ({ page }) => { await loginAs(page, USERS.ana); }); test('LTF-01: default_list_title prefills the create modal and submits as-is', async ({ page }) => { const defaultTitle = `LTF01 ${Date.now()}`; await setDefaultListTitle(page, defaultTitle); // `setDefaultListTitle` already navigates to /lists after the reload. await openCreateListModal(page); // Input is prefilled with the configured default. const input = page.getByTestId('create-list-modal-name'); await expect(input).toHaveValue(defaultTitle); // Submit creates a list with the prefilled name — no `#1` suffix // because the default was never added to the catalog (auto-suffix is // catalog-gated). await page.getByTestId('create-list-modal-submit').click(); await expect(page).toHaveURL(/\/lists\/[0-9a-f-]+$/, { timeout: 10_000 }); // List name lives in an `` on the detail page (inline rename). const titleInput = page.locator('input[aria-label]').filter({ has: page.locator(':scope[aria-label*="list name" i], :scope[aria-label*="nombre" i]') }); // Fallback assertion that's robust to label changes: assert the input // value directly via locator.inputValue() once the input is mounted. await expect.poll(async () => { const inputs = await page.locator('input').all(); for (const inp of inputs) { const v = await inp.inputValue().catch(() => ''); if (v === defaultTitle) return v; } return ''; }, { timeout: 5_000 }).toBe(defaultTitle); // Silence the unused locator warning — we keep titleInput so the // intent is clear if the inputValue poll is ever rewritten. void titleInput; }); test('LTF-02: catalog entry drives auto-suffix on the third "Compra" creation', async ({ page }) => { const prefix = `LTF02-${Date.now()}`; // 1) Add the prefix to the catalog via the manage UI (the path under // test). Then add it to default_list_title via the same UI so the // modal prefills it consistently. await page.goto('/collective/manage'); await expect(page.getByTestId('list-titles-add-button')).toBeVisible({ timeout: 10_000 }); await page.getByTestId('list-titles-add-button').click(); await expect(page.getByTestId('list-titles-add-modal')).toBeVisible({ timeout: 5_000 }); await page.getByTestId('list-titles-add-name').fill(prefix); await page.getByTestId('list-titles-add-save').click(); await expect(page.getByTestId('list-titles-add-modal')).not.toBeVisible({ timeout: 5_000 }); await expect( page.getByTestId(`list-title-row-${prefix}`) ).toBeVisible({ timeout: 5_000 }); const defaultInput = page.getByTestId('manage-default-list-title-input'); await defaultInput.fill(prefix); await defaultInput.blur(); // Wait briefly for the UPDATE round-trip to land. await page.waitForTimeout(700); // Helper that drives the modal once: open → submit (prefilled) → wait // for navigation, then bounce back to /lists. async function createOne(): Promise { await page.goto('/lists'); await openCreateListModal(page); // The prefill should be the catalog prefix verbatim — the modal // auto-suffixes "#N" on submit when the value matches a catalog // entry exactly without a "#N" already. await expect(page.getByTestId('create-list-modal-name')).toHaveValue(prefix); await page.getByTestId('create-list-modal-submit').click(); await expect(page).toHaveURL(/\/lists\/[0-9a-f-]+$/, { timeout: 10_000 }); } // 1st create → no prior matches → finalName === `${prefix}` (bare; // computeNextNumber returns 1 → but rule 9: still fires auto-suffix // when catalog match holds, so finalName === `${prefix} #1`). await createOne(); // 2nd create → prior is `prefix #1` → finalName === `${prefix} #2`. await createOne(); // 3rd create → prior set has {#1, #2} → finalName === `${prefix} #3`. await createOne(); // Visit /lists and confirm the third numbered list exists. We don't // assert the first two by name because the exact #N depends on race // timing of the realtime echoes; what matters is that the catalog // auto-suffix produced unique numbered names and the third one // landed at #3 (no duplicates of bare prefix). await page.goto('/lists'); await expect( page.getByRole('heading', { name: new RegExp(`^${prefix} #3$`) }) ).toBeVisible({ timeout: 10_000 }); }); test('LTF-03: empty input keeps the submit button disabled', async ({ page }) => { // Make sure no default is set so the input opens blank. await setDefaultListTitle(page, ''); // `setDefaultListTitle` already navigates to /lists after the reload. await openCreateListModal(page); const input = page.getByTestId('create-list-modal-name'); const submit = page.getByTestId('create-list-modal-submit'); await expect(input).toHaveValue(''); await expect(submit).toBeDisabled(); // Typing then erasing should keep the disabled state when only spaces // remain (trim-then-check). await input.fill(' '); await expect(submit).toBeDisabled(); await input.fill('something'); await expect(submit).toBeEnabled(); }); }); test.describe('Shopping list title flow — member (Borja, read-only)', () => { test.beforeEach(async ({ page }) => { await loginAs(page, USERS.borja); }); test('LTF-04: member sees the section but the controls are disabled', async ({ page }) => { await page.goto('/collective/manage'); await expect(page.getByTestId('list-titles-section')).toBeVisible({ timeout: 15_000 }); // No "Add title" button for non-admins. await expect(page.getByTestId('list-titles-add-button')).not.toBeVisible(); // Read-only banner is visible. await expect(page.getByTestId('list-titles-readonly-banner')).toBeVisible(); // The default-title input renders but is disabled. await expect(page.getByTestId('manage-default-list-title-input')).toBeDisabled(); }); });