Files
collective-lists/packages/test-utils/tests/rls-notes.test.ts
Oier Bravo Urtasun 104eeba02e feat(fase-3): Tareas + Notas — TDD complete (148 tests green)
3.0 Tests primero
  pgTAP: 005_tasks_rls.sql (5 — completion CHECK), 006_notes_trash_purge.sql (4 — purge SQL inline + pin/archive CHECK)
  Vitest RLS: rls-tasks.test.ts (14), rls-notes.test.ts (15)
  Playwright: tasks.test.ts (3), notes.test.ts (4)

3.1 Tareas
  Migration 008_tasks.sql: task_lists + tasks + task_list_collective_id() helper
  CHECK constraint: is_completed ⇔ completed_by ⇔ completed_at
  RLS by role + cross-collective isolation
  Store apps/web/src/lib/stores/tasks.ts (optimistic CRUD + reorder)
  /tasks overview (grid + sticky create input + 5s polling)
  /tasks/[id] detail with svelte-dnd-action reorder, flip animation,
    inline edit (dblclick), "Completed by …" attribution via lazy-loaded
    collective_members

3.2 Notas
  Migration 009_notes.sql: notes + note_color enum (8) + pin/archive CHECK
    + 7d trash window in RLS + fn_notes_touch trigger + pg_cron purge job
  Store apps/web/src/lib/stores/notes.ts (CRUD + pin/archive/trash/duplicate)
  /notes board (pinned section testid notes-pinned + 30s polling)
  /notes/[id] editor (title + textarea, 500ms debounced autosave,
    color picker, pin/archive/duplicate/trash actions)
  /notes/archive (restore + send to trash)
  /notes/trash (restore + permanent delete)

3.Z Verification
  just test-all → 148 verdes, 2 skipped:
    25 pgTAP (was 16, +9)
    88 Vitest integration (was 59, +29; 2 presence skipped)
    6 Vitest unit (unchanged)
    29 Playwright (was 22, +3 tasks +4 notes)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 10:28:19 +02:00

255 lines
7.6 KiB
TypeScript

/**
* N-series: notes RLS — CRUD by role, archive, trash window, cross-collective isolation.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createClientAs, createAdminClient } from '../src/supabase-clients.js';
import { sql, closePool } from '../src/db-helpers.js';
import { ANA_ID, BORJA_ID, DAVID_ID, EVA_ID, COLLECTIVE_ID } from '../src/seed-constants.js';
const admin = createAdminClient();
const createdNoteIds: string[] = [];
afterAll(async () => {
if (createdNoteIds.length > 0) {
await admin.from('notes').delete().in('id', createdNoteIds);
}
await closePool();
});
describe('Note CRUD by role', () => {
let noteId: string;
it('N-01a: member (Borja) can create a note', async () => {
const borja = await createClientAs(BORJA_ID);
const { data, error } = await borja
.from('notes')
.insert({
collective_id: COLLECTIVE_ID,
title: 'Borja note',
content: 'hello',
created_by: BORJA_ID,
updated_by: BORJA_ID
})
.select('id')
.single();
expect(error).toBeNull();
expect(data?.id).toBeTruthy();
noteId = data!.id;
createdNoteIds.push(noteId);
});
it('N-01b: guest (David) cannot create a note', async () => {
const david = await createClientAs(DAVID_ID);
const { data, error } = await david
.from('notes')
.insert({
collective_id: COLLECTIVE_ID,
content: 'David note',
created_by: DAVID_ID,
updated_by: DAVID_ID
})
.select('id')
.single();
expect(data).toBeNull();
expect(error).not.toBeNull();
});
it('N-01c: guest (David) can read notes', async () => {
const david = await createClientAs(DAVID_ID);
const { data, error } = await david.from('notes').select('id').eq('id', noteId);
expect(error).toBeNull();
expect(data).toHaveLength(1);
});
it('N-01d: any active member can edit any note (Carmen edits Borja\'s)', async () => {
const carmen = await createClientAs('33333333-3333-3333-3333-333333333333');
const { error } = await carmen
.from('notes')
.update({ content: 'edited by Carmen', updated_by: '33333333-3333-3333-3333-333333333333' })
.eq('id', noteId);
expect(error).toBeNull();
const { data } = await admin.from('notes').select('content, updated_by').eq('id', noteId).single();
expect(data?.content).toBe('edited by Carmen');
expect(data?.updated_by).toBe('33333333-3333-3333-3333-333333333333');
});
it('N-01e: cannot spoof created_by/updated_by on insert', async () => {
const borja = await createClientAs(BORJA_ID);
const { data, error } = await borja
.from('notes')
.insert({
collective_id: COLLECTIVE_ID,
content: 'spoof',
created_by: ANA_ID,
updated_by: ANA_ID
})
.select('id')
.single();
expect(data).toBeNull();
expect(error).not.toBeNull();
});
});
describe('Note pin / archive / trash flow', () => {
let noteId: string;
beforeAll(async () => {
const { data } = await admin
.from('notes')
.insert({
collective_id: COLLECTIVE_ID,
content: 'flow note',
created_by: ANA_ID,
updated_by: ANA_ID
})
.select('id')
.single();
noteId = data!.id;
createdNoteIds.push(noteId);
});
it('N-02a: member can pin', async () => {
const borja = await createClientAs(BORJA_ID);
const { error } = await borja
.from('notes')
.update({ is_pinned: true, updated_by: BORJA_ID })
.eq('id', noteId);
expect(error).toBeNull();
});
it('N-02b: pinned + archived combination is rejected (CHECK)', async () => {
const borja = await createClientAs(BORJA_ID);
const { error } = await borja
.from('notes')
.update({ is_archived: true, updated_by: BORJA_ID })
.eq('id', noteId);
expect(error).not.toBeNull();
});
it('N-02c: archive (after unpin) succeeds', async () => {
const borja = await createClientAs(BORJA_ID);
await borja
.from('notes')
.update({ is_pinned: false, is_archived: true, updated_by: BORJA_ID })
.eq('id', noteId);
const { data } = await admin.from('notes').select('is_archived').eq('id', noteId).single();
expect(data?.is_archived).toBe(true);
});
it('N-02d: archived note is still readable', async () => {
const david = await createClientAs(DAVID_ID);
const { data } = await david.from('notes').select('id, is_archived').eq('id', noteId).single();
expect(data?.id).toBe(noteId);
expect(data?.is_archived).toBe(true);
});
});
describe('Note trash window', () => {
let noteId: string;
beforeAll(async () => {
const { data } = await admin
.from('notes')
.insert({
collective_id: COLLECTIVE_ID,
content: 'trash note',
created_by: ANA_ID,
updated_by: ANA_ID
})
.select('id')
.single();
noteId = data!.id;
createdNoteIds.push(noteId);
});
it('N-03a: soft-deleted note is visible within the 7-day window', async () => {
const borja = await createClientAs(BORJA_ID);
await borja
.from('notes')
.update({ deleted_at: new Date().toISOString(), updated_by: BORJA_ID })
.eq('id', noteId);
const { data } = await borja.from('notes').select('id, deleted_at').eq('id', noteId).single();
expect(data?.id).toBe(noteId);
expect(data?.deleted_at).not.toBeNull();
});
it('N-03b: soft-deleted note older than 7 days is invisible', async () => {
await sql(`UPDATE public.notes SET deleted_at = now() - INTERVAL '8 days' WHERE id = $1`, [noteId]);
const borja = await createClientAs(BORJA_ID);
const { data } = await borja.from('notes').select('id').eq('id', noteId);
expect(data).toHaveLength(0);
await sql(`UPDATE public.notes SET deleted_at = now() WHERE id = $1`, [noteId]);
});
it('N-03c: guest cannot hard-delete a soft-deleted note', async () => {
const david = await createClientAs(DAVID_ID);
await david.from('notes').delete().eq('id', noteId);
const { data } = await admin.from('notes').select('id').eq('id', noteId).single();
expect(data?.id).toBe(noteId);
});
it('N-03d: member can hard-delete a soft-deleted note', async () => {
const borja = await createClientAs(BORJA_ID);
const { error } = await borja.from('notes').delete().eq('id', noteId);
expect(error).toBeNull();
const { data } = await admin.from('notes').select('id').eq('id', noteId);
expect(data).toHaveLength(0);
const idx = createdNoteIds.indexOf(noteId);
if (idx !== -1) createdNoteIds.splice(idx, 1);
});
});
describe('Cross-collective note isolation', () => {
let otherCollectiveId: string;
let otherNoteId: string;
beforeAll(async () => {
const { data: collective } = await admin
.from('collectives')
.insert({ name: 'Notes isolation collective', emoji: '🔐', created_by: EVA_ID })
.select('id')
.single();
otherCollectiveId = collective!.id;
await admin.from('collective_members').insert({
collective_id: otherCollectiveId,
user_id: EVA_ID,
role: 'admin'
});
const { data: note } = await admin
.from('notes')
.insert({
collective_id: otherCollectiveId,
content: 'Eva private',
created_by: EVA_ID,
updated_by: EVA_ID
})
.select('id')
.single();
otherNoteId = note!.id;
});
afterAll(async () => {
if (otherCollectiveId) {
await admin.from('collectives').delete().eq('id', otherCollectiveId);
}
});
it('N-04a: Borja cannot read Eva\'s note', async () => {
const borja = await createClientAs(BORJA_ID);
const { data } = await borja.from('notes').select('id').eq('id', otherNoteId);
expect(data).toHaveLength(0);
});
it('N-04b: Borja cannot edit Eva\'s note', async () => {
const borja = await createClientAs(BORJA_ID);
await borja
.from('notes')
.update({ content: 'hacked', updated_by: BORJA_ID })
.eq('id', otherNoteId);
const { data } = await admin.from('notes').select('content').eq('id', otherNoteId).single();
expect(data?.content).toBe('Eva private');
});
});