feat(fase-18): listTitles store + numbering utils + unit + integration

`$lib/utils/list-title.ts` is the pure module — `parseTitle` extracts
{prefix, number} from a typed value, `nextNumberFromNumbers` is the
trivial `max + 1`, and `computeNextNumberFromNames` walks a row list,
counts a bare-prefix match as N=0, and falls back to 1 when nothing
matches. The regex requires whitespace between prefix and `#` so
"Compra#5" stays literal (the chip only fires for catalog-curated
prefixes). 19 unit tests cover LT-U-01..05 plus seven boundary cases
(multi-word prefixes, case-insensitive matching, substring near-miss,
trimming, empty input, bare-hash rejection).

`$lib/stores/listTitles.ts` wraps the two SECURITY DEFINER RPCs
(add/remove) and exposes `loadTitleCatalog`, `setDefaultListTitle`
(plain UPDATE on `collectives` — RLS already gates UPDATE to admins),
`fetchTitleSuggestions` (catalog ∪ last 10 distinct shopping_lists.name,
dedup case-insensitive, cap 15) and `computeNextNumber` (driver for the
SQL `name.ilike.<prefix>,name.ilike.<prefix> #%` filter that feeds the
pure helper). Race-condition note in the docstring points back to the
migration header.

Integration test `list-title-flow.test.ts` (6 cases): admin add round-
trips into the catalog (LT-INT-01), Borja's member-attempt yields P0002
(LT-INT-02), the computeNextNumber math is end-to-end exercised against
the dev DB with 3 seeded "Compra #1/#2/#5" rows → 6 (LT-INT-03), the
no-matches path returns 1 (LT-INT-04), admin remove deletes the row
(LT-INT-05), and an empty-title insert is rejected with P0001 (LT-INT-06).
The pgTAP suite can't drive these because postgres bypasses
auth.uid — same caveat documented in Fase 15's common-items.test.ts.

Tests: 75 unit (was 56; +19), 185 integration (was 179; +6).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 02:11:34 +02:00
parent bd9a0a6d3f
commit 3c4551acd9
4 changed files with 599 additions and 0 deletions

View File

@@ -0,0 +1,154 @@
/**
* 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');
});
});