/** * T-series: trash RPCs (restore_list, hard_delete_list) — Fase 10.4. * * Verifies that the SECURITY DEFINER functions return what the UI store * expects: restore brings the list back to active (visible in loadLists), * hard_delete removes it permanently, and both functions guard on caller * role (guest rejected, non-member rejected). */ 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 { BORJA_ID, DAVID_ID, EVA_ID, COLLECTIVE_ID } from '../src/seed-constants.js'; const admin = createAdminClient(); const trashedIds: string[] = []; afterAll(async () => { if (trashedIds.length) { await admin.from('shopping_lists').delete().in('id', trashedIds); } await closePool(); }); async function makeTrashedList(): Promise { const r = await sql( `INSERT INTO public.shopping_lists (collective_id, name, status, created_by, deleted_at) VALUES ($1, 'Trash RPC test', 'active', $2, now() - interval '1 hour') RETURNING id`, [COLLECTIVE_ID, BORJA_ID] ); const id = r.rows[0].id as string; trashedIds.push(id); return id; } describe('Trash RPCs', () => { it('T-10: member restore brings list back to the active listing', async () => { const id = await makeTrashedList(); const borja = await createClientAs(BORJA_ID); const { error: rpcErr } = await borja.rpc('restore_list', { p_list_id: id }); expect(rpcErr).toBeNull(); const { data } = await admin .from('shopping_lists') .select('deleted_at') .eq('id', id) .single(); expect(data?.deleted_at).toBeNull(); // Visible in the active listing now const { data: active } = await borja .from('shopping_lists') .select('id') .eq('id', id) .is('deleted_at', null) .single(); expect(active?.id).toBe(id); }); it('T-11: guest (David) cannot restore', async () => { const id = await makeTrashedList(); const david = await createClientAs(DAVID_ID); const { error } = await david.rpc('restore_list', { p_list_id: id }); expect(error).not.toBeNull(); }); it('T-12: non-member (Eva) cannot restore', async () => { const id = await makeTrashedList(); const eva = await createClientAs(EVA_ID); const { error } = await eva.rpc('restore_list', { p_list_id: id }); expect(error).not.toBeNull(); }); it('T-13: member can hard-delete a soft-deleted list', async () => { const id = await makeTrashedList(); const borja = await createClientAs(BORJA_ID); const { error } = await borja.rpc('hard_delete_list', { p_list_id: id }); expect(error).toBeNull(); const { data } = await admin.from('shopping_lists').select('id').eq('id', id); expect(data).toHaveLength(0); // Already gone — drop from cleanup list. const idx = trashedIds.indexOf(id); if (idx >= 0) trashedIds.splice(idx, 1); }); it('T-14: hard delete on an active (non-trashed) list is rejected', async () => { const r = await sql( `INSERT INTO public.shopping_lists (collective_id, name, status, created_by) VALUES ($1, 'Trash-RPC-guard', 'active', $2) RETURNING id`, [COLLECTIVE_ID, BORJA_ID] ); const id = r.rows[0].id as string; trashedIds.push(id); const borja = await createClientAs(BORJA_ID); const { error } = await borja.rpc('hard_delete_list', { p_list_id: id }); expect(error).not.toBeNull(); // Row still present const { data } = await admin.from('shopping_lists').select('id').eq('id', id).single(); expect(data?.id).toBe(id); }); });