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:
235
packages/test-utils/tests/rls-tasks.test.ts
Normal file
235
packages/test-utils/tests/rls-tasks.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* T-series: task_lists and tasks RLS — CRUD by role, completion invariants,
|
||||
* cross-collective isolation.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { createClientAs, createAdminClient } from '../src/supabase-clients.js';
|
||||
import { ANA_ID, BORJA_ID, DAVID_ID, EVA_ID, COLLECTIVE_ID } from '../src/seed-constants.js';
|
||||
import { closePool } from '../src/db-helpers.js';
|
||||
|
||||
const admin = createAdminClient();
|
||||
const createdTaskListIds: string[] = [];
|
||||
let seedTaskListId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Seed a task list owned by Ana for the rest of the suite to read against.
|
||||
const { data, error } = await admin
|
||||
.from('task_lists')
|
||||
.insert({ collective_id: COLLECTIVE_ID, name: 'T-series seed list', created_by: ANA_ID })
|
||||
.select('id')
|
||||
.single();
|
||||
if (error) throw error;
|
||||
seedTaskListId = data!.id;
|
||||
createdTaskListIds.push(seedTaskListId);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdTaskListIds.length > 0) {
|
||||
await admin.from('task_lists').delete().in('id', createdTaskListIds);
|
||||
}
|
||||
await closePool();
|
||||
});
|
||||
|
||||
describe('Task list access by role', () => {
|
||||
it('T-01a: member (Borja) can create a task list', async () => {
|
||||
const borja = await createClientAs(BORJA_ID);
|
||||
const { data, error } = await borja
|
||||
.from('task_lists')
|
||||
.insert({ collective_id: COLLECTIVE_ID, name: 'Borja list', created_by: BORJA_ID })
|
||||
.select('id')
|
||||
.single();
|
||||
expect(error).toBeNull();
|
||||
expect(data?.id).toBeTruthy();
|
||||
if (data?.id) createdTaskListIds.push(data.id);
|
||||
});
|
||||
|
||||
it('T-01b: guest (David) cannot create a task list', async () => {
|
||||
const david = await createClientAs(DAVID_ID);
|
||||
const { data, error } = await david
|
||||
.from('task_lists')
|
||||
.insert({ collective_id: COLLECTIVE_ID, name: 'David list', created_by: DAVID_ID })
|
||||
.select('id')
|
||||
.single();
|
||||
expect(data).toBeNull();
|
||||
expect(error).not.toBeNull();
|
||||
});
|
||||
|
||||
it('T-01c: guest (David) can read task lists', async () => {
|
||||
const david = await createClientAs(DAVID_ID);
|
||||
const { data, error } = await david
|
||||
.from('task_lists')
|
||||
.select('id')
|
||||
.eq('id', seedTaskListId);
|
||||
expect(error).toBeNull();
|
||||
expect(data).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Task CRUD by role', () => {
|
||||
let taskId: string;
|
||||
|
||||
it('T-02a: member (Borja) can add a task', async () => {
|
||||
const borja = await createClientAs(BORJA_ID);
|
||||
const { data, error } = await borja
|
||||
.from('tasks')
|
||||
.insert({ list_id: seedTaskListId, title: 'Borja task', created_by: BORJA_ID })
|
||||
.select('id')
|
||||
.single();
|
||||
expect(error).toBeNull();
|
||||
expect(data?.id).toBeTruthy();
|
||||
taskId = data!.id;
|
||||
});
|
||||
|
||||
it('T-02b: guest (David) cannot add a task', async () => {
|
||||
const david = await createClientAs(DAVID_ID);
|
||||
const { data, error } = await david
|
||||
.from('tasks')
|
||||
.insert({ list_id: seedTaskListId, title: 'David task', created_by: DAVID_ID })
|
||||
.select('id')
|
||||
.single();
|
||||
expect(data).toBeNull();
|
||||
expect(error).not.toBeNull();
|
||||
});
|
||||
|
||||
it('T-02c: guest (David) can read tasks', async () => {
|
||||
const david = await createClientAs(DAVID_ID);
|
||||
const { data, error } = await david
|
||||
.from('tasks')
|
||||
.select('id')
|
||||
.eq('list_id', seedTaskListId);
|
||||
expect(error).toBeNull();
|
||||
expect(data!.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('T-02d: cannot spoof created_by', async () => {
|
||||
const borja = await createClientAs(BORJA_ID);
|
||||
const { data, error } = await borja
|
||||
.from('tasks')
|
||||
.insert({ list_id: seedTaskListId, title: 'Spoof', created_by: ANA_ID })
|
||||
.select('id')
|
||||
.single();
|
||||
expect(data).toBeNull();
|
||||
expect(error).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Task completion invariants', () => {
|
||||
let taskId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { data } = await admin
|
||||
.from('tasks')
|
||||
.insert({ list_id: seedTaskListId, title: 'Complete me', created_by: ANA_ID })
|
||||
.select('id')
|
||||
.single();
|
||||
taskId = data!.id;
|
||||
});
|
||||
|
||||
it('T-03a: marking complete sets completed_by + completed_at', async () => {
|
||||
const borja = await createClientAs(BORJA_ID);
|
||||
const { error } = await borja
|
||||
.from('tasks')
|
||||
.update({
|
||||
is_completed: true,
|
||||
completed_by: BORJA_ID,
|
||||
completed_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', taskId);
|
||||
expect(error).toBeNull();
|
||||
|
||||
const { data } = await admin.from('tasks').select('*').eq('id', taskId).single();
|
||||
expect(data?.is_completed).toBe(true);
|
||||
expect(data?.completed_by).toBe(BORJA_ID);
|
||||
expect(data?.completed_at).not.toBeNull();
|
||||
});
|
||||
|
||||
it('T-03b: unmarking clears completed_by + completed_at', async () => {
|
||||
const borja = await createClientAs(BORJA_ID);
|
||||
const { error } = await borja
|
||||
.from('tasks')
|
||||
.update({ is_completed: false, completed_by: null, completed_at: null })
|
||||
.eq('id', taskId);
|
||||
expect(error).toBeNull();
|
||||
|
||||
const { data } = await admin.from('tasks').select('*').eq('id', taskId).single();
|
||||
expect(data?.is_completed).toBe(false);
|
||||
expect(data?.completed_by).toBeNull();
|
||||
expect(data?.completed_at).toBeNull();
|
||||
});
|
||||
|
||||
it('T-03c: CHECK constraint forbids is_completed=true without completed_by', async () => {
|
||||
const borja = await createClientAs(BORJA_ID);
|
||||
const { error } = await borja
|
||||
.from('tasks')
|
||||
.update({ is_completed: true, completed_by: null, completed_at: null })
|
||||
.eq('id', taskId);
|
||||
expect(error).not.toBeNull(); // CHECK violation surfaces as a Postgres error
|
||||
});
|
||||
|
||||
it('T-03d: guest (David) cannot mark complete', async () => {
|
||||
const david = await createClientAs(DAVID_ID);
|
||||
await david
|
||||
.from('tasks')
|
||||
.update({ is_completed: true, completed_by: DAVID_ID, completed_at: new Date().toISOString() })
|
||||
.eq('id', taskId);
|
||||
const { data } = await admin.from('tasks').select('is_completed').eq('id', taskId).single();
|
||||
expect(data?.is_completed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cross-collective isolation', () => {
|
||||
let otherCollectiveId: string;
|
||||
let otherListId: string;
|
||||
let otherTaskId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { data: collective } = await admin
|
||||
.from('collectives')
|
||||
.insert({ name: 'Tasks 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: list } = await admin
|
||||
.from('task_lists')
|
||||
.insert({ collective_id: otherCollectiveId, name: 'Eva tasks', created_by: EVA_ID })
|
||||
.select('id')
|
||||
.single();
|
||||
otherListId = list!.id;
|
||||
const { data: task } = await admin
|
||||
.from('tasks')
|
||||
.insert({ list_id: otherListId, title: 'Eva private task', created_by: EVA_ID })
|
||||
.select('id')
|
||||
.single();
|
||||
otherTaskId = task!.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (otherCollectiveId) {
|
||||
await admin.from('collectives').delete().eq('id', otherCollectiveId);
|
||||
}
|
||||
});
|
||||
|
||||
it('T-04a: Eva (non-member of main collective) sees no tasks from main', async () => {
|
||||
const eva = await createClientAs(EVA_ID);
|
||||
const { data } = await eva.from('tasks').select('id').eq('list_id', seedTaskListId);
|
||||
expect(data).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('T-04b: Borja cannot read Eva\'s task', async () => {
|
||||
const borja = await createClientAs(BORJA_ID);
|
||||
const { data } = await borja.from('tasks').select('id').eq('id', otherTaskId);
|
||||
expect(data).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('T-04c: Borja cannot delete Eva\'s task list', async () => {
|
||||
const borja = await createClientAs(BORJA_ID);
|
||||
await borja.from('task_lists').delete().eq('id', otherListId);
|
||||
const { data } = await admin.from('task_lists').select('id').eq('id', otherListId).single();
|
||||
expect(data?.id).toBe(otherListId);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user