Files
collective-lists/packages/test-utils/tests/rls-tasks.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

236 lines
7.4 KiB
TypeScript

/**
* 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);
});
});