/** * S-series: global search (`search_in_collective`) — filtering, RLS isolation, * trash exclusion (RN-08), and by-type / by-creator / by-date-range filters. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { createClientAs, createAdminClient } from '../src/supabase-clients.js'; import { ANA_ID, BORJA_ID, EVA_ID, COLLECTIVE_ID, SEED_LIST_ID } from '../src/seed-constants.js'; import { closePool } from '../src/db-helpers.js'; const admin = createAdminClient(); // Fixtures use a shared lexeme-safe token that appears as a standalone word in // every fixture (`simple` tsvector tokenises on whitespace — substrings don't match). const UNIQUE = `needle${Date.now()}`; const ITEM_TOKEN = `itm${Date.now()}`; const TASK_TOKEN = `tsk${Date.now()}`; const NOTE_TOKEN = `nt${Date.now()}`; const TRASH_TOKEN = `trash${Date.now()}`; const cleanup: { itemIds: string[]; taskListIds: string[]; noteIds: string[] } = { itemIds: [], taskListIds: [], noteIds: [] }; let taskListId: string; beforeAll(async () => { // Shopping item in the seed list const { data: item } = await admin .from('shopping_items') .insert({ list_id: SEED_LIST_ID, name: `${UNIQUE} ${ITEM_TOKEN}`, sort_order: 990, created_by: ANA_ID }) .select('id') .single(); cleanup.itemIds.push(item!.id); // Task list + task const { data: tl } = await admin .from('task_lists') .insert({ collective_id: COLLECTIVE_ID, name: 'Search test list', created_by: ANA_ID }) .select('id') .single(); taskListId = tl!.id; cleanup.taskListIds.push(taskListId); await admin .from('tasks') .insert({ list_id: taskListId, title: `${UNIQUE} ${TASK_TOKEN}`, created_by: BORJA_ID }); // Note (active) const { data: note } = await admin .from('notes') .insert({ collective_id: COLLECTIVE_ID, title: `${UNIQUE} ${NOTE_TOKEN}`, content: 'recipe ingredients', created_by: ANA_ID, updated_by: ANA_ID }) .select('id') .single(); cleanup.noteIds.push(note!.id); // Note in trash — must NOT appear in search (RN-08) const { data: trashedNote } = await admin .from('notes') .insert({ collective_id: COLLECTIVE_ID, content: `secret ${TRASH_TOKEN} content`, created_by: ANA_ID, updated_by: ANA_ID, deleted_at: new Date().toISOString() }) .select('id') .single(); cleanup.noteIds.push(trashedNote!.id); }); afterAll(async () => { if (cleanup.itemIds.length) { await admin.from('shopping_items').delete().in('id', cleanup.itemIds); } if (cleanup.noteIds.length) { await admin.from('notes').delete().in('id', cleanup.noteIds); } if (cleanup.taskListIds.length) { await admin.from('task_lists').delete().in('id', cleanup.taskListIds); } await closePool(); }); type SearchRow = { id: string; result_type: 'shopping_item' | 'task' | 'note'; title: string | null; snippet: string; collective_id: string; list_id: string | null; created_by: string; created_at: string; rank: number; }; async function search( client: Awaited>, query: string, opts: { types?: SearchRow['result_type'][]; creator?: string; from?: string; to?: string } = {} ): Promise { const { data, error } = await client.rpc('search_in_collective' as never, { p_collective_id: COLLECTIVE_ID, p_query: query, p_types: opts.types ?? null, p_creator: opts.creator ?? null, p_from: opts.from ?? null, p_to: opts.to ?? null } as never); if (error) throw error; return (data ?? []) as SearchRow[]; } describe('search_in_collective — base queries', () => { it('S-01: member (Borja) finds shopping items by name', async () => { const borja = await createClientAs(BORJA_ID); const rows = await search(borja, ITEM_TOKEN); expect(rows.length).toBeGreaterThan(0); expect(rows.some((r) => r.result_type === 'shopping_item' && r.title?.includes(ITEM_TOKEN))).toBe(true); }); it('S-01b: member finds tasks by title', async () => { const borja = await createClientAs(BORJA_ID); const rows = await search(borja, TASK_TOKEN); expect(rows.some((r) => r.result_type === 'task' && r.title?.includes(TASK_TOKEN))).toBe(true); }); it('S-01c: member finds notes by title or content', async () => { const borja = await createClientAs(BORJA_ID); const rows = await search(borja, NOTE_TOKEN); expect(rows.some((r) => r.result_type === 'note' && (r.title?.includes(NOTE_TOKEN) ?? false))).toBe(true); }); }); describe('search_in_collective — RLS isolation', () => { it('S-02: Eva (non-member of main collective) sees zero rows', async () => { const eva = await createClientAs(EVA_ID); const rows = await search(eva, ITEM_TOKEN); expect(rows).toHaveLength(0); }); it('S-02b: passing a stranger collective_id yields zero rows (RLS blocks the joins)', async () => { // Ana querying for a completely unrelated UUID const ana = await createClientAs(ANA_ID); const { data } = await ana.rpc('search_in_collective' as never, { p_collective_id: '00000000-0000-0000-0000-000000000000', p_query: ITEM_TOKEN, p_types: null, p_creator: null, p_from: null, p_to: null } as never); expect(data ?? []).toHaveLength(0); }); }); describe('search_in_collective — trash exclusion (RN-08)', () => { it('S-03: a soft-deleted note does not appear in results', async () => { const borja = await createClientAs(BORJA_ID); const rows = await search(borja, TRASH_TOKEN); expect(rows).toHaveLength(0); }); }); describe('search_in_collective — filters', () => { it('S-04a: type filter restricts to shopping_item only', async () => { const borja = await createClientAs(BORJA_ID); const rows = await search(borja, UNIQUE, { types: ['shopping_item'] }); expect(rows.length).toBeGreaterThan(0); expect(rows.every((r) => r.result_type === 'shopping_item')).toBe(true); }); it('S-04b: creator filter restricts to a single user', async () => { const borja = await createClientAs(BORJA_ID); // Only the task is by BORJA; item + note are by ANA const rows = await search(borja, UNIQUE, { creator: BORJA_ID }); expect(rows.length).toBeGreaterThan(0); expect(rows.every((r) => r.created_by === BORJA_ID)).toBe(true); }); it('S-04c: date range in the far past returns no rows', async () => { const borja = await createClientAs(BORJA_ID); const rows = await search(borja, UNIQUE, { from: '1970-01-01T00:00:00Z', to: '1970-12-31T23:59:59Z' }); expect(rows).toHaveLength(0); }); it('S-04d: date range covering now returns at least one match', async () => { const borja = await createClientAs(BORJA_ID); const from = new Date(Date.now() - 1000 * 60 * 60).toISOString(); const rows = await search(borja, UNIQUE, { from }); expect(rows.length).toBeGreaterThan(0); }); });