/** * CI-INT-series: common items (item_frequency.weight + admin RPCs + suggestion * ordering with weight). Fase 15. * * The RPC role-gate is what pgTAP can't cover (postgres bypasses auth.uid), * so we drive both happy and denied paths through authenticated JWTs here. * * CI-INT-01 admin set weight=50 → suggestions surface that item first. * CI-INT-02 excludeNames filters out the listed entries. * CI-INT-03 member set weight → P0002. * CI-INT-04 purge as admin removes the row. * CI-INT-05 purge as guest → P0002. * CI-INT-06 set_item_frequency_weight on a brand-new name inserts a row * with use_count=0. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { createClientAs, createAdminClient } from '../src/supabase-clients.js'; import { ANA_ID, BORJA_ID, DAVID_ID, COLLECTIVE_ID } from '../src/seed-constants.js'; const admin = createAdminClient(); // The integration tests share a single seed collective with everything else; // we tag our test rows with a unique-ish prefix so cleanup is bounded and we // don't trip over the seeded "milk" / "bread" / etc. const PREFIX = 'cifrq-'; const ITEM_BOOST = `${PREFIX}boost`; const ITEM_NORMAL = `${PREFIX}normal`; const ITEM_HIDDEN = `${PREFIX}hidden`; const ITEM_NEW = `${PREFIX}seeded-new`; const ITEM_TO_PURGE = `${PREFIX}purgeme`; beforeAll(async () => { // Seed three rows with descending use_counts; the boost test relies on // "boost" having a LOWER use_count than "normal" so the only way it can // surface first is via weight. await admin .from('item_frequency') .upsert( [ { collective_id: COLLECTIVE_ID, name: ITEM_NORMAL, use_count: 10, weight: 0 }, { collective_id: COLLECTIVE_ID, name: ITEM_BOOST, use_count: 1, weight: 0 }, { collective_id: COLLECTIVE_ID, name: ITEM_HIDDEN, use_count: 5, weight: 0 }, { collective_id: COLLECTIVE_ID, name: ITEM_TO_PURGE, use_count: 3, weight: 0 } ], { onConflict: 'collective_id,name' } ); }); afterAll(async () => { await admin .from('item_frequency') .delete() .eq('collective_id', COLLECTIVE_ID) .like('name', `${PREFIX}%`); }); describe('item_frequency.weight + admin RPCs', () => { it('CI-INT-01: admin set weight=50 promotes the row in the ordered query', async () => { const ana = await createClientAs(ANA_ID); // Reset baseline first so the test is order-independent inside a single run. await ana.rpc('set_item_frequency_weight', { p_collective_id: COLLECTIVE_ID, p_name: ITEM_NORMAL, p_weight: 0 }); const { error: rpcError } = await ana.rpc('set_item_frequency_weight', { p_collective_id: COLLECTIVE_ID, p_name: ITEM_BOOST, p_weight: 50 }); expect(rpcError).toBeNull(); // Mirror the production suggestion query (no prefix): order by weight, // then use_count, then last_used_at. Boost has use_count=1 vs normal's // 10 — weight is the only way it can surface above. const { data, error } = await ana .from('item_frequency') .select('name, weight, use_count') .eq('collective_id', COLLECTIVE_ID) .like('name', `${PREFIX}%`) .order('weight', { ascending: false }) .order('use_count', { ascending: false }); expect(error).toBeNull(); expect(data?.[0]?.name).toBe(ITEM_BOOST); expect(data?.[0]?.weight).toBe(50); }); it('CI-INT-02: excludeNames filters out the listed entries via .not("name","in",...)', async () => { const ana = await createClientAs(ANA_ID); const exclude = [ITEM_BOOST, ITEM_NORMAL]; const excludeList = `(${exclude.map((n) => `"${n}"`).join(',')})`; const { data, error } = await ana .from('item_frequency') .select('name') .eq('collective_id', COLLECTIVE_ID) .like('name', `${PREFIX}%`) .not('name', 'in', excludeList); expect(error).toBeNull(); const names = (data ?? []).map((r) => r.name); expect(names).not.toContain(ITEM_BOOST); expect(names).not.toContain(ITEM_NORMAL); // The other test rows should still be there. expect(names).toContain(ITEM_HIDDEN); }); it('CI-INT-03: member (Borja) cannot set weight — RPC raises P0002', async () => { const borja = await createClientAs(BORJA_ID); const { error } = await borja.rpc('set_item_frequency_weight', { p_collective_id: COLLECTIVE_ID, p_name: ITEM_HIDDEN, p_weight: -50 }); expect(error).not.toBeNull(); // PostgREST surfaces the SQLSTATE as `code` on the error object. // P0002 is the custom errcode the RPC raises on the role gate. expect((error as { code?: string }).code).toBe('P0002'); }); it('CI-INT-04: admin (Ana) can purge a row via purge_item_frequency', async () => { const ana = await createClientAs(ANA_ID); const { error: rpcError } = await ana.rpc('purge_item_frequency', { p_collective_id: COLLECTIVE_ID, p_name: ITEM_TO_PURGE }); expect(rpcError).toBeNull(); const { data } = await admin .from('item_frequency') .select('name') .eq('collective_id', COLLECTIVE_ID) .eq('name', ITEM_TO_PURGE) .maybeSingle(); expect(data).toBeNull(); }); it('CI-INT-05: guest (David) cannot purge — RPC raises P0002', async () => { const david = await createClientAs(DAVID_ID); const { error } = await david.rpc('purge_item_frequency', { p_collective_id: COLLECTIVE_ID, p_name: ITEM_HIDDEN }); expect(error).not.toBeNull(); expect((error as { code?: string }).code).toBe('P0002'); }); it('CI-INT-06: set_item_frequency_weight on a new name inserts row with use_count=0', async () => { const ana = await createClientAs(ANA_ID); const { error: rpcError } = await ana.rpc('set_item_frequency_weight', { p_collective_id: COLLECTIVE_ID, p_name: ITEM_NEW, p_weight: 50 }); expect(rpcError).toBeNull(); const { data } = await admin .from('item_frequency') .select('name, use_count, weight') .eq('collective_id', COLLECTIVE_ID) .eq('name', ITEM_NEW) .single(); expect(data?.use_count).toBe(0); expect(data?.weight).toBe(50); expect(data?.name).toBe(ITEM_NEW); }); });