Files
collective-lists/packages/test-utils/tests/common-items.test.ts
Oier Bravo Urtasun 3f96e6cdb3 feat(fase-15): fetchSuggestions weight ordering + commonItems store (15.2)
fetchSuggestions now:
  - Orders by `weight DESC, use_count DESC, last_used_at DESC` so admin-
    promoted items lead the dropdown regardless of historical use_count.
  - Accepts `{ excludeNames?: string[]; limit?: number }`. The exclude
    filter is applied as `.not('name', 'in', '(...)')` only when the
    sanitised list is non-empty (PostgREST renders an empty list as a
    SQL error). Each name is lowercased + trimmed to match the
    normalised form `item_frequency.name` is stored in.

commonItems.ts is the unbounded counterpart used by /collective/manage:
loadCommonItems (full catalogue, same sort), setWeight (RPC + optimistic
upsert into the store), purge (RPC + optimistic remove).

Integration test common-items.test.ts (6 specs) covers the role gate
(admin OK, member/guest get P0002), the boost-promotes-low-use-count
ordering, the excludeNames filter, the RPC purge, and the
seed-via-RPC-with-use_count=0 flow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:18:37 +02:00

171 lines
5.9 KiB
TypeScript

/**
* 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);
});
});