/** * LT-INT-series: list-title catalog + numbering (Fase 18). * * Validates the role gate on `add_list_title` / `remove_list_title` (the * only role-gate path pgTAP can't cover because postgres bypasses * auth.uid). Also exercises the suggestion union + the per-collective * numbering math directly against the dev DB so the SQL `or(...)` filter * and the client-side parser stay in lockstep. * * LT-INT-01 admin add → suggestions include the new title * LT-INT-02 member add → P0002 * LT-INT-03 computeNextNumber with 3 lists "Compra #1/#2/#5" → 6 * LT-INT-04 computeNextNumber with no matches → 1 * LT-INT-05 admin remove → row gone, suggestions exclude it * LT-INT-06 add empty title → P0001 */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { createClientAs, createAdminClient } from '../src/supabase-clients.js'; import { ANA_ID, BORJA_ID, COLLECTIVE_ID } from '../src/seed-constants.js'; import { computeNextNumberFromNames } from '../../../apps/web/src/lib/utils/list-title.js'; const admin = createAdminClient(); const PREFIX_TAG = 'LTINT-'; const CATALOG_TITLE = `${PREFIX_TAG}CuratedTitle`; const NUMBERED_PREFIX = `${PREFIX_TAG}NumPrefix`; beforeAll(async () => { // Wipe any residue from previous runs so the integration tests are // deterministic. We only delete rows we own (by prefix). await admin .from('collective_list_titles') .delete() .eq('collective_id', COLLECTIVE_ID) .like('title', `${PREFIX_TAG}%`); await admin .from('shopping_lists') .delete() .eq('collective_id', COLLECTIVE_ID) .like('name', `${NUMBERED_PREFIX}%`); }); afterAll(async () => { await admin .from('collective_list_titles') .delete() .eq('collective_id', COLLECTIVE_ID) .like('title', `${PREFIX_TAG}%`); await admin .from('shopping_lists') .delete() .eq('collective_id', COLLECTIVE_ID) .like('name', `${NUMBERED_PREFIX}%`); }); describe('list-title catalog + numbering (Fase 18)', () => { it('LT-INT-01: admin add → suggestions include the new title', async () => { const ana = await createClientAs(ANA_ID); const { error } = await ana.rpc('add_list_title', { p_collective_id: COLLECTIVE_ID, p_title: CATALOG_TITLE }); expect(error).toBeNull(); // The catalog read goes through `select_member` RLS — even guests can // see it, but admins certainly can. const { data } = await ana .from('collective_list_titles') .select('title') .eq('collective_id', COLLECTIVE_ID) .eq('title', CATALOG_TITLE); expect((data ?? []).length).toBe(1); }); it('LT-INT-02: member (Borja) add → P0002', async () => { const borja = await createClientAs(BORJA_ID); const { error } = await borja.rpc('add_list_title', { p_collective_id: COLLECTIVE_ID, p_title: `${PREFIX_TAG}MemberAttempt` }); expect(error).not.toBeNull(); expect((error as { code?: string }).code).toBe('P0002'); }); it('LT-INT-03: computeNextNumber across "Compra #1 / #2 / #5" → 6', async () => { // Seed three concrete shopping_lists rows whose names match the // prefix, then drive the same SQL the client-side helper uses. const ana = await createClientAs(ANA_ID); const names = [`${NUMBERED_PREFIX} #1`, `${NUMBERED_PREFIX} #2`, `${NUMBERED_PREFIX} #5`]; for (const name of names) { const { error } = await ana .from('shopping_lists') .insert({ collective_id: COLLECTIVE_ID, name, created_by: ANA_ID }); expect(error).toBeNull(); } // Mirror the production query in `computeNextNumber`. const orFilter = `name.ilike.${NUMBERED_PREFIX},name.ilike.${NUMBERED_PREFIX} #%`; const { data } = await ana .from('shopping_lists') .select('name') .eq('collective_id', COLLECTIVE_ID) .is('deleted_at', null) .or(orFilter); const next = computeNextNumberFromNames( NUMBERED_PREFIX, (data ?? []).map((r) => r.name) ); expect(next).toBe(6); }); it('LT-INT-04: computeNextNumber with no matching rows → 1', async () => { const ana = await createClientAs(ANA_ID); const orFilter = `name.ilike.${PREFIX_TAG}NoSuchPrefix,name.ilike.${PREFIX_TAG}NoSuchPrefix #%`; const { data } = await ana .from('shopping_lists') .select('name') .eq('collective_id', COLLECTIVE_ID) .is('deleted_at', null) .or(orFilter); const next = computeNextNumberFromNames( `${PREFIX_TAG}NoSuchPrefix`, (data ?? []).map((r) => r.name) ); expect(next).toBe(1); }); it('LT-INT-05: admin remove → row gone', async () => { const ana = await createClientAs(ANA_ID); const { error } = await ana.rpc('remove_list_title', { p_collective_id: COLLECTIVE_ID, p_title: CATALOG_TITLE }); expect(error).toBeNull(); const { data } = await ana .from('collective_list_titles') .select('title') .eq('collective_id', COLLECTIVE_ID) .eq('title', CATALOG_TITLE); expect((data ?? []).length).toBe(0); }); it('LT-INT-06: admin add with empty title → P0001', async () => { const ana = await createClientAs(ANA_ID); const { error } = await ana.rpc('add_list_title', { p_collective_id: COLLECTIVE_ID, p_title: ' ' }); expect(error).not.toBeNull(); expect((error as { code?: string }).code).toBe('P0001'); }); });