feat(fase-12): features store + integration coverage
Adds the client mirror of section_enabled():
* SectionKey / FeatureFlags types + SECTION_KEYS constant in
@colectivo/types so the SQL and TS sides share the same vocabulary.
* apps/web/src/lib/stores/features.ts:
- currentUserFeatures writable, loaded by root layout
- enabledSections derived map { section → boolean }
- enabledSectionList ordered SectionKey[] visible to UI;
forces 'lists' if every layer says OFF
- setUserFeature per-user toggle (optimistic + rollback)
- setCollectiveFeature admin toggle (rolls back on RLS deny)
- realtime subscriptions one channel for the user's row, one
for the active collective; rebuilt
whenever those targets change
Wired into the root layout: loadCurrentUserFeatures runs from inside
the existing setTimeout-deferred onAuthStateChange callback (the
documented gotcha), and currentCollective.subscribe drives
subscribeCollectiveFeatures so collective switches re-target the
realtime filter without an extra auth event.
loadUserCollectives + every other place that fabricates a Collective
object now carries feature_flags (defaulting to {} for freshly created
collectives) so the Collective interface stays sound.
Integration spec (6 tests, SV-01..SV-06) covers default-ON, user-only,
collective-beats-user, RLS writes (user_update_own + collectives_update),
and per-collective isolation for a user in two collectives.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
233
packages/test-utils/tests/section-visibility.test.ts
Normal file
233
packages/test-utils/tests/section-visibility.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,22 @@
|
||||
export type AvatarType = 'initials' | 'emoji' | 'upload';
|
||||
export type MemberRole = 'admin' | 'member' | 'guest';
|
||||
export type ListStatus = 'active' | 'completed' | 'archived';
|
||||
|
||||
/**
|
||||
* Top-level navigable sections. Must mirror `public.known_sections()` in
|
||||
* supabase/migrations/023_section_visibility.sql. Adding a section is a
|
||||
* coordinated diff: migration that updates known_sections() + an entry here
|
||||
* + toggle rows in /settings and /collective/manage.
|
||||
*/
|
||||
export type SectionKey = 'lists' | 'tasks' | 'notes' | 'search';
|
||||
export const SECTION_KEYS: readonly SectionKey[] = ['lists', 'tasks', 'notes', 'search'] as const;
|
||||
|
||||
/**
|
||||
* Per-user or per-collective opt-out map. A missing key means "no opinion"
|
||||
* — defer to the next layer (collective > user > default ON). See
|
||||
* `public.section_enabled()` for the canonical resolution.
|
||||
*/
|
||||
export type FeatureFlags = Partial<Record<SectionKey, boolean>>;
|
||||
// NoteColor is defined in database.ts (matches the Postgres enum) and re-exported via index.ts
|
||||
import type { NoteColor, ItemTagColor } from './database.js';
|
||||
export type { NoteColor, ItemTagColor };
|
||||
|
||||
Reference in New Issue
Block a user