/** * CI-series — Common items management (Fase 15). * * CI-01 Ana boosts "milk" → in /lists/ the dropdown shows it first * even though seeded use_counts put other items higher. * CI-02 Ana adds "pan" to the active list → the dropdown stops including * it on the next keystroke. * CI-03 Borja (member) opens /collective/manage → he sees the table but * the action buttons are disabled. * CI-04 Ana purges a row → the row disappears from the table and the * dropdown stops showing it. * * Note: CI-02 lives in this file but exercises the /lists/[id] page, not * /collective/manage — it pairs with the exclude-on-list wiring landing * in the next commit (15.4). It is listed here because it is part of the * Fase-15 contract. */ import { test, expect } from '@playwright/test'; import { USERS } from '../fixtures/users.js'; import { loginAs } from '../fixtures/login.js'; const SEED_COLLECTIVE_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; const SEED_LIST_ID = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; const PREFIX = 'ci-e2e-'; /** * Reset every item_frequency row this suite touched, and clear any boost * we left on a seeded name. Runs via the page's `__sb` hook (service-role * not exposed in the browser, so we go through Ana — who is admin in the * seed collective — and the RPCs gate accordingly). */ async function resetCommonItems(page: import('@playwright/test').Page): Promise { await page.waitForFunction( () => Boolean((window as unknown as { __sb?: unknown }).__sb), null, { timeout: 10_000 } ); await page.evaluate( async ({ collectiveId }) => { const supabase = ( window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient } ).__sb; // Purge every name this suite is known to mutate. Direct table // deletes are blocked by the deny-all RLS from migration 006; // the admin RPC is the only delete path available to the browser. for (const name of ['ci02alpha', 'ci02beta']) { try { await supabase.rpc('purge_item_frequency', { p_collective_id: collectiveId, p_name: name }); } catch { /* ignore */ } } // Reset weight on seeded names this suite mutates so a re-run starts // from a known place. for (const name of ['milk', 'bread', 'eggs', 'apples', 'butter', 'yogurt', 'coffee']) { try { await supabase.rpc('set_item_frequency_weight', { p_collective_id: collectiveId, p_name: name, p_weight: 0 }); } catch { /* ignore */ } } }, { collectiveId: SEED_COLLECTIVE_ID } ); } test.describe('Common items management', () => { test.beforeEach(async ({ page }) => { await loginAs(page, USERS.ana); await resetCommonItems(page); }); test.afterEach(async ({ page }) => { try { await resetCommonItems(page); } catch { /* don't mask original failure */ } }); test('CI-01: Ana boosts "yogurt" — dropdown surfaces it ahead of higher-count items', async ({ page }) => { // Use a freshly-created list. The seed list is shared with the entire // suite and tends to be polluted with hundreds of test items, which // shifts the dropdown selection (other items end up with high // use_counts from previous runs). await page.goto('/collective/manage'); await page.waitForLoadState('networkidle'); const yogurtRow = page.getByTestId('common-item-row-yogurt'); await expect(yogurtRow).toBeVisible({ timeout: 10_000 }); await yogurtRow.getByTestId('common-item-boost-yogurt').click(); await expect(yogurtRow).toHaveAttribute('data-weight-state', 'boost', { timeout: 5_000 }); // Create an isolated empty list so the seed-pollution doesn't fill the // dropdown with hundreds of other names. 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: `ci-01 list ${Date.now()}`, created_by: '11111111-1111-1111-1111-111111111111' }) .select('id') .single(); return data?.id ?? ''; }); await page.goto(`/lists/${listId}`); await page.waitForLoadState('networkidle'); const strip = page.getByTestId('item-suggestions-strip'); await expect(strip).toBeVisible({ timeout: 10_000 }); // Yogurt now leads the dropdown thanks to its boost (weight=50). const firstChip = strip.locator('button').first(); await expect(firstChip).toHaveText('yogurt'); // Cleanup 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('CI-03: Borja sees the table read-only with disabled actions', async ({ browser }) => { // Seed the data as Ana first so there's something for Borja to see. // (Default seed already has milk/bread/etc.) const borjaCtx = await browser.newContext(); const borja = await borjaCtx.newPage(); try { await loginAs(borja, USERS.borja); await borja.goto('/collective/manage'); await borja.waitForLoadState('networkidle'); const milkRow = borja.getByTestId('common-item-row-milk'); await expect(milkRow).toBeVisible({ timeout: 10_000 }); // Hide / Normal / Boost buttons must all be disabled for members. const hideBtn = milkRow.getByTestId('common-item-hide-milk'); const normalBtn = milkRow.getByTestId('common-item-normal-milk'); const boostBtn = milkRow.getByTestId('common-item-boost-milk'); await expect(hideBtn).toBeDisabled(); await expect(normalBtn).toBeDisabled(); await expect(boostBtn).toBeDisabled(); // Purge button must be hidden or disabled for members. const purgeBtn = milkRow.getByTestId('common-item-purge-milk'); await expect(purgeBtn).toBeDisabled(); // The "Add common item" button must not appear for members. await expect(borja.getByTestId('common-item-add-button')).toHaveCount(0); } finally { await borjaCtx.close(); } }); test('CI-02: items already in the list disappear from the suggestion strip', async ({ page }) => { // Create a fresh empty list (the seed "Weekly shop" is shared with the // entire suite and tends to be polluted with hundreds of test items, // which both buries the assertion target and inflates the excludeNames // payload). Seed two item_frequency entries we control completely. 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: `ci-02 list ${Date.now()}`, created_by: '11111111-1111-1111-1111-111111111111' }) .select('id') .single(); return data?.id ?? ''; }); expect(listId).toBeTruthy(); // Boost both names heavily so they lead the dropdown regardless of // the pollution accumulated in item_frequency from other test runs. await page.evaluate( async ({ collective }) => { const supabase = ( window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient } ).__sb; await supabase.rpc('set_item_frequency_weight', { p_collective_id: collective, p_name: 'ci02alpha', p_weight: 100 }); await supabase.rpc('set_item_frequency_weight', { p_collective_id: collective, p_name: 'ci02beta', p_weight: 100 }); }, { collective: SEED_COLLECTIVE_ID } ); await page.goto(`/lists/${listId}`); await page.waitForLoadState('networkidle'); const strip = page.getByTestId('item-suggestions-strip'); await expect(strip).toBeVisible({ timeout: 10_000 }); // Both seeded names appear in the dropdown (list is empty so nothing // is excluded yet). await expect(page.getByTestId('item-suggestion-ci02alpha')).toHaveCount(1); await expect(page.getByTestId('item-suggestion-ci02beta')).toHaveCount(1); // Click the ci02alpha chip — the selectSuggestion handler fills the // input and refocuses it; press Enter to submit. await page.getByTestId('item-suggestion-ci02alpha').click(); const addForm = page.getByTestId('add-item-form'); // The first input inside add-item-form is the name field. await addForm.locator('input').first().press('Enter'); // excludedItemNames recompute drops ci02alpha from the strip on the // next debounced fetchSuggestions call. await expect .poll(async () => await page.getByTestId('item-suggestion-ci02alpha').count(), { timeout: 10_000 }) .toBe(0); // ci02beta is still in the list — sanity check the filter isn't over-eager. await expect(page.getByTestId('item-suggestion-ci02beta')).toHaveCount(1); // Cleanup. Direct deletes from item_frequency are blocked by RLS — use // the admin purge RPC. await page.evaluate( async ({ id, collective }) => { const supabase = ( window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient } ).__sb; await supabase.from('shopping_items').delete().eq('list_id', id); await supabase.from('shopping_lists').delete().eq('id', id); for (const name of ['ci02alpha', 'ci02beta']) { try { await supabase.rpc('purge_item_frequency', { p_collective_id: collective, p_name: name }); } catch { /* ignore */ } } }, { id: listId, collective: SEED_COLLECTIVE_ID } ); }); test('CI-04: Ana purges "apples" — row disappears from the table and dropdown', async ({ page }) => { await page.goto('/collective/manage'); await page.waitForLoadState('networkidle'); const applesRow = page.getByTestId('common-item-row-apples'); await expect(applesRow).toBeVisible({ timeout: 10_000 }); await applesRow.getByTestId('common-item-purge-apples').click(); // Modal confirmation. const confirmBtn = page.getByTestId('common-item-purge-confirm'); await expect(confirmBtn).toBeVisible({ timeout: 5_000 }); await confirmBtn.click(); // Row must vanish from the table. await expect(applesRow).toHaveCount(0, { timeout: 5_000 }); // Re-seed apples via the existing trigger by NOT adding the item — just // confirm the row is gone from the manage view. The dropdown behaviour // is exercised indirectly by CI-01. }); });