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>
This commit is contained in:
254
packages/test-utils/tests/rls-notes.test.ts
Normal file
254
packages/test-utils/tests/rls-notes.test.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user