/** * SV-series: Section visibility / feature_flags (Fase 12.2). * * SV-01 default ON — fresh user + fresh collective, every known section * resolves to true. * SV-02 user OFF + collective absent → section OFF for that user, ON for * everyone else. * SV-03 collective OFF beats user ON (admin opt-out applies to everyone, * including users who tried to opt back in). * SV-04 a user can update their own `feature_flags` (RLS write-path * piggybacks on users_update_own). * SV-05 an admin can update the collective `feature_flags`; a non-admin * member cannot (RLS write-path piggybacks on collectives_update). * SV-06 the same user in two collectives sees independent collective * overrides even though the user-level flag is shared (foundational * check for the SV-03 e2e spec). */ import { describe, it, expect, afterAll } from 'vitest'; import { createClientAs, createAdminClient } from '../src/supabase-clients.js'; import { ANA_ID, BORJA_ID, CARMEN_ID, COLLECTIVE_ID } from '../src/seed-constants.js'; const admin = createAdminClient(); const createdCollectiveIds: string[] = []; // Reset both seed rows to a clean `{}` state at the top of every test so the // previous one doesn't leak. The user/collective rows themselves are seeded // (and never deleted) — only the JSONB column is reset. async function resetFlags() { await admin .from('users') .update({ feature_flags: {} }) .in('id', [ANA_ID, BORJA_ID, CARMEN_ID]); await admin .from('collectives') .update({ feature_flags: {} }) .eq('id', COLLECTIVE_ID); } afterAll(async () => { await resetFlags(); if (createdCollectiveIds.length) { await admin.from('collectives').delete().in('id', createdCollectiveIds); } }); describe('section_enabled() precedence (integration)', () => { it('SV-01: default ON — empty flags on both layers, every known section is enabled', async () => { await resetFlags(); const borja = await createClientAs(BORJA_ID); const sections = ['lists', 'tasks', 'notes', 'search']; for (const section of sections) { const { data, error } = await borja.rpc('section_enabled', { p_section: section, p_user: BORJA_ID, p_collective: COLLECTIVE_ID }); expect(error).toBeNull(); expect(data).toBe(true); } }); it('SV-02: user OFF only — applies to that user, not to a peer in the same collective', async () => { await resetFlags(); // Borja opts out of tasks for himself. const borja = await createClientAs(BORJA_ID); const upd = await borja .from('users') .update({ feature_flags: { tasks: false } }) .eq('id', BORJA_ID); expect(upd.error).toBeNull(); // Borja sees tasks OFF. const borjaEval = await borja.rpc('section_enabled', { p_section: 'tasks', p_user: BORJA_ID, p_collective: COLLECTIVE_ID }); expect(borjaEval.error).toBeNull(); expect(borjaEval.data).toBe(false); // Carmen (also a member of the same collective) still sees tasks ON. const carmen = await createClientAs(CARMEN_ID); const carmenEval = await carmen.rpc('section_enabled', { p_section: 'tasks', p_user: CARMEN_ID, p_collective: COLLECTIVE_ID }); expect(carmenEval.error).toBeNull(); expect(carmenEval.data).toBe(true); }); it('SV-03: collective OFF beats user ON (admin override applies to everyone)', async () => { await resetFlags(); // Carmen tries to opt INTO notes for herself. const carmen = await createClientAs(CARMEN_ID); const carmenSet = await carmen .from('users') .update({ feature_flags: { notes: true } }) .eq('id', CARMEN_ID); expect(carmenSet.error).toBeNull(); // Ana (admin) turns notes OFF for the whole collective. const ana = await createClientAs(ANA_ID); const collSet = await ana .from('collectives') .update({ feature_flags: { notes: false } }) .eq('id', COLLECTIVE_ID); expect(collSet.error).toBeNull(); const result = await carmen.rpc('section_enabled', { p_section: 'notes', p_user: CARMEN_ID, p_collective: COLLECTIVE_ID }); expect(result.error).toBeNull(); expect(result.data).toBe(false); }); }); describe('feature_flags RLS writes', () => { it('SV-04: a user can update their own feature_flags', async () => { await resetFlags(); const borja = await createClientAs(BORJA_ID); const { error } = await borja .from('users') .update({ feature_flags: { search: false } }) .eq('id', BORJA_ID); expect(error).toBeNull(); const { data } = await borja .from('users') .select('feature_flags') .eq('id', BORJA_ID) .single(); expect(data?.feature_flags).toEqual({ search: false }); }); it('SV-05: admin can update collective feature_flags; non-admin member cannot', async () => { await resetFlags(); // Ana (admin) — allowed. const ana = await createClientAs(ANA_ID); const anaUpd = await ana .from('collectives') .update({ feature_flags: { tasks: false } }) .eq('id', COLLECTIVE_ID) .select('feature_flags'); expect(anaUpd.error).toBeNull(); expect(anaUpd.data?.[0]?.feature_flags).toEqual({ tasks: false }); // Borja (member, not admin) — denied. RLS UPDATE policy returns 0 rows // updated; PostgREST surfaces this as `data: []` with no error. const borja = await createClientAs(BORJA_ID); const borjaUpd = await borja .from('collectives') .update({ feature_flags: { tasks: true } }) .eq('id', COLLECTIVE_ID) .select('feature_flags'); expect(borjaUpd.data ?? []).toHaveLength(0); // The collective row still reflects Ana's value (admin's call won). const { data: postState } = await admin .from('collectives') .select('feature_flags') .eq('id', COLLECTIVE_ID) .single(); expect(postState?.feature_flags).toEqual({ tasks: false }); }); }); describe('per-collective isolation', () => { it('SV-06: a user override applied to one collective context is independent of another (where Ana is also a member)', async () => { await resetFlags(); // Build a second collective that Ana is admin of. const { data: extra, error: createErr } = await admin .from('collectives') .insert({ name: `SV-06 extra ${Date.now()}`, emoji: '🧪', created_by: ANA_ID }) .select('id') .single(); expect(createErr).toBeNull(); const extraId = extra!.id; createdCollectiveIds.push(extraId); await admin .from('collective_members') .insert({ collective_id: extraId, user_id: ANA_ID, role: 'admin' }); // Seed collective: collective OFF tasks. Extra collective: collective ON // (empty / no opinion) tasks. Ana's user flags are empty. await admin .from('collectives') .update({ feature_flags: { tasks: false } }) .eq('id', COLLECTIVE_ID); await admin .from('collectives') .update({ feature_flags: {} }) .eq('id', extraId); const ana = await createClientAs(ANA_ID); const seedEval = await ana.rpc('section_enabled', { p_section: 'tasks', p_user: ANA_ID, p_collective: COLLECTIVE_ID }); expect(seedEval.error).toBeNull(); expect(seedEval.data).toBe(false); const extraEval = await ana.rpc('section_enabled', { p_section: 'tasks', p_user: ANA_ID, p_collective: extraId }); expect(extraEval.error).toBeNull(); expect(extraEval.data).toBe(true); }); });