/** * E-series: item_frequency RLS — read-only for members, trigger-only writes. * The table is populated exclusively by fn_record_item_frequency (SECURITY DEFINER trigger). * All direct INSERT/UPDATE/DELETE must be denied. */ import { describe, it, expect, 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'; const admin = createAdminClient(); describe('item_frequency read access', () => { it('E-01: member (Borja) can read frequency suggestions', async () => { const borja = await createClientAs(BORJA_ID); const { data, error } = await borja .from('item_frequency') .select('name, use_count') .eq('collective_id', COLLECTIVE_ID) .order('use_count', { ascending: false }) .limit(5); expect(error).toBeNull(); expect(data!.length).toBeGreaterThanOrEqual(1); }); it('E-02: guest (David) can read frequency suggestions', async () => { const david = await createClientAs(DAVID_ID); const { data, error } = await david .from('item_frequency') .select('name') .eq('collective_id', COLLECTIVE_ID); expect(error).toBeNull(); expect(data!.length).toBeGreaterThanOrEqual(1); }); it('E-03: Eva (non-member) sees no frequency data', async () => { const eva = await createClientAs(EVA_ID); const { data } = await eva .from('item_frequency') .select('name') .eq('collective_id', COLLECTIVE_ID); expect(data).toHaveLength(0); }); it('E-04: prefix filter returns matching suggestions', async () => { const borja = await createClientAs(BORJA_ID); const { data, error } = await borja .from('item_frequency') .select('name') .eq('collective_id', COLLECTIVE_ID) .ilike('name', 'mi%'); expect(error).toBeNull(); // "milk" matches "mi%" expect(data!.some((row) => row.name === 'milk')).toBe(true); }); }); describe('item_frequency write protection', () => { it('E-05: member (Borja) cannot INSERT directly into item_frequency', async () => { const borja = await createClientAs(BORJA_ID); const { data, error } = await borja .from('item_frequency') .insert({ collective_id: COLLECTIVE_ID, name: 'hacked item', use_count: 999 }) .select() .single(); expect(data).toBeNull(); expect(error).not.toBeNull(); }); it('E-06: admin (Ana) cannot UPDATE item_frequency directly', async () => { const ana = await createClientAs(ANA_ID); await ana .from('item_frequency') .update({ use_count: 999 }) .eq('collective_id', COLLECTIVE_ID) .eq('name', 'milk'); // Verify count was not changed const { data } = await admin .from('item_frequency') .select('use_count') .eq('collective_id', COLLECTIVE_ID) .eq('name', 'milk') .single(); expect(data?.use_count).not.toBe(999); }); it('E-07: member cannot DELETE from item_frequency', async () => { const borja = await createClientAs(BORJA_ID); await borja .from('item_frequency') .delete() .eq('collective_id', COLLECTIVE_ID) .eq('name', 'milk'); // Verify "milk" still exists const { data } = await admin .from('item_frequency') .select('name') .eq('collective_id', COLLECTIVE_ID) .eq('name', 'milk') .single(); expect(data?.name).toBe('milk'); }); }); describe('item_frequency trigger — populated via INSERT on shopping_items', () => { let addedItemId: string; const TEST_ITEM_NAME = 'trigger-test-item-unique'; let testListId: string; // Create a temporary list for this test group afterAll(async () => { if (addedItemId) { await admin.from('shopping_items').delete().eq('id', addedItemId); } if (testListId) { await admin.from('shopping_lists').delete().eq('id', testListId); } // Clean up the frequency record await admin .from('item_frequency') .delete() .eq('collective_id', COLLECTIVE_ID) .eq('name', TEST_ITEM_NAME.toLowerCase().trim()); }); it('E-08: adding an item increments item_frequency via trigger', async () => { // Get a list to add to const { data: lists } = await admin .from('shopping_lists') .select('id') .eq('collective_id', COLLECTIVE_ID) .is('deleted_at', null) .limit(1); // Use seed list or create one const listId = lists?.[0]?.id; if (!listId) { const { data: newList } = await admin .from('shopping_lists') .insert({ collective_id: COLLECTIVE_ID, name: 'Trigger test list', created_by: ANA_ID }) .select('id') .single(); testListId = newList!.id; } const useListId = listId ?? testListId; // Check before count const { data: before } = await admin .from('item_frequency') .select('use_count') .eq('collective_id', COLLECTIVE_ID) .eq('name', TEST_ITEM_NAME.toLowerCase()) .maybeSingle(); const beforeCount = before?.use_count ?? 0; // Insert an item (trigger fires) const ana = await createClientAs(ANA_ID); const { data: item, error } = await ana .from('shopping_items') .insert({ list_id: useListId, name: TEST_ITEM_NAME, sort_order: 999, created_by: ANA_ID }) .select('id') .single(); expect(error).toBeNull(); addedItemId = item!.id; // Check after count const { data: after } = await admin .from('item_frequency') .select('use_count') .eq('collective_id', COLLECTIVE_ID) .eq('name', TEST_ITEM_NAME.toLowerCase()) .single(); expect(after?.use_count).toBe(beforeCount + 1); }); });