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:
154
packages/test-utils/tests/list-title-flow.test.ts
Normal file
154
packages/test-utils/tests/list-title-flow.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user