feat(fase-11): item_tags + shopping_item_tags schema (11.1)
Migration 022 adds two new tables for collective-scoped item tags plus the many-to-many bridge to shopping_items. Tags carry a colour from an 8-preset palette enforced by CHECK. RLS uses is_member for SELECT (guests can see) and is_active_member for writes (guests are read-only). A new STABLE helper item_collective_id(uuid) resolves the parent collective in a single function call so the shopping_item_tags policies do not chain two STABLE lookups. Both tables join the supabase_realtime publication with REPLICA IDENTITY FULL, matching the shape of 007 for shopping_items / shopping_lists. pgTAP 014 covers schema invariants (publication, replica identity, unique + CHECK constraints, cascade behaviour for both tag delete and collective delete). Per-role RLS semantics will be covered by the Vitest integration suite in the next commit — pgTAP runs as postgres which bypasses RLS. Types: ItemTag, ItemWithTags + ItemTagColor in domain.ts; item_tags, shopping_item_tags, item_collective_id added by hand to the curated database.ts (the supabase CLI is not installed locally; just db-types is gated and skips with a notice). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
142
supabase/tests/014_item_tags.sql
Normal file
142
supabase/tests/014_item_tags.sql
Normal file
@@ -0,0 +1,142 @@
|
||||
-- pgTAP: item_tags + shopping_item_tags structural invariants (Fase 11)
|
||||
-- Run with: psql -U postgres -d postgres -f supabase/tests/014_item_tags.sql
|
||||
--
|
||||
-- These pgTAP assertions verify schema-level invariants only (publication
|
||||
-- membership, REPLICA IDENTITY, unique constraint, cascade behaviour). The
|
||||
-- per-role RLS semantics (member can insert, non-member denied, cross-
|
||||
-- collective attach rejected) are covered by the Vitest integration suite
|
||||
-- (packages/test-utils/tests/rls-item-tags.test.ts) which connects as an
|
||||
-- authenticated role — pgTAP runs as postgres which bypasses RLS.
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgtap;
|
||||
|
||||
BEGIN;
|
||||
SELECT plan(8);
|
||||
|
||||
-- T-01: item_tags is in the supabase_realtime publication
|
||||
SELECT ok(
|
||||
EXISTS (
|
||||
SELECT 1 FROM pg_publication_tables
|
||||
WHERE pubname = 'supabase_realtime'
|
||||
AND schemaname = 'public'
|
||||
AND tablename = 'item_tags'
|
||||
),
|
||||
'T-01: item_tags is part of the supabase_realtime publication'
|
||||
);
|
||||
|
||||
-- T-02: shopping_item_tags is in the supabase_realtime publication
|
||||
SELECT ok(
|
||||
EXISTS (
|
||||
SELECT 1 FROM pg_publication_tables
|
||||
WHERE pubname = 'supabase_realtime'
|
||||
AND schemaname = 'public'
|
||||
AND tablename = 'shopping_item_tags'
|
||||
),
|
||||
'T-02: shopping_item_tags is part of the supabase_realtime publication'
|
||||
);
|
||||
|
||||
-- T-03: REPLICA IDENTITY FULL on item_tags
|
||||
SELECT results_eq(
|
||||
$$ SELECT relreplident::text FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = 'public' AND c.relname = 'item_tags' $$,
|
||||
$$ VALUES ('f') $$,
|
||||
'T-03: item_tags has REPLICA IDENTITY FULL'
|
||||
);
|
||||
|
||||
-- T-04: REPLICA IDENTITY FULL on shopping_item_tags
|
||||
SELECT results_eq(
|
||||
$$ SELECT relreplident::text FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = 'public' AND c.relname = 'shopping_item_tags' $$,
|
||||
$$ VALUES ('f') $$,
|
||||
'T-04: shopping_item_tags has REPLICA IDENTITY FULL'
|
||||
);
|
||||
|
||||
-- T-05: UNIQUE (collective_id, name) enforced
|
||||
INSERT INTO public.item_tags (collective_id, name, color)
|
||||
VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'pgtap-vegano', 'green');
|
||||
|
||||
SELECT throws_ok(
|
||||
$$ INSERT INTO public.item_tags (collective_id, name, color)
|
||||
VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'pgtap-vegano', 'red') $$,
|
||||
'23505',
|
||||
NULL,
|
||||
'T-05: (collective_id, name) unique constraint enforced'
|
||||
);
|
||||
|
||||
-- T-06: color CHECK constraint rejects unknown palette entries
|
||||
SELECT throws_ok(
|
||||
$$ INSERT INTO public.item_tags (collective_id, name, color)
|
||||
VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'pgtap-bad-color', 'rainbow') $$,
|
||||
'23514',
|
||||
NULL,
|
||||
'T-06: color CHECK constraint blocks values outside the 8-preset palette'
|
||||
);
|
||||
|
||||
-- T-07: shopping_item_tags cascades on item_tags DELETE
|
||||
INSERT INTO public.item_tags (id, collective_id, name, color)
|
||||
VALUES (
|
||||
'eeeeeeee-1111-1111-1111-111111111111',
|
||||
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||
'pgtap-cascade-tag',
|
||||
'sky'
|
||||
);
|
||||
|
||||
INSERT INTO public.shopping_item_tags (item_id, tag_id)
|
||||
SELECT id, 'eeeeeeee-1111-1111-1111-111111111111'
|
||||
FROM public.shopping_items
|
||||
WHERE list_id = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'
|
||||
ORDER BY sort_order
|
||||
LIMIT 1;
|
||||
|
||||
DELETE FROM public.item_tags WHERE id = 'eeeeeeee-1111-1111-1111-111111111111';
|
||||
|
||||
SELECT is(
|
||||
(SELECT count(*)::int FROM public.shopping_item_tags
|
||||
WHERE tag_id = 'eeeeeeee-1111-1111-1111-111111111111'),
|
||||
0,
|
||||
'T-07: deleting a tag cascades shopping_item_tags rows'
|
||||
);
|
||||
|
||||
-- T-08: deleting the collective cascades item_tags + shopping_item_tags
|
||||
-- Build an isolated mini-tree: collective → list → item, plus a tag attached.
|
||||
INSERT INTO public.collectives (id, name, emoji, created_by)
|
||||
VALUES ('aaaaaaaa-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'pgtap-cascade-collective', '🧪', '11111111-1111-1111-1111-111111111111');
|
||||
|
||||
INSERT INTO public.collective_members (collective_id, user_id, role)
|
||||
VALUES ('aaaaaaaa-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '11111111-1111-1111-1111-111111111111', 'admin');
|
||||
|
||||
INSERT INTO public.shopping_lists (id, collective_id, name, status, created_by)
|
||||
VALUES (
|
||||
'bbbbbbbb-1111-1111-1111-111111111111',
|
||||
'aaaaaaaa-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
|
||||
'cascade list', 'active', '11111111-1111-1111-1111-111111111111'
|
||||
);
|
||||
|
||||
INSERT INTO public.shopping_items (id, list_id, name, sort_order, created_by)
|
||||
VALUES (
|
||||
'cccccccc-1111-1111-1111-111111111111',
|
||||
'bbbbbbbb-1111-1111-1111-111111111111',
|
||||
'cascade item', 0, '11111111-1111-1111-1111-111111111111'
|
||||
);
|
||||
|
||||
INSERT INTO public.item_tags (id, collective_id, name, color)
|
||||
VALUES ('eeeeeeee-2222-2222-2222-222222222222',
|
||||
'aaaaaaaa-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
|
||||
'cascade tag', 'pink');
|
||||
|
||||
INSERT INTO public.shopping_item_tags (item_id, tag_id)
|
||||
VALUES ('cccccccc-1111-1111-1111-111111111111',
|
||||
'eeeeeeee-2222-2222-2222-222222222222');
|
||||
|
||||
DELETE FROM public.collectives WHERE id = 'aaaaaaaa-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
|
||||
|
||||
SELECT ok(
|
||||
NOT EXISTS (SELECT 1 FROM public.item_tags WHERE id = 'eeeeeeee-2222-2222-2222-222222222222')
|
||||
AND NOT EXISTS (SELECT 1 FROM public.shopping_item_tags WHERE tag_id = 'eeeeeeee-2222-2222-2222-222222222222'),
|
||||
'T-08: deleting a collective cascades item_tags + shopping_item_tags'
|
||||
);
|
||||
|
||||
SELECT * FROM finish();
|
||||
ROLLBACK;
|
||||
Reference in New Issue
Block a user