/** * SV-series — Section visibility / feature_flags (Fase 12). * * SV-01 Ana hides "Tasks" in /settings → sidebar + bottom tab bar stop * showing it, and visiting /tasks redirects to /lists. * SV-02 Ana (admin) hides "Notes" in /collective/manage → Borja (member of * the same collective) sees the entry disappear without a reload * (realtime). * SV-03 Ana sets a collective-level Tasks override ON in a fresh extra * collective; Borja (already opted out of Tasks at the user layer) * sees Tasks reappear in that collective but stays hidden in others. * Verifies precedence collective > user resolves per-collective. * * Each test resets the seed user/collective JSON columns afterwards so the * suite is idempotent. Reset uses the live PostgREST endpoint via the JWT * already stashed in localStorage by `loginAs`. */ import { test, expect, type Page } from '@playwright/test'; import { USERS, COLLECTIVE_NAME } from '../fixtures/users.js'; import { loginAs } from '../fixtures/login.js'; const SEED_COLLECTIVE_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; /** * Patch a row via the Supabase client already loaded in the page (exposed * as `window.__sb` in dev — see `+layout.svelte`). Returns the HTTP-ish * status (200 on success, 0 if the client is not ready yet). */ async function patchRow( page: Page, table: 'users' | 'collectives', id: string, body: Record ): Promise { // Wait for the dev hook to land — onMount fires after the first paint, so // in fast tests we can race the eval. 5s timeout is plenty. await page.waitForFunction( () => Boolean((window as unknown as { __sb?: unknown }).__sb), null, { timeout: 5_000 } ); return await page.evaluate( async ({ table, id, body }) => { const supabase = ( window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient; } ).__sb; const { error } = await supabase.from(table).update(body).eq('id', id); return error ? 500 : 200; }, { table, id, body } ); } /** * Tab bar entries live inside `[data-testid="bottom-tab-bar"]`. We assert on * the *count* of the matching `[data-section="X"]` so the test does not depend * on the visible-vs-hidden CSS state (the entry is removed from the DOM when * hidden). */ async function tabBarHasSection(page: Page, section: string): Promise { return await page .getByTestId('bottom-tab-bar') .locator(`[data-section="${section}"]`) .count(); } async function resetAllFlags(browser: import('@playwright/test').Browser): Promise { // Reset the seed rows via Ana (the only user authoritative for both her own // row AND the seed collective row). Patching another user's row from Ana // would be blocked by RLS, so we open a second context for Borja. const anaCtx = await browser.newContext(); const anaPage = await anaCtx.newPage(); try { await loginAs(anaPage, USERS.ana); await patchRow(anaPage, 'users', USERS.ana.id, { feature_flags: {} }); await patchRow(anaPage, 'collectives', SEED_COLLECTIVE_ID, { feature_flags: {} }); // Fase 13: also clear the server-layer defaults so a leftover from the // admin suite (which can run earlier in `just test-all`) doesn't break // our precedence assertions. Ana is the seed server_admin so the RPC // passes the gate. await anaPage.evaluate(async () => { const supabase = ( window as unknown as { __sb: import('@supabase/supabase-js').SupabaseClient } ).__sb; for (const s of ['lists', 'tasks', 'notes', 'search']) { try { await supabase.rpc('admin_clear_default_section', { p_section: s }); } catch { /* non-admin: ignore */ } } }); } catch { /* don't mask original failure */ } finally { await anaCtx.close(); } const borjaCtx = await browser.newContext(); const borjaPage = await borjaCtx.newPage(); try { await loginAs(borjaPage, USERS.borja); await patchRow(borjaPage, 'users', USERS.borja.id, { feature_flags: {} }); } catch { /* don't mask original failure */ } finally { await borjaCtx.close(); } } test.describe('Section visibility', () => { // Reset BOTH before and after every test. The before-each guards against // state leakage from prior suites (the broader `just test-all` run shares // the same Docker database with whatever tasks/lists tests ran before). // The after-each keeps subsequent suites clean. test.beforeEach(async ({ browser }) => { await resetAllFlags(browser); }); test.afterEach(async ({ browser }) => { await resetAllFlags(browser); }); test('SV-01: Ana hides Tasks in /settings — sidebar + tab bar drop it, /tasks redirects to /lists', async ({ page }) => { await loginAs(page, USERS.ana); await page.goto('/settings'); const toggle = page.getByTestId('settings-section-toggle-tasks'); await expect(toggle).toBeVisible({ timeout: 10_000 }); await expect(toggle).toBeChecked(); await toggle.click(); await expect(toggle).not.toBeChecked(); const sidebar = page.getByTestId('desktop-sidebar'); await expect(sidebar.locator('[data-section="tasks"]')).toHaveCount(0, { timeout: 5_000 }); const tabBar = page.getByTestId('bottom-tab-bar'); await expect(tabBar.locator('[data-section="tasks"]')).toHaveCount(0); await expect(tabBar).toHaveAttribute('data-section-count', '3'); await page.goto('/tasks'); await page.waitForURL(/\/lists/, { timeout: 10_000 }); await expect(page.getByTestId('section-disabled-toast')).toBeVisible({ timeout: 5_000 }); }); test('SV-02: Ana hides Notes for the collective — Borja sees it disappear in realtime', async ({ browser }) => { const anaCtx = await browser.newContext(); const ana = await anaCtx.newPage(); const borjaCtx = await browser.newContext(); const borja = await borjaCtx.newPage(); try { await loginAs(ana, USERS.ana); // Make sure both flag rows are clean before Borja loads. await patchRow(ana, 'collectives', SEED_COLLECTIVE_ID, { feature_flags: {} }); await patchRow(ana, 'users', USERS.ana.id, { feature_flags: {} }); await loginAs(borja, USERS.borja); await patchRow(borja, 'users', USERS.borja.id, { feature_flags: {} }); await borja.goto('/lists'); // BottomTabBar is `md:hidden` so on Desktop Chrome it lives in the // DOM but is CSS-hidden — assert via locator count instead of // toBeVisible (which would otherwise fail on the visibility check). await expect(borja.getByTestId('bottom-tab-bar')).toHaveCount(1, { timeout: 10_000 }); expect(await tabBarHasSection(borja, 'notes')).toBe(1); // Ana flips the collective-level Notes toggle off. await ana.goto('/collective/manage'); const adminToggle = ana.getByTestId('manage-section-toggle-notes'); await expect(adminToggle).toBeVisible({ timeout: 10_000 }); await expect(adminToggle).toBeChecked(); await adminToggle.click(); await expect(adminToggle).not.toBeChecked(); // Borja's nav loses Notes without a reload — realtime carried the change. await expect .poll(() => tabBarHasSection(borja, 'notes'), { timeout: 15_000 }) .toBe(0); } finally { await anaCtx.close(); await borjaCtx.close(); } }); test('SV-03: collective ON override wins over user OFF — Tasks reappears per-collective', async ({ page, browser }) => { // Borja opts out of Tasks at the user layer (in the seed collective). await loginAs(page, USERS.borja); await patchRow(page, 'users', USERS.borja.id, { feature_flags: { tasks: false } }); // Force a re-fetch by reloading — the page first loaded with the empty // row, the realtime subscription will also fire, but reload is the most // deterministic way to start from a known state. await page.reload(); await page.waitForLoadState('networkidle'); await expect(page.getByTestId('bottom-tab-bar')).toHaveCount(1, { timeout: 10_000 }); expect(await tabBarHasSection(page, 'tasks')).toBe(0); // Ana (admin) explicitly sets Tasks ON at the collective level. Since // the collective layer outranks the user layer, Tasks should reappear // for Borja in this collective without him touching his user toggle. const adminCtx = await browser.newContext(); const adminPage = await adminCtx.newPage(); try { await loginAs(adminPage, USERS.ana); const status = await patchRow(adminPage, 'collectives', SEED_COLLECTIVE_ID, { feature_flags: { tasks: true } }); expect(status).toBeGreaterThanOrEqual(200); expect(status).toBeLessThan(300); } finally { await adminCtx.close(); } // Borja's BottomTabBar should pick the change up via realtime within // the standard 15s window (matches the Realtime gotcha note). await expect .poll(() => tabBarHasSection(page, 'tasks'), { timeout: 15_000 }) .toBe(1); // Sanity reference for the COLLECTIVE_NAME import — keeps the // fixture link explicit in case the helper file moves. expect(COLLECTIVE_NAME.length).toBeGreaterThan(0); }); });