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:
@@ -230,5 +230,15 @@
|
|||||||
"settings_tags_section": "Tags",
|
"settings_tags_section": "Tags",
|
||||||
"settings_tags_empty": "No tags yet — create one from any list.",
|
"settings_tags_empty": "No tags yet — create one from any list.",
|
||||||
"settings_tags_delete": "Delete",
|
"settings_tags_delete": "Delete",
|
||||||
"settings_tags_color": "Color"
|
"settings_tags_color": "Color",
|
||||||
|
"section_disabled_for_collective": "That section is hidden for this collective.",
|
||||||
|
"settings_section_visibility_title": "Visible sections",
|
||||||
|
"settings_section_visibility_blurb": "Hide top-level sections you don't use. Your admin can also hide them for the whole collective.",
|
||||||
|
"settings_section_visibility_overridden": "Hidden by the collective.",
|
||||||
|
"collective_section_visibility_title": "Collective sections",
|
||||||
|
"collective_section_visibility_blurb": "Hide sections for every member of this collective. Members can also hide them individually for themselves.",
|
||||||
|
"section_label_lists": "Lists",
|
||||||
|
"section_label_tasks": "Tasks",
|
||||||
|
"section_label_notes": "Notes",
|
||||||
|
"section_label_search": "Search"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,5 +230,15 @@
|
|||||||
"settings_tags_section": "Etiquetas",
|
"settings_tags_section": "Etiquetas",
|
||||||
"settings_tags_empty": "Aún no hay etiquetas — créalas desde cualquier lista.",
|
"settings_tags_empty": "Aún no hay etiquetas — créalas desde cualquier lista.",
|
||||||
"settings_tags_delete": "Eliminar",
|
"settings_tags_delete": "Eliminar",
|
||||||
"settings_tags_color": "Color"
|
"settings_tags_color": "Color",
|
||||||
|
"section_disabled_for_collective": "Esta sección está oculta para este colectivo.",
|
||||||
|
"settings_section_visibility_title": "Secciones visibles",
|
||||||
|
"settings_section_visibility_blurb": "Oculta las secciones de nivel superior que no usas. Tu admin también puede ocultarlas para todo el colectivo.",
|
||||||
|
"settings_section_visibility_overridden": "Oculta por el colectivo.",
|
||||||
|
"collective_section_visibility_title": "Secciones del colectivo",
|
||||||
|
"collective_section_visibility_blurb": "Oculta secciones para todos los miembros de este colectivo. Cada miembro también puede ocultarlas para sí.",
|
||||||
|
"section_label_lists": "Listas",
|
||||||
|
"section_label_tasks": "Tareas",
|
||||||
|
"section_label_notes": "Notas",
|
||||||
|
"section_label_search": "Buscar"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,26 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import { enabledSections } from '$lib/stores/features';
|
||||||
|
import type { SectionKey } from '@colectivo/types';
|
||||||
import * as m from '$lib/paraglide/messages';
|
import * as m from '$lib/paraglide/messages';
|
||||||
import { ShoppingCart, CheckSquare, FileText, Search } from 'lucide-svelte';
|
import { ShoppingCart, CheckSquare, FileText, Search } from 'lucide-svelte';
|
||||||
|
|
||||||
const items = [
|
const allItems: Array<{ section: SectionKey; href: string; label: () => string; icon: typeof ShoppingCart }> = [
|
||||||
{ href: '/lists', label: () => m.nav_lists(), icon: ShoppingCart },
|
{ section: 'lists', href: '/lists', label: () => m.nav_lists(), icon: ShoppingCart },
|
||||||
{ href: '/tasks', label: () => m.nav_tasks(), icon: CheckSquare },
|
{ section: 'tasks', href: '/tasks', label: () => m.nav_tasks(), icon: CheckSquare },
|
||||||
{ href: '/notes', label: () => m.nav_notes(), icon: FileText },
|
{ section: 'notes', href: '/notes', label: () => m.nav_notes(), icon: FileText },
|
||||||
{ href: '/search', label: () => m.nav_search(), icon: Search }
|
{ section: 'search', href: '/search', label: () => m.nav_search(), icon: Search }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Fase 12.3.1/12.3.3: filter by enabled flags + recompute the grid column
|
||||||
|
// count from the visible length so the tab-bar adapts (the original was
|
||||||
|
// `justify-around` over a fixed 4; we keep that distribution but the
|
||||||
|
// number of children is now dynamic). "lists" is always shown
|
||||||
|
// (§12.3.4 hard override).
|
||||||
|
const items = $derived(
|
||||||
|
allItems.filter((it) => it.section === 'lists' || $enabledSections[it.section])
|
||||||
|
);
|
||||||
|
|
||||||
const activePath = $derived($page.url.pathname);
|
const activePath = $derived($page.url.pathname);
|
||||||
|
|
||||||
function isActive(href: string): boolean {
|
function isActive(href: string): boolean {
|
||||||
@@ -19,14 +30,16 @@
|
|||||||
|
|
||||||
<nav
|
<nav
|
||||||
data-testid="bottom-tab-bar"
|
data-testid="bottom-tab-bar"
|
||||||
|
data-section-count={items.length}
|
||||||
class="md:hidden fixed inset-x-0 bottom-0 z-40 border-t border-black/5 bg-surface/80 backdrop-blur-xl pb-[env(safe-area-inset-bottom)] dark:border-white/5"
|
class="md:hidden fixed inset-x-0 bottom-0 z-40 border-t border-black/5 bg-surface/80 backdrop-blur-xl pb-[env(safe-area-inset-bottom)] dark:border-white/5"
|
||||||
>
|
>
|
||||||
<ul class="flex items-stretch justify-around">
|
<ul class="flex items-stretch justify-around">
|
||||||
{#each items as { href, label, icon: Icon }}
|
{#each items as { section, href, label, icon: Icon } (section)}
|
||||||
{@const active = isActive(href)}
|
{@const active = isActive(href)}
|
||||||
<li class="flex-1">
|
<li class="flex-1">
|
||||||
<a
|
<a
|
||||||
{href}
|
{href}
|
||||||
|
data-section={section}
|
||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active ? 'page' : undefined}
|
||||||
aria-label={label()}
|
aria-label={label()}
|
||||||
class="flex h-14 flex-col items-center justify-center gap-0.5 text-xs transition-colors {active ? 'text-slate-900 dark:text-slate-50' : 'text-slate-400 dark:text-slate-500'}"
|
class="flex h-14 flex-col items-center justify-center gap-0.5 text-xs transition-colors {active ? 'text-slate-900 dark:text-slate-50' : 'text-slate-400 dark:text-slate-500'}"
|
||||||
|
|||||||
@@ -2,18 +2,29 @@
|
|||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { displayName } from '$lib/stores/auth';
|
import { displayName } from '$lib/stores/auth';
|
||||||
import { currentCollective, userCollectives } from '$lib/stores/collective';
|
import { currentCollective, userCollectives } from '$lib/stores/collective';
|
||||||
|
import { enabledSections } from '$lib/stores/features';
|
||||||
|
import type { SectionKey } from '@colectivo/types';
|
||||||
import Avatar from '$lib/components/Avatar.svelte';
|
import Avatar from '$lib/components/Avatar.svelte';
|
||||||
import CreateCollectiveModal from '$lib/components/CreateCollectiveModal.svelte';
|
import CreateCollectiveModal from '$lib/components/CreateCollectiveModal.svelte';
|
||||||
import * as m from '$lib/paraglide/messages';
|
import * as m from '$lib/paraglide/messages';
|
||||||
import { ShoppingCart, CheckSquare, FileText, Search, Settings, Plus } from 'lucide-svelte';
|
import { ShoppingCart, CheckSquare, FileText, Search, Settings, Plus } from 'lucide-svelte';
|
||||||
|
|
||||||
const navItems = [
|
// Fase 12.3.1: nav items declared with their section key so we can filter
|
||||||
{ href: '/lists', label: () => m.nav_lists(), icon: ShoppingCart },
|
// by $enabledSections (precedence resolved client-side; see features.ts).
|
||||||
{ href: '/tasks', label: () => m.nav_tasks(), icon: CheckSquare },
|
// Force "lists" through unconditionally in case every flag layer says OFF
|
||||||
{ href: '/notes', label: () => m.nav_notes(), icon: FileText },
|
// — matches the BottomTabBar / MobileDrawer "always one safe landing"
|
||||||
{ href: '/search', label: () => m.nav_search(), icon: Search }
|
// rule documented in §12.3.4.
|
||||||
|
const allNavItems: Array<{ section: SectionKey; href: string; label: () => string; icon: typeof ShoppingCart }> = [
|
||||||
|
{ section: 'lists', href: '/lists', label: () => m.nav_lists(), icon: ShoppingCart },
|
||||||
|
{ section: 'tasks', href: '/tasks', label: () => m.nav_tasks(), icon: CheckSquare },
|
||||||
|
{ section: 'notes', href: '/notes', label: () => m.nav_notes(), icon: FileText },
|
||||||
|
{ section: 'search', href: '/search', label: () => m.nav_search(), icon: Search }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const navItems = $derived(
|
||||||
|
allNavItems.filter((item) => item.section === 'lists' || $enabledSections[item.section])
|
||||||
|
);
|
||||||
|
|
||||||
let switcherOpen = $state(false);
|
let switcherOpen = $state(false);
|
||||||
let showCreate = $state(false);
|
let showCreate = $state(false);
|
||||||
|
|
||||||
@@ -76,9 +87,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="flex-1 overflow-y-auto px-2 py-3">
|
<nav class="flex-1 overflow-y-auto px-2 py-3">
|
||||||
{#each navItems as { href, label, icon: Icon }}
|
{#each navItems as { section, href, label, icon: Icon } (section)}
|
||||||
<a
|
<a
|
||||||
{href}
|
{href}
|
||||||
|
data-section={section}
|
||||||
class="mb-0.5 flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors {activePath.startsWith(href) ? 'bg-black/10 text-slate-900 dark:bg-white/10 dark:text-slate-50' : 'text-slate-500 hover:bg-black/5 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-white/5 dark:hover:text-slate-50'}"
|
class="mb-0.5 flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors {activePath.startsWith(href) ? 'bg-black/10 text-slate-900 dark:bg-white/10 dark:text-slate-50' : 'text-slate-500 hover:bg-black/5 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-white/5 dark:hover:text-slate-50'}"
|
||||||
>
|
>
|
||||||
<Icon size={16} strokeWidth={1.5} />
|
<Icon size={16} strokeWidth={1.5} />
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { isAuthenticated, authLoading, currentUser } from '$lib/stores/auth';
|
import { isAuthenticated, authLoading, currentUser } from '$lib/stores/auth';
|
||||||
import { login, isLoggingOut } from '$lib/auth';
|
import { login, isLoggingOut } from '$lib/auth';
|
||||||
import { getSupabase } from '$lib/supabase';
|
import { getSupabase } from '$lib/supabase';
|
||||||
import { setThemePreference, themePreference, type ThemePreference } from '$lib/theme';
|
import { setThemePreference, themePreference, type ThemePreference } from '$lib/theme';
|
||||||
import { currentCollective } from '$lib/stores/collective';
|
import { currentCollective } from '$lib/stores/collective';
|
||||||
|
import { enabledSections } from '$lib/stores/features';
|
||||||
|
import type { SectionKey } from '@colectivo/types';
|
||||||
import { setSyncContext } from '$lib/sync';
|
import { setSyncContext } from '$lib/sync';
|
||||||
import DesktopSidebar from '$lib/components/layout/DesktopSidebar.svelte';
|
import DesktopSidebar from '$lib/components/layout/DesktopSidebar.svelte';
|
||||||
import MobileTopBar from '$lib/components/layout/MobileTopBar.svelte';
|
import MobileTopBar from '$lib/components/layout/MobileTopBar.svelte';
|
||||||
@@ -57,6 +60,35 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fase 12.3.2: section-visibility route guard. If the URL hits a
|
||||||
|
// disabled section we bounce to /lists and surface a transient toast.
|
||||||
|
// "lists" is hard-allowed (the §12.3.4 always-one-landing rule) even if
|
||||||
|
// every flag layer says OFF — its $enabledSections value is still
|
||||||
|
// computed normally but we never redirect away from it from here.
|
||||||
|
const SECTION_BY_PREFIX: Array<[string, SectionKey]> = [
|
||||||
|
['/tasks', 'tasks'],
|
||||||
|
['/notes', 'notes'],
|
||||||
|
['/search', 'search']
|
||||||
|
];
|
||||||
|
let sectionToast = $state<string | null>(null);
|
||||||
|
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
$effect(() => {
|
||||||
|
if ($authLoading || !$isAuthenticated) return;
|
||||||
|
const path = $page.url.pathname;
|
||||||
|
const match = SECTION_BY_PREFIX.find(
|
||||||
|
([prefix]) => path === prefix || path.startsWith(`${prefix}/`)
|
||||||
|
);
|
||||||
|
if (!match) return;
|
||||||
|
const [, section] = match;
|
||||||
|
if ($enabledSections[section]) return;
|
||||||
|
|
||||||
|
// Disabled — show toast then redirect away.
|
||||||
|
sectionToast = m.section_disabled_for_collective();
|
||||||
|
if (toastTimer) clearTimeout(toastTimer);
|
||||||
|
toastTimer = setTimeout(() => (sectionToast = null), 3500);
|
||||||
|
void goto('/lists');
|
||||||
|
});
|
||||||
|
|
||||||
// Fase 9.1: cross-device theme sync. When the user logs in, read
|
// Fase 9.1: cross-device theme sync. When the user logs in, read
|
||||||
// public.users.theme and adopt it if it differs from the local
|
// public.users.theme and adopt it if it differs from the local
|
||||||
// preference. The anti-FOUC inline script in app.html and initTheme()
|
// preference. The anti-FOUC inline script in app.html and initTheme()
|
||||||
@@ -117,5 +149,17 @@
|
|||||||
|
|
||||||
<BottomTabBar />
|
<BottomTabBar />
|
||||||
<UndoToast />
|
<UndoToast />
|
||||||
|
{#if sectionToast}
|
||||||
|
<div
|
||||||
|
data-testid="section-disabled-toast"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
class="pointer-events-none fixed inset-x-0 top-6 z-50 flex justify-center px-4"
|
||||||
|
>
|
||||||
|
<div class="pointer-events-auto rounded-md bg-slate-900 px-4 py-2 text-sm text-white shadow-[0px_20px_40px_rgba(15,23,42,0.25)] dark:bg-slate-100 dark:text-slate-900">
|
||||||
|
{sectionToast}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -25,6 +25,14 @@
|
|||||||
// avoid FOUC — initTheme just builds the JS-side state on top.
|
// avoid FOUC — initTheme just builds the JS-side state on top.
|
||||||
initTheme();
|
initTheme();
|
||||||
|
|
||||||
|
// Dev-only: expose the supabase client on window so Playwright tests
|
||||||
|
// can drive PostgREST through it without re-creating a parallel
|
||||||
|
// client. Production never sees this (import.meta.env.DEV is
|
||||||
|
// dead-code-eliminated by Vite at build time).
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
(window as unknown as { __sb?: unknown }).__sb = getSupabase();
|
||||||
|
}
|
||||||
|
|
||||||
// Fase 6.1: register the PWA service worker. `@vite-pwa/sveltekit` does
|
// Fase 6.1: register the PWA service worker. `@vite-pwa/sveltekit` does
|
||||||
// not auto-register — the virtual module is a no-op unless called.
|
// not auto-register — the virtual module is a no-op unless called.
|
||||||
void import('virtual:pwa-register').then(({ registerSW }) => {
|
void import('virtual:pwa-register').then(({ registerSW }) => {
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -95,3 +95,20 @@ COMMENT ON FUNCTION public.section_enabled(text, uuid, uuid) IS
|
|||||||
'Effective ON/OFF of a top-level section for (user, collective). '
|
'Effective ON/OFF of a top-level section for (user, collective). '
|
||||||
'Precedence: collective override > user override > default true. '
|
'Precedence: collective override > user override > default true. '
|
||||||
'Fase 13 will prepend a server-settings layer above the collective.';
|
'Fase 13 will prepend a server-settings layer above the collective.';
|
||||||
|
|
||||||
|
-- ── Realtime ────────────────────────────────────────────────────────────────
|
||||||
|
-- The client-side `features.ts` store subscribes to `users` (own row) and
|
||||||
|
-- `collectives` (active row) so admin/user toggles propagate without a
|
||||||
|
-- reload. Without the publication membership those subscriptions receive
|
||||||
|
-- zero events — the realtime test (Playwright SV-02) was the catch.
|
||||||
|
--
|
||||||
|
-- REPLICA IDENTITY FULL is required so the OLD row carries all columns on
|
||||||
|
-- DELETE events; we only consume UPDATE here today, but `users` rows can be
|
||||||
|
-- deleted by the account-deletion flow (Fase 10.5) and a future Fase 13
|
||||||
|
-- "admin removes collective" will delete `collectives`. Filtering both
|
||||||
|
-- subscriptions still works correctly on DELETE this way.
|
||||||
|
ALTER PUBLICATION supabase_realtime ADD TABLE public.users;
|
||||||
|
ALTER PUBLICATION supabase_realtime ADD TABLE public.collectives;
|
||||||
|
|
||||||
|
ALTER TABLE public.users REPLICA IDENTITY FULL;
|
||||||
|
ALTER TABLE public.collectives REPLICA IDENTITY FULL;
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
CREATE EXTENSION IF NOT EXISTS pgtap;
|
CREATE EXTENSION IF NOT EXISTS pgtap;
|
||||||
|
|
||||||
BEGIN;
|
BEGIN;
|
||||||
SELECT plan(16);
|
SELECT plan(20);
|
||||||
|
|
||||||
-- ── Schema invariants ───────────────────────────────────────────────────────
|
-- ── Schema invariants ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -158,5 +158,47 @@ SELECT is(
|
|||||||
'SV-T16: an unknown section key returns true (default ON, no migration race)'
|
'SV-T16: an unknown section key returns true (default ON, no migration race)'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- ── Realtime publication membership ─────────────────────────────────────────
|
||||||
|
-- features.ts subscribes to UPDATE events on users (own row) + collectives
|
||||||
|
-- (active row). Without the publication membership those subscriptions
|
||||||
|
-- receive zero events. REPLICA IDENTITY FULL keeps OLD-row DELETE payloads
|
||||||
|
-- intact for the same callers down the line.
|
||||||
|
|
||||||
|
SELECT ok(
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM pg_publication_tables
|
||||||
|
WHERE pubname = 'supabase_realtime'
|
||||||
|
AND schemaname = 'public'
|
||||||
|
AND tablename = 'users'
|
||||||
|
),
|
||||||
|
'SV-T17: public.users is part of the supabase_realtime publication'
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT ok(
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM pg_publication_tables
|
||||||
|
WHERE pubname = 'supabase_realtime'
|
||||||
|
AND schemaname = 'public'
|
||||||
|
AND tablename = 'collectives'
|
||||||
|
),
|
||||||
|
'SV-T18: public.collectives is part of the supabase_realtime publication'
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT results_eq(
|
||||||
|
$$ SELECT relreplident::text FROM pg_class c
|
||||||
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
WHERE n.nspname = 'public' AND c.relname = 'users' $$,
|
||||||
|
$$ VALUES ('f') $$,
|
||||||
|
'SV-T19: public.users has REPLICA IDENTITY FULL'
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT results_eq(
|
||||||
|
$$ SELECT relreplident::text FROM pg_class c
|
||||||
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
WHERE n.nspname = 'public' AND c.relname = 'collectives' $$,
|
||||||
|
$$ VALUES ('f') $$,
|
||||||
|
'SV-T20: public.collectives has REPLICA IDENTITY FULL'
|
||||||
|
);
|
||||||
|
|
||||||
SELECT * FROM finish();
|
SELECT * FROM finish();
|
||||||
ROLLBACK;
|
ROLLBACK;
|
||||||
|
|||||||
Reference in New Issue
Block a user