diff --git a/apps/web/src/routes/(app)/lists/[id]/+page.svelte b/apps/web/src/routes/(app)/lists/[id]/+page.svelte index 68c3ca5..68eefbc 100644 --- a/apps/web/src/routes/(app)/lists/[id]/+page.svelte +++ b/apps/web/src/routes/(app)/lists/[id]/+page.svelte @@ -338,10 +338,16 @@ // UPDATE payload. INSERTs of brand-new rows arrive without tags // (they're attached separately); seed an empty array. const next = applyItemEvent(items as unknown as ShoppingItem[], evt); - items = next.map((row) => { - const existing = items.find((i) => i.id === row.id); - return { ...row, tags: existing?.tags ?? [] }; - }); + // Re-sort by sort_order so a realtime UPDATE that only changes the + // row's sort_order reorders the rendered list without a refresh + // (Fase 11.4.3). Without this the array order is stable across + // shallow-merge and a remote reorder is invisible to other clients. + items = next + .map((row) => { + const existing = items.find((i) => i.id === row.id); + return { ...row, tags: existing?.tags ?? [] }; + }) + .sort((a, b) => a.sort_order - b.sort_order); }); // Listen for shopping_item_tags changes so attach/detach in another tab diff --git a/apps/web/tests/e2e/reorder-items.test.ts b/apps/web/tests/e2e/reorder-items.test.ts new file mode 100644 index 0000000..2358a66 --- /dev/null +++ b/apps/web/tests/e2e/reorder-items.test.ts @@ -0,0 +1,204 @@ +/** + * RO-series — Drag-reorder of shopping items (Fase 11.4) + * + * The list detail view wires `svelte-dnd-action` over the unchecked-items + * section via a per-row GripVertical handle, calling reorderItems() on + * drop. These tests verify the user-observable contract: + * + * RO-01: a reorder persists across reload (sort_order is written to DB) + * RO-02: Ana's reorder propagates to Borja via realtime UPDATEs on + * shopping_items.sort_order without a refresh + * + * Note on drag emulation: svelte-dnd-action uses native HTML5 DnD which + * headless Chromium handles poorly. Rather than fight the engine, both + * tests drive the same write path the page uses (reorderItems batch + * UPDATE) by issuing the PATCH calls directly against the REST endpoint + * using the session token already in localStorage. The realtime + * propagation, the SELECT after reload, and the optimistic UI update + * paths are unchanged. + */ +import { test, expect, type Page, type Browser } 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; + +// Dev-stack anon key (also in root .env). Playwright doesn't load .env, and +// even when it did the LAN-IP supabase URL in there is wrong for headless. +// This is the public anon key for the dev demo instance — no secrets here. +const DEV_SUPABASE_URL = 'http://localhost:8001'; +const DEV_SUPABASE_ANON_KEY = + process.env.PUBLIC_SUPABASE_ANON_KEY ?? + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjIwOTE0OTEyOTZ9.fK2is86W1xqCPR525AOU_VukQL4CMxncX55ZBLab-dA'; + +async function gotoSeedList(page: Page) { + await page.goto(SEED_LIST_PATH); + await expect(page.getByPlaceholder(ADD_ITEM_PLACEHOLDER)).toBeVisible({ timeout: 15_000 }); +} + +async function createItem(page: Page, name: string) { + const input = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER); + await input.fill(name); + await input.press('Enter'); + await expect(page.locator(`[role="listitem"][data-name="${name}"]`).first()).toBeVisible({ + timeout: 5_000 + }); + await page.waitForTimeout(400); +} + +/** Read item rows in document order, filtered by prefix. */ +async function orderedNames(page: Page, prefix: string): Promise { + return page.evaluate((p) => { + return Array.from(document.querySelectorAll('[role="listitem"][data-checked="false"]')) + .map((el) => (el as HTMLElement).dataset.name ?? '') + .filter((n) => n.startsWith(p)); + }, prefix); +} + +/** + * Reorder items inside the browser context by PATCHing sort_order directly + * to the Supabase REST endpoint via the same access token the SDK uses. + * This is what the in-page reorderItems() helper does under the hood + * (Promise.all of per-row updates). + */ +async function reorderByName(page: Page, listId: string, orderedNames: string[]) { + await page.evaluate( + async ({ listId, orderedNames, supabaseUrl, anonKey }) => { + // Find the GoTrue session token written by supabase-js v2. + let token = ''; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i)!; + if (k.startsWith('sb-') && k.endsWith('-auth-token')) { + try { + const obj = JSON.parse(localStorage.getItem(k)!); + token = obj.access_token ?? obj.currentSession?.access_token ?? ''; + } catch { + /* */ + } + } + } + const headers: Record = { + apikey: anonKey, + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + }; + const listRes = await fetch( + `${supabaseUrl}/rest/v1/shopping_items?list_id=eq.${listId}&select=id,name`, + { headers } + ); + const text = await listRes.text(); + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + throw new Error(`reorderByName: bad JSON from ${supabaseUrl}/rest/v1/shopping_items: ${text.slice(0, 120)}`); + } + if (!Array.isArray(parsed)) { + throw new Error( + `reorderByName: expected array, got ${JSON.stringify(parsed).slice(0, 120)} (status ${listRes.status})` + ); + } + const items = parsed as { id: string; name: string }[]; + const ids = orderedNames + .map((n) => items.find((i) => i.name === n)?.id) + .filter((x): x is string => !!x); + + await Promise.all( + ids.map((id, idx) => + fetch(`${supabaseUrl}/rest/v1/shopping_items?id=eq.${id}`, { + method: 'PATCH', + headers: { ...headers, Prefer: 'return=minimal' }, + body: JSON.stringify({ sort_order: idx }) + }) + ) + ); + }, + { + listId, + orderedNames, + supabaseUrl: DEV_SUPABASE_URL, + anonKey: DEV_SUPABASE_ANON_KEY + } + ); +} + +test.describe('Drag-reorder', () => { + test.beforeEach(async ({ page }) => { + await loginAs(page, USERS.ana); + }); + + test('RO-01: reordering persists across reload', async ({ page }) => { + await gotoSeedList(page); + const stamp = Date.now(); + const prefix = `RO01-${stamp}-`; + const names = [`${prefix}A`, `${prefix}B`, `${prefix}C`]; + + for (const n of names) { + await createItem(page, n); + } + + const initial = await orderedNames(page, prefix); + expect(initial).toEqual(names); + + // Move C above A and B. + await reorderByName(page, SEED_LIST_ID, [names[2], names[0], names[1]]); + + await page.reload(); + await expect(page.getByPlaceholder(ADD_ITEM_PLACEHOLDER)).toBeVisible({ timeout: 15_000 }); + + const reordered = await orderedNames(page, prefix); + expect(reordered[0]).toBe(names[2]); + const aIdx = reordered.indexOf(names[0]); + const bIdx = reordered.indexOf(names[1]); + expect(aIdx).toBeGreaterThan(0); + expect(bIdx).toBeGreaterThan(aIdx); + }); +}); + +test.describe('Drag-reorder — realtime', () => { + async function loggedInListPage(browser: Browser, user: (typeof USERS)[keyof typeof USERS]) { + const ctx = await browser.newContext(); + const p = await ctx.newPage(); + await loginAs(p, user); + await p.goto(SEED_LIST_PATH); + await expect(p.getByPlaceholder(ADD_ITEM_PLACEHOLDER)).toBeVisible({ timeout: 15_000 }); + return { ctx, p }; + } + + test('RO-02: a reorder by Ana propagates to Borja without reload', async ({ browser }) => { + const ana = await loggedInListPage(browser, USERS.ana); + const borja = await loggedInListPage(browser, USERS.borja); + + try { + const stamp = Date.now(); + const prefix = `RO02-${stamp}-`; + const names = [`${prefix}X`, `${prefix}Y`]; + for (const n of names) { + await createItem(ana.p, n); + } + + // Both sides see the items. + for (const n of names) { + await expect( + borja.p.locator(`[role="listitem"][data-name="${n}"]`).first() + ).toBeVisible({ timeout: 10_000 }); + } + + // Reverse the order on Ana's side. + await reorderByName(ana.p, SEED_LIST_ID, [names[1], names[0]]); + + // Realtime feeds the new order to Borja within the suite's 10s window. + await expect + .poll( + async () => orderedNames(borja.p, prefix), + { timeout: 10_000 } + ) + .toEqual([names[1], names[0]]); + } finally { + await ana.ctx.close(); + await borja.ctx.close(); + } + }); +});