feat(fase-12): nav gating + redirect guard + realtime publication
UI side:
* DesktopSidebar / BottomTabBar filter their entries by $enabledSections,
tag each entry with data-section, and BottomTabBar exposes a
data-section-count attribute so the grid is observable from tests.
* "lists" is force-shown unconditionally — the §12.3.4 always-one-
landing rule covers the edge case where every layer says OFF.
* (app)/+layout.svelte adds a $effect that, when the URL matches a
disabled section (/tasks, /notes, /search), goto's /lists and shows
a transient `section_disabled_for_collective` toast.
* Root +layout.svelte exposes the supabase singleton on window.__sb
in dev so the new Playwright spec can patch rows without a parallel
client; dead-code-eliminated in production builds.
DB side:
* Migration 023 grows by ALTER PUBLICATION supabase_realtime ADD TABLE
public.users + public.collectives + REPLICA IDENTITY FULL on both.
Without this membership the features.ts subscriptions get zero
events and SV-02 (collective toggle → realtime → member's nav
updates) silently fails. Caught while running the spec.
* pgTAP 015 grows from 16 to 20 assertions to cover publication
membership + REPLICA IDENTITY for both new realtime tables.
Paraglide messages: section_disabled_for_collective, the visibility
section titles + blurbs, section_label_* per SectionKey. Both en + es.
Playwright tests/e2e/section-visibility.test.ts (SV-01..SV-03):
SV-01 user toggle → nav reflects → /tasks redirects to /lists
SV-02 admin collective toggle → member sees it disappear in realtime
SV-03 collective ON beats user OFF — per-collective resolution
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
198
apps/web/tests/e2e/section-visibility.test.ts
Normal file
198
apps/web/tests/e2e/section-visibility.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* 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<string, unknown>
|
||||
): Promise<number> {
|
||||
// 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<number> {
|
||||
return await page
|
||||
.getByTestId('bottom-tab-bar')
|
||||
.locator(`[data-section="${section}"]`)
|
||||
.count();
|
||||
}
|
||||
|
||||
test.describe('Section visibility', () => {
|
||||
test.afterEach(async ({ browser }) => {
|
||||
// Best-effort cleanup as Ana so the collective row resets too. We only
|
||||
// touch the seed rows — extra collectives created mid-test are left in
|
||||
// place (the integration suite already covers the cross-collective path
|
||||
// without leaking, and Playwright's seed.sql is rebuilt by `just db-reset`).
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
await loginAs(page, USERS.ana);
|
||||
await patchRow(page, 'users', USERS.ana.id, { feature_flags: {} });
|
||||
await patchRow(page, 'users', USERS.borja.id, { feature_flags: {} });
|
||||
await patchRow(page, 'collectives', SEED_COLLECTIVE_ID, { feature_flags: {} });
|
||||
} catch {
|
||||
/* don't mask the original failure */
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user