diff --git a/supabase/migrations/20261103142204_catalog_foundation.sql b/supabase/migrations/20261103142204_catalog_foundation.sql new file mode 100644 index 0000000..e528cc2 --- /dev/null +++ b/supabase/migrations/20261103142204_catalog_foundation.sql @@ -0,0 +1,336 @@ +-- KAT-05 / PR 2a: Katalog-Fundament (Spec docs/specs/2-katalog-daten.md, Abschnitt 3.2). +-- Nur additiv. Enthält: Quellen (catalog_sources), neue Spalten an games, Verlage, +-- Kennzeichnung manuell gepflegter Felder, Ausblenden/Zusammenführen (Spalten), View catalog_games +-- und die neu geschnittenen Spaltenrechte auf games. +-- Nicht enthalten (spätere PRs): admin_merge_games (2b), game_reports und +-- games_visibility_immutable (2c). + +-- --------------------------------------------------------------------------------------------- +-- Quellen: woher Katalogdaten stammen und was davon angezeigt werden darf (Krit. 2, 6; E5) +-- --------------------------------------------------------------------------------------------- +create table public.catalog_sources ( + id uuid primary key default gen_random_uuid(), + key text not null unique check (key ~ '^[a-z0-9_]{2,30}$'), + name text not null check (char_length(name) between 1 and 100), + attribution_text text check (char_length(attribution_text) <= 200), + attribution_url text check (char_length(attribution_url) <= 500 and attribution_url ~ '^https://'), + images_allowed boolean not null default false, + texts_allowed boolean not null default false, + terms_url text check (char_length(terms_url) <= 500 and terms_url ~ '^https://'), + terms_checked_at date, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create trigger set_updated_at before update on public.catalog_sources + for each row execute function public.set_updated_at(); + +-- Referenzdaten, keine Katalogdaten: BGG ist als Quelle bekannt, aber ohne Anzeige-Erlaubnis. +insert into public.catalog_sources (key, name, attribution_text, attribution_url, images_allowed, texts_allowed) +values ('bgg', 'BoardGameGeek', 'Powered by BGG', 'https://boardgamegeek.com', false, false); + +-- --------------------------------------------------------------------------------------------- +-- Verlage (Klärung 4, E7) +-- --------------------------------------------------------------------------------------------- +create table public.publishers ( + id uuid primary key default gen_random_uuid(), + name text not null check (char_length(name) between 1 and 200), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); +create unique index publishers_name_key on public.publishers (lower(name)); + +create table public.game_publishers ( + id uuid primary key default gen_random_uuid(), + game_id uuid not null references public.games (id) on delete cascade, + publisher_id uuid not null references public.publishers (id) on delete cascade, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (game_id, publisher_id) +); +create index game_publishers_publisher_id_idx on public.game_publishers (publisher_id); + +create trigger set_updated_at before update on public.publishers + for each row execute function public.set_updated_at(); +create trigger set_updated_at before update on public.game_publishers + for each row execute function public.set_updated_at(); + +-- --------------------------------------------------------------------------------------------- +-- Neue Spalten an games +-- --------------------------------------------------------------------------------------------- +alter table public.games + add column description_locale text, + add column hidden_at timestamptz, + add column merged_into_id uuid, + add column manual_fields text[] not null default '{}', + add column catalog_source_id uuid, + add column source_ref text, + add column source_url text, + add column source_synced_at timestamptz; + +-- Fremdschlüssel: Ziel eines Zusammenführens und Quellen werden nie gelöscht (H7). +alter table public.games + add constraint games_merged_into_id_fkey + foreign key (merged_into_id) references public.games (id) on delete restrict, + add constraint games_catalog_source_id_fkey + foreign key (catalog_source_id) references public.catalog_sources (id) on delete restrict; + +alter table public.games + add constraint games_description_locale_check + check (description_locale in ('de', 'en')), + add constraint games_description_locale_needs_description + check (description_locale is null or description is not null), + add constraint games_merged_into_not_self + check (merged_into_id <> id), + add constraint games_merged_requires_hidden + check (merged_into_id is null or hidden_at is not null), + -- Schlüsselliste siehe Spec 3.2. Neue Schlüssel ergänzt die jeweilige Story per Migration. + add constraint games_manual_fields_check + check (manual_fields <@ array['title', 'description', 'description_locale', 'year', + 'min_players', 'max_players', 'min_minutes', 'max_minutes', 'min_age', 'image_path', + 'genres', 'mechanics', 'categories', 'publishers']::text[]), + add constraint games_source_ref_check + check (char_length(source_ref) between 1 and 100), + add constraint games_source_url_check + check (char_length(source_url) <= 500 and source_url ~ '^https://'), + add constraint games_source_ref_matches_source + check ((catalog_source_id is null) = (source_ref is null)), + add constraint games_source_details_need_source + check (catalog_source_id is not null or (source_url is null and source_synced_at is null)); + +create unique index games_catalog_source_ref_key on public.games (catalog_source_id, source_ref) + where visibility = 'catalog' and catalog_source_id is not null; +create index games_catalog_source_id_idx on public.games (catalog_source_id); +create index games_merged_into_id_idx on public.games (merged_into_id); +-- Grundlage der Suche (KAT-01 baut darauf aus): nur sichtbare Katalogspiele. +create index games_catalog_listed_idx on public.games (lower(title)) + where visibility = 'catalog' and hidden_at is null and merged_into_id is null; + +-- --------------------------------------------------------------------------------------------- +-- Trigger: manuell gepflegte Felder kennzeichnen (Krit. 1, 4; E4) +-- Beide Funktionen: security invoker, search_path leer. EXECUTE wird entzogen; ein Trigger feuert +-- unabhängig davon. +-- --------------------------------------------------------------------------------------------- +create function public.games_mark_manual_fields() +returns trigger +language plpgsql +set search_path = '' +as $$ +declare + cols constant text[] := array['title', 'description', 'description_locale', 'year', + 'min_players', 'max_players', 'min_minutes', 'max_minutes', 'min_age', 'image_path']; + col text; + n jsonb := to_jsonb(new); + o jsonb; + marked text[] := '{}'; +begin + if new.visibility <> 'catalog' then + return new; + end if; + -- Ausnahme für den späteren Import: nur als Rolle catalog_importer UND mit set local. + -- Ein bloßes set im Studio (Rolle postgres) schaltet die Kennzeichnung nicht ab (W7). + if current_user = 'catalog_importer' + and coalesce(current_setting('shuffle.catalog_import', true), '') = 'on' then + return new; + end if; + + if tg_op = 'UPDATE' then + o := to_jsonb(old); + end if; + foreach col in array cols loop + if tg_op = 'INSERT' then + if n -> col <> 'null'::jsonb then + marked := marked || col; + end if; + elsif n -> col is distinct from o -> col then + marked := marked || col; + end if; + end loop; + + -- Es wird nur ergänzt, nie entfernt: Eine bewusste Änderung nur an manual_fields bleibt erhalten. + if cardinality(marked) > 0 or tg_op = 'INSERT' then + new.manual_fields := array( + select distinct x from unnest(new.manual_fields || marked) as x order by 1); + end if; + return new; +end; +$$; + +create trigger games_mark_manual_fields before insert or update on public.games + for each row execute function public.games_mark_manual_fields(); + +-- Zuordnungen (Genre, Mechanik, Kategorie, Verlag) zählen als Pseudo-Felder. Argument: Feldname. +create function public.game_links_mark_manual() +returns trigger +language plpgsql +set search_path = '' +as $$ +declare + field constant text := tg_argv[0]; + ids uuid[] := '{}'; + gid uuid; +begin + if current_user = 'catalog_importer' + and coalesce(current_setting('shuffle.catalog_import', true), '') = 'on' then + return null; + end if; + + if tg_op in ('INSERT', 'UPDATE') then + ids := ids || new.game_id; + end if; + if tg_op in ('UPDATE', 'DELETE') then + if not (old.game_id = any(ids)) then + ids := ids || old.game_id; + end if; + end if; + + foreach gid in array ids loop + -- Erst prüfen, dann schreiben: Nutzer ändern Zuordnungen privater Spiele und haben kein + -- Schreibrecht auf manual_fields; die UPDATE-Anweisung darf für sie nie ausgeführt werden. + -- Nur bei fehlendem Schlüssel schreiben, damit updated_at des Spiels stabil bleibt. + if exists ( + select 1 from public.games g + where g.id = gid and g.visibility = 'catalog' and not (field = any(g.manual_fields)) + ) then + update public.games g + set manual_fields = array(select distinct x from unnest(g.manual_fields || field) as x order by 1) + where g.id = gid; + end if; + end loop; + return null; +end; +$$; + +create trigger game_links_mark_manual after insert or update or delete on public.game_genres + for each row execute function public.game_links_mark_manual('genres'); +create trigger game_links_mark_manual after insert or update or delete on public.game_mechanics + for each row execute function public.game_links_mark_manual('mechanics'); +create trigger game_links_mark_manual after insert or update or delete on public.game_categories + for each row execute function public.game_links_mark_manual('categories'); +create trigger game_links_mark_manual after insert or update or delete on public.game_publishers + for each row execute function public.game_links_mark_manual('publishers'); + +-- merged_into_id darf nur auf Katalogspiele zeigen (H7). Ein Check-Constraint darf keine andere +-- Zeile lesen. Ohne den Trigger könnte ein Studio-Handgriff einen Verweis auf ein privates Spiel +-- erzeugen, dessen Löschen beim Konto-Löschen an on delete restrict scheitern würde (KTO-05). +create function public.games_merged_into_catalog() +returns trigger +language plpgsql +set search_path = '' +as $$ +declare + target_visibility text; +begin + if new.merged_into_id is not null then + select g.visibility into target_visibility from public.games g where g.id = new.merged_into_id; + -- Existiert das Ziel nicht, meldet der Fremdschlüssel den Fehler (23503). + if target_visibility is not null and target_visibility <> 'catalog' then + raise exception 'merged_into_not_catalog' using errcode = 'P0001'; + end if; + end if; + return new; +end; +$$; + +create trigger games_merged_into_catalog before insert or update of merged_into_id on public.games + for each row execute function public.games_merged_into_catalog(); + +-- Rechte-Regel: neue Funktionen sind für niemanden aufrufbar (nur Eigentümer). +revoke execute on function public.games_mark_manual_fields() from public, anon, authenticated, service_role; +revoke execute on function public.game_links_mark_manual() from public, anon, authenticated, service_role; +revoke execute on function public.games_merged_into_catalog() from public, anon, authenticated, service_role; + +-- --------------------------------------------------------------------------------------------- +-- Rechte und RLS (Supabase-Standardrechte zuerst entziehen) +-- --------------------------------------------------------------------------------------------- +alter table public.catalog_sources enable row level security; +alter table public.publishers enable row level security; +alter table public.game_publishers enable row level security; + +revoke all on public.catalog_sources, public.publishers, public.game_publishers + from anon, authenticated; +grant select on public.catalog_sources, public.publishers, public.game_publishers + to anon, authenticated; +grant insert, update, delete on public.catalog_sources, public.publishers, public.game_publishers + to authenticated; + +create policy catalog_sources_select on public.catalog_sources + for select to anon, authenticated using (true); +-- Schreiben nur Admin (is_admin); in R1 ungenutzt, Pflege über Studio (E10). +create policy catalog_sources_write_admin on public.catalog_sources + for all to authenticated + using (public.is_admin()) with check (public.is_admin()); + +create policy publishers_select on public.publishers + for select to anon, authenticated using (true); +create policy publishers_write_admin on public.publishers + for all to authenticated + using (public.is_admin()) with check (public.is_admin()); + +create policy game_publishers_select on public.game_publishers + for select to anon, authenticated + using (exists (select 1 from public.games g where g.id = game_id)); +create policy game_publishers_write on public.game_publishers + for all to authenticated + using ( + public.is_admin() + or exists (select 1 from public.games g + where g.id = game_id and g.owner_id = (select auth.uid())) + ) + with check ( + public.is_admin() + or exists (select 1 from public.games g + where g.id = game_id and g.owner_id = (select auth.uid())) + ); + +-- games: Schreibrechte spaltenweise. Nutzer können hidden_at, merged_into_id, manual_fields, +-- catalog_source_id und source_* nie setzen, auch nicht an eigenen privaten Spielen (Krit. 9). +-- id fehlt bewusst: Clients wählen keine IDs. select und delete bleiben wie bisher. +revoke insert, update on public.games from authenticated; +grant insert (owner_id, title, description, description_locale, year, min_players, max_players, + min_minutes, max_minutes, min_age, image_path, bgg_id, visibility) + on public.games to authenticated; +grant update (title, description, description_locale, year, min_players, max_players, + min_minutes, max_minutes, min_age, image_path, bgg_id, visibility) + on public.games to authenticated; + +-- --------------------------------------------------------------------------------------------- +-- View catalog_games: was Suche und Entdecken sehen (Krit. 4, E3, E6) +-- Ausgeblendete und zusammengeführte Spiele fehlen. description/image_path nur, wenn die Quelle +-- die Anzeige erlaubt, das Feld manuell gepflegt ist oder es keine Quelle gibt (zweite Sicherung; +-- die erste ist, solche Daten gar nicht zu speichern). +-- security_invoker: die RLS-Regeln von games und catalog_sources gelten für den Aufrufer. +-- --------------------------------------------------------------------------------------------- +create view public.catalog_games with (security_invoker = true) as +select + g.id, + g.title, + g.year, + g.min_players, + g.max_players, + g.min_minutes, + g.max_minutes, + g.min_age, + g.bgg_id, + g.description_locale, + g.created_at, + g.updated_at, + case when g.catalog_source_id is null + or 'description' = any(g.manual_fields) + or s.texts_allowed + then g.description end as description, + case when g.catalog_source_id is null + or 'image_path' = any(g.manual_fields) + or s.images_allowed + then g.image_path end as image_path, + s.key as source_key, + s.name as source_name, + s.attribution_text as source_attribution_text, + s.attribution_url as source_attribution_url, + g.source_url as source_url +from public.games g +left join public.catalog_sources s on s.id = g.catalog_source_id +where g.visibility = 'catalog' and g.hidden_at is null and g.merged_into_id is null; + +revoke all on public.catalog_games from anon, authenticated; +grant select on public.catalog_games to anon, authenticated; diff --git a/supabase/tests/database/00_schema.test.sql b/supabase/tests/database/00_schema.test.sql index bd39c67..359840d 100644 --- a/supabase/tests/database/00_schema.test.sql +++ b/supabase/tests/database/00_schema.test.sql @@ -2,8 +2,8 @@ begin; select plan(5); -select is((select count(*) from pg_tables where schemaname = 'public'), 17::bigint, - 'R1 hat genau 17 Tabellen'); +select is((select count(*) from pg_tables where schemaname = 'public'), 20::bigint, + 'R1 hat genau 20 Tabellen (17 + catalog_sources, publishers, game_publishers)'); select is_empty($$ select c.relname from pg_class c join pg_namespace n on n.oid = c.relnamespace diff --git a/supabase/tests/database/06_catalog_foundation.test.sql b/supabase/tests/database/06_catalog_foundation.test.sql new file mode 100644 index 0000000..f2ebd62 --- /dev/null +++ b/supabase/tests/database/06_catalog_foundation.test.sql @@ -0,0 +1,564 @@ +-- KAT-05 (PR 2a): Katalog-Fundament. Quellen, Verlage, Ausblenden, manual_fields, View catalog_games, +-- Spaltenrechte auf games, Startbestand-Werkzeug (Fixtures) und die SQL-Bausteine aus docs/katalog-pflege.md. +-- Läuft auch mit eingespieltem Startbestand: eigene Fixtures mit festen IDs, gezählt wird nur darüber. +-- Die Include-Dateien in ../support/ (\ir) erzeugt supabase/catalog/build_startbestand.ts aus catalog/testdata/. +begin; +select plan(133); + +insert into auth.users (id, email, raw_user_meta_data) values + ('11111111-1111-1111-1111-111111111111', 'anna@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Anna"}'), + ('22222222-2222-2222-2222-222222222222', 'ben@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Ben"}'); + +-- --------------------------------------------------------------------------------------------- +-- Struktur (KAT-05 Kriterium 2) +-- --------------------------------------------------------------------------------------------- +select col_is_pk('public', 'games', 'id', 'KAT-05 Kriterium 2: games.id ist der Primärschlüssel'); +select col_type_is('public', 'games', 'id', 'uuid', 'KAT-05 Kriterium 2: games.id ist eine uuid'); +select col_has_default('public', 'games', 'id', 'KAT-05 Kriterium 2: games.id hat einen Standardwert (eigene Shuffle-ID)'); +select is((select count(*) from information_schema.columns + where table_schema = 'public' and table_name = 'games' + and column_name in ('description_locale', 'hidden_at', 'merged_into_id', 'manual_fields', + 'catalog_source_id', 'source_ref', 'source_url', 'source_synced_at')), + 8::bigint, 'KAT-05 Kriterium 2: games hat die neuen Spalten'); +select results_eq($$select key, name, images_allowed, texts_allowed from public.catalog_sources where key = 'bgg'$$, + $$values ('bgg'::text, 'BoardGameGeek'::text, false, false)$$, + 'KAT-05 Kriterium 6: Quelle bgg ist angelegt, ohne Anzeige-Erlaubnis für Bilder und Texte'); + +insert into public.games (id, title, visibility) values + ('f6000000-0000-0000-0000-000000000001', 'Fixture Eins', 'catalog'), + ('f6000000-0000-0000-0000-000000000002', 'Fixture Zwei', 'catalog'); + +select lives_ok($$update public.games set id = 'f6000000-0000-0000-0000-0000000000ff', title = 'Fixture Eins' + where id = 'f6000000-0000-0000-0000-000000000001'$$, 'Update mit neuer id läuft ohne Fehler'); +select is((select count(*) from public.games where id = 'f6000000-0000-0000-0000-000000000001'), 1::bigint, + 'KAT-05 Kriterium 2: die ID bleibt bei einem Update unverändert'); + +select throws_ok($$update public.games set catalog_source_id = (select id from public.catalog_sources where key = 'bgg') + where id = 'f6000000-0000-0000-0000-000000000001'$$, '23514', null, + 'KAT-05 Kriterium 2: Quelle ohne source_ref wird abgelehnt'); +select throws_ok($$update public.games set source_ref = '13' where id = 'f6000000-0000-0000-0000-000000000001'$$, + '23514', null, 'KAT-05 Kriterium 2: source_ref ohne Quelle wird abgelehnt'); +select throws_ok($$update public.games set source_url = 'https://example.org/x' where id = 'f6000000-0000-0000-0000-000000000001'$$, + '23514', null, 'source_url ohne Quelle wird abgelehnt'); +update public.games set catalog_source_id = (select id from public.catalog_sources where key = 'bgg'), source_ref = '13' + where id = 'f6000000-0000-0000-0000-000000000001'; +select throws_ok($$update public.games set catalog_source_id = (select id from public.catalog_sources where key = 'bgg'), source_ref = '13' + where id = 'f6000000-0000-0000-0000-000000000002'$$, '23505', null, + 'KAT-05 Kriterium 2: (Quelle, source_ref) ist im Katalog eindeutig'); +select throws_ok($$update public.games set source_url = 'http://example.org/x' where id = 'f6000000-0000-0000-0000-000000000001'$$, + '23514', null, 'source_url muss mit https:// beginnen'); +select throws_ok($$update public.catalog_sources set terms_url = 'http://example.org' where key = 'bgg'$$, + '23514', null, 'KAT-05 Kriterium 2: terms_url muss mit https:// beginnen'); +select throws_ok($$update public.catalog_sources set attribution_url = 'ftp://example.org' where key = 'bgg'$$, + '23514', null, 'attribution_url muss mit https:// beginnen'); +select throws_ok($$insert into public.catalog_sources (key, name) values ('BGG!', 'Falsch')$$, '23514', null, + 'Schlüssel einer Quelle: nur a-z, 0-9, Unterstrich'); +select throws_ok($$delete from public.catalog_sources where key = 'bgg'$$, '23503', null, + 'Eine Quelle mit Spielen wird nicht gelöscht (on delete restrict)'); + +select throws_ok($$update public.games set description_locale = 'fr', description = 'x' where id = 'f6000000-0000-0000-0000-000000000002'$$, + '23514', null, 'description_locale nur de oder en'); +select throws_ok($$update public.games set description_locale = 'de' where id = 'f6000000-0000-0000-0000-000000000002'$$, + '23514', null, 'description_locale nur zusammen mit description'); +select throws_ok($$update public.games set manual_fields = array['title', 'unbekannt'] where id = 'f6000000-0000-0000-0000-000000000002'$$, + '23514', null, 'KAT-05 Kriterium 1: unbekannter Schlüssel in manual_fields wird abgelehnt'); +select throws_ok($$update public.games set merged_into_id = id, hidden_at = now() where id = 'f6000000-0000-0000-0000-000000000002'$$, + '23514', null, 'Ein Spiel kann nicht in sich selbst aufgehen'); +select throws_ok($$update public.games set merged_into_id = 'f6000000-0000-0000-0000-000000000001' where id = 'f6000000-0000-0000-0000-000000000002'$$, + '23514', null, 'Zusammengeführte Spiele müssen ausgeblendet sein'); + +-- --------------------------------------------------------------------------------------------- +-- Trigger games_merged_into_catalog und on delete restrict (KAT-05 Kriterium 4) +-- --------------------------------------------------------------------------------------------- +insert into public.games (id, title, visibility, owner_id) values + ('f6000000-0000-0000-0000-0000000000a1', 'Annas Prototyp', 'private', '11111111-1111-1111-1111-111111111111'), + ('f6000000-0000-0000-0000-0000000000b1', 'Bens Prototyp', 'private', '22222222-2222-2222-2222-222222222222'); +select throws_ok($$update public.games set merged_into_id = 'f6000000-0000-0000-0000-0000000000a1', hidden_at = now() + where id = 'f6000000-0000-0000-0000-000000000002'$$, 'P0001', 'merged_into_not_catalog', + 'KAT-05 Kriterium 4: merged_into_id auf ein privates Spiel wird abgelehnt'); +select throws_ok($$insert into public.games (title, visibility, hidden_at, merged_into_id) + values ('Fixture Drei', 'catalog', now(), 'f6000000-0000-0000-0000-0000000000a1')$$, 'P0001', 'merged_into_not_catalog', + 'merged_into_id auf ein privates Spiel wird auch beim Anlegen abgelehnt'); +select lives_ok($$update public.games set merged_into_id = 'f6000000-0000-0000-0000-000000000001', hidden_at = now() + where id = 'f6000000-0000-0000-0000-000000000002'$$, 'merged_into_id auf ein Katalogspiel ist erlaubt'); +select throws_ok($$delete from public.games where id = 'f6000000-0000-0000-0000-000000000001'$$, '23503', null, + 'KAT-05 Kriterium 4: Ein Zieleintrag mit Verweis wird nicht gelöscht (on delete restrict)'); +update public.games set merged_into_id = null, hidden_at = null where id = 'f6000000-0000-0000-0000-000000000002'; + +-- --------------------------------------------------------------------------------------------- +-- Verlage +-- --------------------------------------------------------------------------------------------- +insert into public.publishers (id, name) values ('f6000000-0000-0000-0000-0000000000c1', 'Fixture Verlag'); +select throws_ok($$insert into public.publishers (name) values ('FIXTURE VERLAG')$$, '23505', null, + 'Verlagsnamen sind unabhängig von der Schreibweise eindeutig'); +select throws_ok($$insert into public.publishers (name) values (repeat('x', 201))$$, '23514', null, + 'Verlagsname höchstens 200 Zeichen'); +select lives_ok($$insert into public.game_publishers (game_id, publisher_id) + values ('f6000000-0000-0000-0000-000000000001', 'f6000000-0000-0000-0000-0000000000c1')$$, + 'Ein Katalogspiel bekommt einen Verlag'); +select throws_ok($$insert into public.game_publishers (game_id, publisher_id) + values ('f6000000-0000-0000-0000-000000000001', 'f6000000-0000-0000-0000-0000000000c1')$$, '23505', null, + 'Das Paar Spiel/Verlag ist eindeutig'); +select lives_ok($$insert into public.game_publishers (game_id, publisher_id) + values ('f6000000-0000-0000-0000-000000000002', 'f6000000-0000-0000-0000-0000000000c1')$$, + 'Ein Verlag hat mehrere Spiele'); + +-- --------------------------------------------------------------------------------------------- +-- manual_fields (KAT-05 Kriterium 1, nur Datenmodell; Kriterium 4) +-- --------------------------------------------------------------------------------------------- +-- Zeile 1: nur Titel gesetzt. Zeile 2: Fakten gesetzt (Spec-F4: alle gesetzten Felder gelten als manuell). +insert into public.games (id, title, year, min_players, description, description_locale, visibility) values + ('f6000000-0000-0000-0000-0000000000d1', 'Fixture Manuell', 1999, 2, 'Eigener Text.', 'de', 'catalog'); +select is((select manual_fields from public.games where id = 'f6000000-0000-0000-0000-0000000000d1'), + array['description', 'description_locale', 'min_players', 'title', 'year'], + 'KAT-05 Kriterium 1: Insert markiert alle gesetzten Felder, sortiert und ohne Duplikate'); +insert into public.games (id, title, visibility) values ('f6000000-0000-0000-0000-0000000000d2', 'Fixture Nur Titel', 'catalog'); +select is((select manual_fields from public.games where id = 'f6000000-0000-0000-0000-0000000000d2'), + array['title'], 'KAT-05 Kriterium 1: Insert mit nur einem gesetzten Feld markiert nur dieses'); + +update public.games set min_age = 8 where id = 'f6000000-0000-0000-0000-0000000000d1'; +select is((select manual_fields from public.games where id = 'f6000000-0000-0000-0000-0000000000d1'), + array['description', 'description_locale', 'min_age', 'min_players', 'title', 'year'], + 'KAT-05 Kriterium 1: Update als postgres ergänzt das geänderte Feld'); +update public.games set year = year where id = 'f6000000-0000-0000-0000-0000000000d1'; +select is((select cardinality(manual_fields) from public.games where id = 'f6000000-0000-0000-0000-0000000000d1'), + 6, 'Ein Update ohne Wertänderung markiert nichts zusätzlich'); + +update public.games set manual_fields = array_remove(manual_fields, 'min_age') where id = 'f6000000-0000-0000-0000-0000000000d1'; +select is((select 'min_age' = any(manual_fields) from public.games where id = 'f6000000-0000-0000-0000-0000000000d1'), + false, 'KAT-05 Kriterium 4: Eine bewusst entfernte Markierung bleibt entfernt'); +update public.games set min_age = 10, manual_fields = array_remove(manual_fields, 'min_age') where id = 'f6000000-0000-0000-0000-0000000000d1'; +select is((select 'min_age' = any(manual_fields) from public.games where id = 'f6000000-0000-0000-0000-0000000000d1'), + true, 'Wer das Feld ändert, markiert es wieder, auch wenn die Markierung im selben Update entfernt wird'); + +-- set local als postgres schaltet die Kennzeichnung NICHT ab (W7) +set local shuffle.catalog_import = 'on'; +update public.games set min_age = 12 where id = 'f6000000-0000-0000-0000-0000000000d1'; +update public.games set manual_fields = array_remove(manual_fields, 'min_age') where id = 'f6000000-0000-0000-0000-0000000000d1'; +update public.games set min_age = 13 where id = 'f6000000-0000-0000-0000-0000000000d1'; +select is((select 'min_age' = any(manual_fields) from public.games where id = 'f6000000-0000-0000-0000-0000000000d1'), + true, 'KAT-05 Kriterium 1: set local shuffle.catalog_import als postgres schaltet die Kennzeichnung nicht ab'); +reset shuffle.catalog_import; + +-- Privates Spiel: keine Kennzeichnung +insert into public.games (id, title, year, visibility, owner_id) values + ('f6000000-0000-0000-0000-0000000000a2', 'Annas Zweites', 2001, 'private', '11111111-1111-1111-1111-111111111111'); +select is((select manual_fields from public.games where id = 'f6000000-0000-0000-0000-0000000000a2'), + '{}'::text[], 'Private Spiele werden nicht gekennzeichnet'); + +-- Import-Ausnahme: Rolle catalog_importer UND set local +create role catalog_importer nologin; +grant catalog_importer to postgres with set true; +grant usage on schema public to catalog_importer; +grant select, insert, update on public.games to catalog_importer; +grant select, insert, update, delete on public.game_genres to catalog_importer; +create policy t_importer_games on public.games for all to catalog_importer using (true) with check (true); +create policy t_importer_links on public.game_genres for all to catalog_importer using (true) with check (true); +insert into public.genres (id, name_de, name_en) values + ('f6000000-0000-0000-0000-0000000000e1', 'Fixture Genre Eins', 'Fixture genre one'), + ('f6000000-0000-0000-0000-0000000000e2', 'Fixture Genre Zwei', 'Fixture genre two'); + +set local role catalog_importer; +set local shuffle.catalog_import = 'on'; +insert into public.games (id, title, year, visibility) values + ('f6000000-0000-0000-0000-0000000000f1', 'Fixture Import', 2010, 'catalog'); +insert into public.game_genres (game_id, genre_id) values + ('f6000000-0000-0000-0000-0000000000f1', 'f6000000-0000-0000-0000-0000000000e1'); +reset shuffle.catalog_import; +reset role; +select is((select manual_fields from public.games where id = 'f6000000-0000-0000-0000-0000000000f1'), + '{}'::text[], 'KAT-05 Kriterium 1: Rolle catalog_importer mit set local markiert nichts (Insert und Zuordnung)'); + +set local role catalog_importer; +update public.games set year = 2011 where id = 'f6000000-0000-0000-0000-0000000000f1'; +reset role; +select is((select manual_fields from public.games where id = 'f6000000-0000-0000-0000-0000000000f1'), + array['year'], 'KAT-05 Kriterium 1: Rolle catalog_importer ohne set local wird gekennzeichnet'); + +-- Zuordnungen als Pseudo-Felder +update public.games set manual_fields = '{}' where id = 'f6000000-0000-0000-0000-0000000000f1'; +insert into public.mechanics (id, name_de, name_en) values ('f6000000-0000-0000-0000-0000000000e3', 'Fixture Mechanik', 'Fixture mechanic'); +insert into public.categories (id, name_de, name_en) values ('f6000000-0000-0000-0000-0000000000e4', 'Fixture Kategorie', 'Fixture category'); +insert into public.game_genres (game_id, genre_id) values ('f6000000-0000-0000-0000-000000000002', 'f6000000-0000-0000-0000-0000000000e1'); +insert into public.game_mechanics (game_id, mechanic_id) values ('f6000000-0000-0000-0000-000000000002', 'f6000000-0000-0000-0000-0000000000e3'); +insert into public.game_categories (game_id, category_id) values ('f6000000-0000-0000-0000-000000000002', 'f6000000-0000-0000-0000-0000000000e4'); +select is((select manual_fields from public.games where id = 'f6000000-0000-0000-0000-000000000002'), + array['categories', 'genres', 'mechanics', 'publishers', 'title'], + 'KAT-05 Kriterium 1: Zuordnungen anlegen markiert genres, mechanics, categories und publishers'); + +update public.games set manual_fields = array['title'] where id = 'f6000000-0000-0000-0000-000000000002'; +update public.game_genres set genre_id = 'f6000000-0000-0000-0000-0000000000e2' + where game_id = 'f6000000-0000-0000-0000-000000000002'; +select is((select manual_fields from public.games where id = 'f6000000-0000-0000-0000-000000000002'), + array['genres', 'title'], 'KAT-05 Kriterium 1: Zuordnung ändern markiert genres'); +update public.games set manual_fields = array['title'] where id = 'f6000000-0000-0000-0000-000000000002'; +delete from public.game_genres where game_id = 'f6000000-0000-0000-0000-000000000002'; +delete from public.game_publishers where game_id = 'f6000000-0000-0000-0000-000000000002'; +select is((select manual_fields from public.games where id = 'f6000000-0000-0000-0000-000000000002'), + array['genres', 'publishers', 'title'], 'KAT-05 Kriterium 1: Zuordnung löschen markiert genres und publishers'); + +-- Stabilität: eine bereits gesetzte Markierung ändert die Spielzeile nicht noch einmal +create temp table snap_game on commit drop as + select ctid::text as c, updated_at from public.games where id = 'f6000000-0000-0000-0000-000000000002'; +insert into public.game_mechanics (game_id, mechanic_id) values ('f6000000-0000-0000-0000-000000000002', 'f6000000-0000-0000-0000-0000000000e3') + on conflict do nothing; +delete from public.game_genres where game_id = 'f6000000-0000-0000-0000-000000000002'; +select is((select g.ctid::text from public.games g where g.id = 'f6000000-0000-0000-0000-000000000002'), + (select c from snap_game), 'Eine schon vorhandene Markierung schreibt die Spielzeile nicht erneut'); + +-- --------------------------------------------------------------------------------------------- +-- View catalog_games: Ausblenden, Zusammenführen, Anzeige-Erlaubnis (Kriterien 4 und 6) +-- --------------------------------------------------------------------------------------------- +select is((select count(*) from public.catalog_games where id in + ('f6000000-0000-0000-0000-000000000001', 'f6000000-0000-0000-0000-000000000002', 'f6000000-0000-0000-0000-0000000000a1')), + 2::bigint, 'catalog_games enthält Katalogspiele, keine privaten'); + +insert into public.collection_items (owner_id, game_id) + values ('11111111-1111-1111-1111-111111111111', 'f6000000-0000-0000-0000-000000000002'); + +-- docs/katalog-pflege.md, Abschnitt 4: ausblenden +update public.games set hidden_at = now() +where title = 'Fixture Zwei' and visibility = 'catalog' and hidden_at is null; + +select is((select count(*) from public.catalog_games where id = 'f6000000-0000-0000-0000-000000000002'), 0::bigint, + 'KAT-05 Kriterium 4: Ein ausgeblendetes Spiel fehlt in catalog_games'); +set local role anon; +select is((select count(*) from public.catalog_games where id = 'f6000000-0000-0000-0000-000000000002'), 0::bigint, + 'KAT-05 Kriterium 4: Besucher finden das ausgeblendete Spiel nicht in catalog_games'); +select is((select count(*) from public.games where id = 'f6000000-0000-0000-0000-000000000002'), 1::bigint, + 'KAT-05 Kriterium 4: Das ausgeblendete Spiel bleibt per ID lesbar (Spec-F2)'); +reset role; +select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true); +set local role authenticated; +select is((select count(*) from public.catalog_games where id = 'f6000000-0000-0000-0000-000000000002'), 0::bigint, + 'KAT-05 Kriterium 4: Angemeldete finden das ausgeblendete Spiel nicht in catalog_games'); +select is((select count(*) from public.collection_items where game_id = 'f6000000-0000-0000-0000-000000000002'), 1::bigint, + 'KAT-05 Kriterium 4: Ein Sammlungseintrag zu einem ausgeblendeten Spiel bleibt lesbar'); +reset role; + +-- docs/katalog-pflege.md, Abschnitt 4: wieder einblenden +update public.games set hidden_at = null +where title = 'Fixture Zwei' and visibility = 'catalog'; +select is((select count(*) from public.catalog_games where id = 'f6000000-0000-0000-0000-000000000002'), 1::bigint, + 'KAT-05 Kriterium 4: Wieder eingeblendet ist das Spiel in catalog_games'); + +-- zusammengeführt +update public.games set merged_into_id = 'f6000000-0000-0000-0000-000000000001', hidden_at = now() + where id = 'f6000000-0000-0000-0000-000000000002'; +select is((select count(*) from public.catalog_games where id = 'f6000000-0000-0000-0000-000000000002'), 0::bigint, + 'KAT-05 Kriterium 4: Ein zusammengeführtes Spiel fehlt in catalog_games'); +update public.games set merged_into_id = null, hidden_at = null where id = 'f6000000-0000-0000-0000-000000000002'; + +-- Anzeige-Erlaubnis je Quelle (zweite Sicherung, E6). Ein Import würde manual_fields leer lassen. +insert into public.games (id, title, description, description_locale, image_path, visibility, + catalog_source_id, source_ref, source_url) +values ('f6000000-0000-0000-0000-0000000000f2', 'Fixture Quelle', 'Fremder Text', 'en', 'covers/fremd.png', 'catalog', + (select id from public.catalog_sources where key = 'bgg'), '99', 'https://boardgamegeek.com/boardgame/99'); +update public.games set manual_fields = '{}' where id = 'f6000000-0000-0000-0000-0000000000f2'; + +select results_eq($$select description, image_path from public.catalog_games where id = 'f6000000-0000-0000-0000-0000000000f2'$$, + $$values (null::text, null::text)$$, + 'KAT-05 Kriterium 6: Text und Bild einer Quelle ohne Erlaubnis sind in catalog_games leer'); +select results_eq($$select source_key, source_name, source_attribution_text, source_attribution_url, source_url + from public.catalog_games where id = 'f6000000-0000-0000-0000-0000000000f2'$$, + $$values ('bgg'::text, 'BoardGameGeek'::text, 'Powered by BGG'::text, 'https://boardgamegeek.com'::text, + 'https://boardgamegeek.com/boardgame/99'::text)$$, + 'catalog_games liefert die Quellenangabe'); +update public.catalog_sources set texts_allowed = true where key = 'bgg'; +select results_eq($$select description, image_path from public.catalog_games where id = 'f6000000-0000-0000-0000-0000000000f2'$$, + $$values ('Fremder Text'::text, null::text)$$, + 'KAT-05 Kriterium 6: Mit Text-Erlaubnis erscheint der Text, das Bild weiter nicht'); +update public.catalog_sources set texts_allowed = false, images_allowed = true where key = 'bgg'; +select results_eq($$select description, image_path from public.catalog_games where id = 'f6000000-0000-0000-0000-0000000000f2'$$, + $$values (null::text, 'covers/fremd.png'::text)$$, + 'KAT-05 Kriterium 6: Mit Bild-Erlaubnis erscheint das Bild, der Text nicht'); +update public.catalog_sources set images_allowed = false where key = 'bgg'; +update public.games set manual_fields = array['description', 'image_path'] where id = 'f6000000-0000-0000-0000-0000000000f2'; +select results_eq($$select description, image_path from public.catalog_games where id = 'f6000000-0000-0000-0000-0000000000f2'$$, + $$values ('Fremder Text'::text, 'covers/fremd.png'::text)$$, + 'KAT-05 Kriterium 6: Manuell gepflegte Felder erscheinen auch ohne Erlaubnis der Quelle'); +select is((select description from public.catalog_games where id = 'f6000000-0000-0000-0000-0000000000d1'), 'Eigener Text.', + 'KAT-05 Kriterium 6: Ein Spiel ohne Quelle zeigt seinen Text'); + +-- --------------------------------------------------------------------------------------------- +-- Rechte (KAT-05 Kriterium 9, Sicherheit der Daten Kriterium 2) +-- --------------------------------------------------------------------------------------------- +select is_empty($$select col from unnest(array['id', 'hidden_at', 'merged_into_id', 'manual_fields', 'catalog_source_id', + 'source_ref', 'source_url', 'source_synced_at', 'created_at', 'updated_at']) as col + where has_column_privilege('authenticated', 'public.games', col, 'update') + or has_column_privilege('authenticated', 'public.games', col, 'insert')$$, + 'KAT-05 Kriterium 9: Nutzer haben kein Schreibrecht auf id, hidden_at, merged_into_id, manual_fields, catalog_source_id, source_*'); +select is_empty($$select col from unnest(array['owner_id', 'title', 'description', 'description_locale', 'year', 'min_players', + 'max_players', 'min_minutes', 'max_minutes', 'min_age', 'image_path', 'bgg_id', 'visibility']) as col + where not has_column_privilege('authenticated', 'public.games', col, 'insert') + or (col <> 'owner_id' and not has_column_privilege('authenticated', 'public.games', col, 'update'))$$, + 'Nutzer dürfen die Spalten laut Spec 3.2 anlegen und ändern'); +select is_empty($$select p.proname from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname in ('games_mark_manual_fields', 'game_links_mark_manual', 'games_merged_into_catalog') + and (has_function_privilege('anon', p.oid, 'execute') or has_function_privilege('authenticated', p.oid, 'execute') + or has_function_privilege('service_role', p.oid, 'execute'))$$, + 'Die neuen Funktionen sind für anon, authenticated und service_role nicht ausführbar'); +select is_empty($$select t from unnest(array['catalog_sources', 'publishers', 'game_publishers']) as t + where not (select relrowsecurity from pg_class where oid = ('public.' || t)::regclass)$$, + 'Die neuen Tabellen haben Row Level Security'); + +-- Besucher +set local role anon; +select ok((select count(*) from public.catalog_sources) >= 1, 'Sicherheit Kriterium 2: Besucher lesen catalog_sources'); +select is((select count(*) from public.publishers where id = 'f6000000-0000-0000-0000-0000000000c1'), 1::bigint, + 'Sicherheit Kriterium 2: Besucher lesen publishers'); +select is((select count(*) from public.game_publishers where game_id = 'f6000000-0000-0000-0000-000000000001'), 1::bigint, + 'Sicherheit Kriterium 2: Besucher lesen game_publishers von Katalogspielen'); +select is((select count(*) from public.catalog_games where id = 'f6000000-0000-0000-0000-000000000001'), 1::bigint, + 'Sicherheit Kriterium 2: Besucher lesen catalog_games'); +select throws_ok($$insert into public.publishers (name) values ('Besucher-Verlag')$$, '42501', null, 'Besucher legen keine Verlage an'); +select throws_ok($$insert into public.catalog_sources (key, name) values ('besucher', 'Besucher')$$, '42501', null, + 'Besucher legen keine Quellen an'); +select throws_ok($$update public.games set title = 'Gehackt' where id = 'f6000000-0000-0000-0000-000000000001'$$, '42501', null, + 'KAT-05 Kriterium 9: Besucher ändern keine Katalogspiele'); +select throws_ok($$delete from public.game_publishers where game_id = 'f6000000-0000-0000-0000-000000000001'$$, '42501', null, + 'Besucher löschen keine Verlagszuordnungen'); +select throws_ok($$update public.catalog_games set title = 'Gehackt'$$, '55000', null, 'Die View catalog_games ist für Besucher nicht schreibbar (nicht aktualisierbar)'); +select is_empty($$select p from unnest(array['insert', 'update', 'delete']) as p + where has_table_privilege('anon', 'public.catalog_games', p) or has_table_privilege('authenticated', 'public.catalog_games', p)$$, + 'Kriterium 9: anon und authenticated haben auf catalog_games nur select'); +reset role; + +-- Anna +select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true); +set local role authenticated; +select lives_ok($$update public.games set title = 'Annas Prototyp 2' where id = 'f6000000-0000-0000-0000-0000000000a1'$$, + 'Anna ändert den Titel ihres privaten Spiels'); +select throws_ok($$update public.games set hidden_at = now() where id = 'f6000000-0000-0000-0000-0000000000a1'$$, '42501', null, + 'KAT-05 Kriterium 9: Anna setzt hidden_at nicht, auch nicht an ihrem privaten Spiel'); +select throws_ok($$update public.games set merged_into_id = 'f6000000-0000-0000-0000-000000000001' where id = 'f6000000-0000-0000-0000-0000000000a1'$$, + '42501', null, 'KAT-05 Kriterium 9: Anna setzt merged_into_id nicht'); +select throws_ok($$update public.games set manual_fields = array['title'] where id = 'f6000000-0000-0000-0000-0000000000a1'$$, + '42501', null, 'KAT-05 Kriterium 9: Anna setzt manual_fields nicht'); +select throws_ok($$update public.games set catalog_source_id = (select id from public.catalog_sources limit 1), source_ref = 'x' + where id = 'f6000000-0000-0000-0000-0000000000a1'$$, '42501', null, 'KAT-05 Kriterium 9: Anna setzt catalog_source_id nicht'); +select throws_ok($$update public.games set source_url = 'https://example.org' where id = 'f6000000-0000-0000-0000-0000000000a1'$$, + '42501', null, 'KAT-05 Kriterium 9: Anna setzt source_url nicht'); +select throws_ok($$update public.games set id = gen_random_uuid() where id = 'f6000000-0000-0000-0000-0000000000a1'$$, + '42501', null, 'KAT-05 Kriterium 9: Anna ändert die id nicht'); +select throws_ok($$insert into public.games (id, title, visibility, owner_id) + values (gen_random_uuid(), 'Mit eigener ID', 'private', '11111111-1111-1111-1111-111111111111')$$, '42501', null, + 'KAT-05 Kriterium 9: Anna wählt beim Anlegen keine eigene id'); +select throws_ok($$insert into public.games (title, visibility, owner_id, hidden_at) + values ('Versteckt', 'private', '11111111-1111-1111-1111-111111111111', now())$$, '42501', null, + 'Anna legt kein Spiel mit hidden_at an'); +select lives_ok($$insert into public.games (title, description, year, visibility, owner_id) + values ('Annas Neues', 'Text', 2022, 'private', '11111111-1111-1111-1111-111111111111')$$, 'Anna legt ein privates Spiel an'); +select is((select manual_fields from public.games where title = 'Annas Neues'), '{}'::text[], + 'Annas privates Spiel wird nicht als manuell gepflegt gekennzeichnet'); +select is_empty($$update public.games set title = 'Gehackt' where id = 'f6000000-0000-0000-0000-000000000001' returning 1$$, + 'KAT-05 Kriterium 9: Anna ändert keine Katalogspiele'); +select lives_ok($$insert into public.game_publishers (game_id, publisher_id) + values ('f6000000-0000-0000-0000-0000000000a1', 'f6000000-0000-0000-0000-0000000000c1')$$, + 'Anna ordnet ihrem privaten Spiel einen Verlag zu (Trigger stört nicht)'); +select lives_ok($$insert into public.game_genres (game_id, genre_id) + values ('f6000000-0000-0000-0000-0000000000a1', 'f6000000-0000-0000-0000-0000000000e1')$$, + 'Anna ordnet ihrem privaten Spiel ein Genre zu (Trigger stört nicht)'); +select lives_ok($$delete from public.game_genres where game_id = 'f6000000-0000-0000-0000-0000000000a1'$$, + 'Anna löscht eine Zuordnung ihres privaten Spiels'); +select throws_ok($$insert into public.game_publishers (game_id, publisher_id) + values ('f6000000-0000-0000-0000-000000000001', 'f6000000-0000-0000-0000-0000000000c1')$$, '42501', null, + 'KAT-05 Kriterium 9: Anna ordnet einem Katalogspiel keinen Verlag zu'); +select is_empty($$delete from public.game_publishers where game_id = 'f6000000-0000-0000-0000-000000000001' returning 1$$, + 'KAT-05 Kriterium 9: Anna löscht keine Verlagszuordnung eines Katalogspiels'); +select throws_ok($$insert into public.publishers (name) values ('Annas Verlag')$$, '42501', null, 'Anna legt keine Verlage an'); +select throws_ok($$insert into public.catalog_sources (key, name) values ('anna', 'Anna')$$, '42501', null, 'Anna legt keine Quellen an'); +select is_empty($$update public.catalog_sources set texts_allowed = true returning 1$$, 'Anna ändert keine Quellen'); +select is_empty($$delete from public.catalog_sources returning 1$$, 'Anna löscht keine Quellen'); +select is_empty($$update public.publishers set name = 'Gehackt' returning 1$$, 'Anna ändert keine Verlage'); +select throws_ok($$insert into public.catalog_games (id, title) values (gen_random_uuid(), 'Gehackt')$$, '55000', null, + 'Die View catalog_games ist für Nutzer nicht schreibbar (nicht aktualisierbar)'); +select is((select count(*) from public.catalog_games where id = 'f6000000-0000-0000-0000-0000000000a1'), 0::bigint, + 'catalog_games zeigt auch Annas eigenes privates Spiel nicht'); +reset role; + +-- Gefälschte Admin-Rolle in user_metadata zählt nicht +select set_config('request.jwt.claims', + '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated","user_metadata":{"role":"admin"}}', true); +set local role authenticated; +select throws_ok($$insert into public.publishers (name) values ('Admin-Verlag')$$, '42501', null, + 'KAT-05 Kriterium 9: user_metadata.role = admin gibt keine Rechte'); +select is_empty($$update public.games set title = 'Gehackt' where id = 'f6000000-0000-0000-0000-000000000001' returning 1$$, + 'KAT-05 Kriterium 9: user_metadata.role = admin ändert keine Katalogspiele'); +reset role; + +-- Ben +select set_config('request.jwt.claims', '{"sub":"22222222-2222-2222-2222-222222222222","role":"authenticated"}', true); +set local role authenticated; +select is((select count(*) from public.game_publishers where game_id = 'f6000000-0000-0000-0000-0000000000a1'), 0::bigint, + 'Ben sieht keine Verlagszuordnungen von Annas privatem Spiel'); +select throws_ok($$insert into public.game_publishers (game_id, publisher_id) + values ('f6000000-0000-0000-0000-0000000000a1', 'f6000000-0000-0000-0000-0000000000c1')$$, '42501', null, + 'Ben ordnet Annas privatem Spiel keinen Verlag zu'); +reset role; + +-- Konto löschen erfasst die privaten Spiele samt Zuordnungen (Kaskaden) +select lives_ok($$delete from auth.users where id = '11111111-1111-1111-1111-111111111111'$$, + 'Das Konto von Anna lässt sich löschen'); +select is((select count(*) from public.games where owner_id = '11111111-1111-1111-1111-111111111111'), 0::bigint, + 'Annas private Spiele sind mit dem Konto gelöscht'); +select is((select count(*) from public.game_publishers where game_id = 'f6000000-0000-0000-0000-0000000000a1'), 0::bigint, + 'Annas Zuordnungen sind mit dem Konto gelöscht'); +select is((select count(*) from public.games where id = 'f6000000-0000-0000-0000-000000000001'), 1::bigint, + 'Katalogspiele bleiben beim Löschen eines Kontos erhalten'); + +-- --------------------------------------------------------------------------------------------- +-- Runbook docs/katalog-pflege.md, Abschnitt 3 und 4 (SQL-Bausteine unverändert, ohne begin/commit) +-- --------------------------------------------------------------------------------------------- +insert into public.genres (name_de, name_en) values ('Strategiespiel', 'Strategy game') on conflict do nothing; + +-- Abschnitt 3: Neues Katalogspiel mit Verlag und Genre +insert into public.publishers (name) values ('Beispielverlag') + on conflict ((lower(name))) do nothing; + +with neu as ( + insert into public.games (title, year, min_players, max_players, min_minutes, max_minutes, min_age, + description, description_locale, visibility) + values ('Beispielspiel Runbook', 2020, 2, 4, 30, 60, 8, 'Eigener Kurztext.', 'de', 'catalog') + returning id +), verlag as ( + insert into public.game_publishers (game_id, publisher_id) + select neu.id, p.id from neu, public.publishers p where lower(p.name) = lower('Beispielverlag') +) +insert into public.game_genres (game_id, genre_id) +select neu.id, g.id from neu, public.genres g where g.name_de = 'Strategiespiel'; + +select is((select count(*) from public.games where title = 'Beispielspiel Runbook' and visibility = 'catalog' and owner_id is null), 1::bigint, + 'KAT-05 Kriterium 8: Runbook 3, Spiel anlegen'); +select is((select manual_fields from public.games where title = 'Beispielspiel Runbook'), + array['description', 'description_locale', 'genres', 'max_minutes', 'max_players', 'min_age', 'min_minutes', 'min_players', + 'publishers', 'title', 'year'], + 'KAT-05 Kriterium 8: Runbook 3, das neue Spiel ist als manuell gepflegt gekennzeichnet'); +select is((select count(*) from public.game_publishers gp join public.games g on g.id = gp.game_id + where g.title = 'Beispielspiel Runbook'), 1::bigint, 'Runbook 3: Verlag zugeordnet'); +select is((select count(*) from public.game_genres gg join public.games g on g.id = gg.game_id + where g.title = 'Beispielspiel Runbook'), 1::bigint, 'Runbook 3: Genre zugeordnet'); + +-- Abschnitt 3: Fakten ändern +update public.games set min_age = 10, max_minutes = 90 +where title = 'Beispielspiel Runbook' and visibility = 'catalog'; +select is((select min_age from public.games where title = 'Beispielspiel Runbook'), 10::smallint, 'Runbook 3: Fakten ändern'); + +-- Abschnitt 3: Markierung bewusst entfernen +update public.games set manual_fields = array_remove(manual_fields, 'min_age') +where title = 'Beispielspiel Runbook' and visibility = 'catalog'; +select is((select 'min_age' = any(manual_fields) from public.games where title = 'Beispielspiel Runbook'), false, + 'Runbook 3: Markierung entfernen'); + +-- Abschnitt 4: ausblenden +update public.games set hidden_at = now() +where title = 'Beispielspiel Runbook' and visibility = 'catalog' and hidden_at is null; +select is((select count(*) from public.catalog_games where title = 'Beispielspiel Runbook'), 0::bigint, + 'KAT-05 Kriterium 8: Runbook 4, ausgeblendet'); +-- Abschnitt 4: wieder einblenden +update public.games set hidden_at = null +where title = 'Beispielspiel Runbook' and visibility = 'catalog'; +select is((select count(*) from public.catalog_games where title = 'Beispielspiel Runbook'), 1::bigint, + 'KAT-05 Kriterium 8: Runbook 4, wieder eingeblendet'); + +-- --------------------------------------------------------------------------------------------- +-- Startbestand-Werkzeug mit fiktiven Testdaten (KAT-05 Kriterium 7 und 10) +-- Vorab: ein im Studio angelegter Begriff und Verlag mit gleichem Namen, aber anderer ID bzw. Schreibweise. +-- --------------------------------------------------------------------------------------------- +insert into public.genres (id, name_de, name_en) values + ('f6000000-0000-0000-0000-0000000000e5', 'Testgenre A', 'Test genre A (Studio)'); +insert into public.publishers (id, name) values ('f6000000-0000-0000-0000-0000000000c2', 'TestVerlag'); + +\ir ../support/vocabulary_fixture.inc +\ir ../support/startbestand_fixture.inc + +select is((select count(*) from public.genres where name_de = 'Testgenre A'), 1::bigint, + 'Vokabular: Ein im Studio angelegter Begriff mit gleichem Namen löst keinen Fehler aus'); +select is((select id from public.genres where name_de = 'Testgenre A'), 'f6000000-0000-0000-0000-0000000000e5'::uuid, + 'Vokabular: Der vorhandene Begriff gewinnt'); +select is((select count(*) from public.publishers where lower(name) = 'testverlag'), 1::bigint, + 'Startbestand: Ein Verlag in anderer Schreibweise wird wiederverwendet'); +select is((select count(*) from public.games where title like 'Testspiel %'), 3::bigint, 'KAT-05 Kriterium 7: drei Testspiele eingespielt'); +select is((select id from public.games where title = 'Testspiel A'), + extensions.uuid_generate_v5('6f1d9a52-3c4e-4b8a-9f27-5d0e8c71a3b4', 'game:testspiel-a'), + 'KAT-05 Kriterium 7: gleicher key gibt die gleiche UUID wie uuid_generate_v5 in PostgreSQL'); +select is((select id from public.games where title like 'Testspiel C%'), + extensions.uuid_generate_v5('6f1d9a52-3c4e-4b8a-9f27-5d0e8c71a3b4', 'game:testspiel-c'), + 'Die ID ist unabhängig von Sonderzeichen im Titel'); +select results_eq($$select title, year, min_players, max_players, min_minutes, max_minutes, min_age, description_locale, visibility + from public.games where title = 'Testspiel A'$$, + $$values ('Testspiel A'::text, 2020::smallint, 2::smallint, 4::smallint, 30::smallint, 60::smallint, 8::smallint, 'de'::text, 'catalog'::text)$$, + 'KAT-05 Kriterium 10: Fakten des Testspiels A'); +select is_empty($$select 1 from public.games where title like 'Testspiel %' + and (image_path is not null or catalog_source_id is not null or bgg_id is not null or source_ref is not null or owner_id is not null)$$, + 'KAT-05 Kriterium 10: kein Cover, keine Quelle, keine BGG-Kennung (SB6)'); +select is((select manual_fields from public.games where title = 'Testspiel A'), + array['description', 'description_locale', 'genres', 'max_minutes', 'max_players', 'mechanics', 'min_age', 'min_minutes', + 'min_players', 'publishers', 'title', 'year'], + 'KAT-05 Kriterium 1: Der Startbestand gilt als manuell gepflegt (SB8)'); +select is((select hidden_at is not null from public.games where title like 'Testspiel C%'), true, 'hidden = ja blendet das Spiel aus'); +select is((select hidden_at is null from public.games where title = 'Testspiel A'), true, 'hidden leer lässt hidden_at unberührt'); +select is((select count(*) from public.game_publishers gp join public.games g on g.id = gp.game_id where g.title = 'Testspiel B'), 2::bigint, + 'Testspiel B hat zwei Verlage'); +select is((select count(*) from public.game_genres gg join public.games g on g.id = gg.game_id where g.title = 'Testspiel A'), 2::bigint, + 'Testspiel A hat zwei Genres'); + +-- Abgleich der Zuordnungen und "die Datei gewinnt" vor dem Go-live +update public.games set title = 'Geändert' where title = 'Testspiel A'; +insert into public.game_genres (game_id, genre_id) + select g.id, t.id from public.games g, public.genres t where g.title = 'Testspiel B' and t.name_de = 'Testgenre B'; +delete from public.game_genres where game_id = (select id from public.games where title = 'Geändert') + and genre_id = (select id from public.genres where name_de = 'Testgenre B'); +\ir ../support/startbestand_fixture.inc +select is((select count(*) from public.games where title = 'Testspiel A'), 1::bigint, 'Zweites Einspielen stellt geänderte Fakten wieder her'); +select results_eq($$select t.name_de from public.game_genres gg join public.games g on g.id = gg.game_id + join public.genres t on t.id = gg.genre_id where g.title = 'Testspiel A' order by 1$$, + $$values ('Testgenre A'::text), ('Testgenre B'::text)$$, 'Zweites Einspielen ergänzt fehlende Zuordnungen'); +select results_eq($$select t.name_de from public.game_genres gg join public.games g on g.id = gg.game_id + join public.genres t on t.id = gg.genre_id where g.title = 'Testspiel B' order by 1$$, + $$values ('Testgenre A'::text)$$, 'Zweites Einspielen entfernt nicht mehr gewünschte Zuordnungen'); + +-- Idempotenz: ein weiteres Einspielen ändert nichts (ctid statt updated_at, weil now() in der Transaktion konstant ist) +create temp table snap_rows on commit drop as + select 'g' as t, id::text as a, null::text as b, ctid::text as c, updated_at as u from public.games where title like 'Testspiel %' + union all select 'gg', game_id::text, genre_id::text, ctid::text, updated_at from public.game_genres + where game_id in (select id from public.games where title like 'Testspiel %') + union all select 'gm', game_id::text, mechanic_id::text, ctid::text, updated_at from public.game_mechanics + where game_id in (select id from public.games where title like 'Testspiel %') + union all select 'gp', game_id::text, publisher_id::text, ctid::text, updated_at from public.game_publishers + where game_id in (select id from public.games where title like 'Testspiel %') + union all select 'p', id::text, null, ctid::text, updated_at from public.publishers where lower(name) in ('testverlag', 'zweiter testverlag') + union all select 'v', id::text, null, ctid::text, updated_at from public.genres where name_de like 'Testgenre %' + union all select 'm', id::text, null, ctid::text, updated_at from public.mechanics where name_de like 'Testmechanik %'; +\ir ../support/vocabulary_fixture.inc +\ir ../support/startbestand_fixture.inc +select is((select count(*) from snap_rows), 21::bigint, 'Schnappschuss enthält die erwarteten Zeilen (3 Spiele, 12 Zuordnungen, 2 Verlage, 2 Genres, 2 Mechaniken)'); +select is_empty($$select * from snap_rows s where not exists (select 1 from ( + select 'g' as t, id::text as a, null::text as b, ctid::text as c, updated_at as u from public.games + union all select 'gg', game_id::text, genre_id::text, ctid::text, updated_at from public.game_genres + union all select 'gm', game_id::text, mechanic_id::text, ctid::text, updated_at from public.game_mechanics + union all select 'gp', game_id::text, publisher_id::text, ctid::text, updated_at from public.game_publishers + union all select 'p', id::text, null, ctid::text, updated_at from public.publishers + union all select 'v', id::text, null, ctid::text, updated_at from public.genres + union all select 'm', id::text, null, ctid::text, updated_at from public.mechanics) n + where n.t = s.t and n.a = s.a and n.b is not distinct from s.b and n.c = s.c and n.u = s.u)$$, + 'KAT-05 Kriterium 7: Erneutes Einspielen ändert keine Zeile (Spiele, Zuordnungen, Verlage, Begriffe)'); + +-- Prüfabfrage checks.sql (Fixture-Variante derselben Abfrage) +\ir ../support/checks_fixture.inc +select results_eq($$select rule, ist::int, status from checks_result order by rule$$, + $$values ('Info'::text, 1, 'Information'::text), ('SB0', 3, 'Information'), ('SB1', 2, 'Verstoß'), + ('SB2', 0, 'ok'), ('SB4', 0, 'ok'), ('SB6', 0, 'ok')$$, + 'KAT-05 Kriterium 10: checks.sql meldet für die Testspiele keinen Verstoß (SB1 ist lokal nur Information)'); +update public.games set min_minutes = null where title = 'Testspiel A'; +update public.games set bgg_id = 4711 where title = 'Testspiel B'; +update public.games set description = null, description_locale = null where title like 'Testspiel C%'; +select results_eq($$select rule, ist::int, status from checks_result where rule in ('SB2', 'SB4', 'SB6') order by rule$$, + $$values ('SB2'::text, 1, 'Verstoß'::text), ('SB4', 1, 'Verstoß'), ('SB6', 1, 'Verstoß')$$, + 'KAT-05 Kriterium 10: checks.sql meldet fehlende Dauer, gesetzte BGG-Kennung und fehlenden Kurztext'); +insert into public.games (title, visibility) values ('Kein Startbestand', 'catalog'); +select is((select ist::int from checks_result where rule = 'SB2'), 1, + 'KAT-05 Kriterium 10: Ein Spiel außerhalb des Startbestands wird von checks.sql ignoriert'); + +select * from finish(); +rollback; diff --git a/supabase/tests/support/checks_fixture.inc b/supabase/tests/support/checks_fixture.inc new file mode 100644 index 0000000..caaedd7 --- /dev/null +++ b/supabase/tests/support/checks_fixture.inc @@ -0,0 +1,49 @@ +-- Erzeugt von supabase/catalog/build_startbestand.ts. Nicht von Hand bearbeiten. +-- Include für pgTAP (\ir), keine Test-Datei. Dieselbe Abfrage wie checks.sql, aber mit den IDs der +-- Testspiele aus supabase/catalog/testdata/. +create temp view checks_result as +with sb(id) as ( + select t.value::uuid from jsonb_array_elements_text('[ + "5189ad0e-ab5b-5500-a3e0-a0b3671cdf14", + "6577717f-4c1f-5829-af22-ae6bb37c8394", + "c3c6f0fd-43bc-5fb9-b692-3be7770b6cc3" +]'::jsonb) as t(value) +), voc(kind, id, name_de) as ( + select x.kind, x.id, x.name_de from jsonb_to_recordset('[ + {"kind":"genre","id":"b685bcce-91da-5bdb-b5bc-5ccb45e2445c","name_de":"Testgenre A"}, + {"kind":"genre","id":"4af96f83-6ee7-51e6-8ee0-f9554ca9bd4e","name_de":"Testgenre B"}, + {"kind":"mechanic","id":"d27d168e-f05f-5781-8ccf-2443e4224795","name_de":"Testmechanik A"}, + {"kind":"mechanic","id":"17182118-57e3-520c-8ff9-d4cce37a17ce","name_de":"Testmechanik B"} +]'::jsonb) as x(kind text, id uuid, name_de text) +) +select rule, beschreibung, soll, ist, case when ok is null then 'Information' when ok then 'ok' else 'Verstoß' end as status +from ( + select 1 as ord, 'SB0' as rule, 'Startbestandsspiele in der Datenbank vorhanden' as beschreibung, + 'alle ' || (select count(*) from sb) as soll, n as ist, null::boolean as ok + from (select count(*) as n from public.games g join sb on sb.id = g.id) x + union all + select 2, 'SB1', 'Sichtbare Katalogspiele aus dem Startbestand (Abnahme: mindestens 180)', + '>= 180', n, n >= 180 + from (select count(*) as n from public.catalog_games c join sb on sb.id = c.id) x + union all + select 3, 'SB2', 'Spiele ohne Pflicht-Fakt (Jahr, Spielerzahl, Dauer, Alter, Verlag, Genre, Mechanik)', '0', n, n = 0 + from (select count(*) as n from public.games g join sb on sb.id = g.id + where g.year is null or g.min_players is null or g.max_players is null + or g.min_minutes is null or g.max_minutes is null or g.min_age is null + or not exists (select 1 from public.game_publishers x where x.game_id = g.id) + or not exists (select 1 from public.game_genres x where x.game_id = g.id) + or not exists (select 1 from public.game_mechanics x where x.game_id = g.id)) x + union all + select 4, 'SB4', 'Spiele ohne deutschen Kurztext', '0', n, n = 0 + from (select count(*) as n from public.games g join sb on sb.id = g.id + where g.description is null or g.description_locale is distinct from 'de') x + union all + select 5, 'SB6', 'Spiele mit Cover, Quelle oder BGG-Kennung', '0', n, n = 0 + from (select count(*) as n from public.games g join sb on sb.id = g.id + where g.image_path is not null or g.catalog_source_id is not null or g.bgg_id is not null) x + union all + select 6, 'Info', 'Begriffe mit abweichender ID (im Studio angelegt, bitte prüfen)', '-', n, null::boolean + from (select (select count(*) from public.genres t join voc v on v.kind = 'genre' and v.name_de = t.name_de where t.id <> v.id) + + (select count(*) from public.mechanics t join voc v on v.kind = 'mechanic' and v.name_de = t.name_de where t.id <> v.id) as n) x +) r +order by ord; diff --git a/supabase/tests/support/startbestand_fixture.inc b/supabase/tests/support/startbestand_fixture.inc new file mode 100644 index 0000000..09a6a2f --- /dev/null +++ b/supabase/tests/support/startbestand_fixture.inc @@ -0,0 +1,100 @@ +-- Erzeugt von supabase/catalog/build_startbestand.ts. Nicht von Hand bearbeiten. + +-- Startbestand (KAT-05). Idempotent bis zum Go-live; danach eingefroren (docs/katalog-pflege.md). +-- Reihenfolge: Migrationen, vocabulary.sql, dann diese Datei. + +do $guard$ +declare + missing text; +begin + select string_agg(n, ', ') into missing from jsonb_array_elements_text('["Testgenre A","Testgenre B"]'::jsonb) as t(n) + where not exists (select 1 from public.genres g where g.name_de = t.n); + if missing is not null then + raise exception 'startbestand: Genre fehlt in der Datenbank (vocabulary.sql eingespielt?): %', missing; + end if; + select string_agg(n, ', ') into missing from jsonb_array_elements_text('["Testmechanik A","Testmechanik B"]'::jsonb) as t(n) + where not exists (select 1 from public.mechanics m where m.name_de = t.n); + if missing is not null then + raise exception 'startbestand: Mechanik fehlt in der Datenbank (vocabulary.sql eingespielt?): %', missing; + end if; +end +$guard$; + +-- Verlage +insert into public.publishers (name) values + ('Testverlag'), + ('Zweiter Testverlag') +on conflict ((lower(name))) do nothing; + +-- Spiele (unveränderte Zeilen werden nicht angefasst) +insert into public.games (id, title, description, description_locale, year, min_players, max_players, min_minutes, max_minutes, min_age, visibility) values + ('5189ad0e-ab5b-5500-a3e0-a0b3671cdf14', 'Testspiel A', 'Fiktives Testspiel A. Nur für automatische Tests.', 'de', 2020, 2, 4, 30, 60, 8, 'catalog'), + ('6577717f-4c1f-5829-af22-ae6bb37c8394', 'Testspiel B', 'Fiktives Testspiel B, mit Komma.', 'de', 2021, 1, 5, 10, 20, 6, 'catalog'), + ('c3c6f0fd-43bc-5fb9-b692-3be7770b6cc3', 'Testspiel C: Wo''s lang geht', 'Fiktives Testspiel C mit Apostroph im Titel.', 'de', 1999, 3, 3, 45, 45, 12, 'catalog') +on conflict (id) do update set + title = excluded.title, + description = excluded.description, + description_locale = excluded.description_locale, + year = excluded.year, + min_players = excluded.min_players, + max_players = excluded.max_players, + min_minutes = excluded.min_minutes, + max_minutes = excluded.max_minutes, + min_age = excluded.min_age +where (games.title, games.description, games.description_locale, games.year, games.min_players, games.max_players, games.min_minutes, games.max_minutes, games.min_age) + is distinct from (excluded.title, excluded.description, excluded.description_locale, excluded.year, excluded.min_players, excluded.max_players, excluded.min_minutes, excluded.max_minutes, excluded.min_age); + +-- hidden = ja: ausblenden, nichts löschen +update public.games set hidden_at = now() +where hidden_at is null and id = any(array['c3c6f0fd-43bc-5fb9-b692-3be7770b6cc3']::uuid[]); + +-- Zuordnung Genre: fehlende anlegen, nicht mehr gewünschte löschen +with want(game_id, term) as (values + ('5189ad0e-ab5b-5500-a3e0-a0b3671cdf14'::uuid, 'Testgenre A'), + ('5189ad0e-ab5b-5500-a3e0-a0b3671cdf14'::uuid, 'Testgenre B'), + ('6577717f-4c1f-5829-af22-ae6bb37c8394'::uuid, 'Testgenre A'), + ('c3c6f0fd-43bc-5fb9-b692-3be7770b6cc3'::uuid, 'Testgenre B') +), resolved as ( + select w.game_id, t.id as genre_id from want w join public.genres t on t.name_de = w.term +), removed as ( + delete from public.game_genres l + where l.game_id = any(array['5189ad0e-ab5b-5500-a3e0-a0b3671cdf14', '6577717f-4c1f-5829-af22-ae6bb37c8394', 'c3c6f0fd-43bc-5fb9-b692-3be7770b6cc3']::uuid[]) + and not exists (select 1 from resolved r where r.game_id = l.game_id and r.genre_id = l.genre_id) +) +insert into public.game_genres (game_id, genre_id) +select game_id, genre_id from resolved +on conflict (game_id, genre_id) do nothing; + +-- Zuordnung Mechanik: fehlende anlegen, nicht mehr gewünschte löschen +with want(game_id, term) as (values + ('5189ad0e-ab5b-5500-a3e0-a0b3671cdf14'::uuid, 'Testmechanik A'), + ('6577717f-4c1f-5829-af22-ae6bb37c8394'::uuid, 'Testmechanik A'), + ('6577717f-4c1f-5829-af22-ae6bb37c8394'::uuid, 'Testmechanik B'), + ('c3c6f0fd-43bc-5fb9-b692-3be7770b6cc3'::uuid, 'Testmechanik B') +), resolved as ( + select w.game_id, t.id as mechanic_id from want w join public.mechanics t on t.name_de = w.term +), removed as ( + delete from public.game_mechanics l + where l.game_id = any(array['5189ad0e-ab5b-5500-a3e0-a0b3671cdf14', '6577717f-4c1f-5829-af22-ae6bb37c8394', 'c3c6f0fd-43bc-5fb9-b692-3be7770b6cc3']::uuid[]) + and not exists (select 1 from resolved r where r.game_id = l.game_id and r.mechanic_id = l.mechanic_id) +) +insert into public.game_mechanics (game_id, mechanic_id) +select game_id, mechanic_id from resolved +on conflict (game_id, mechanic_id) do nothing; + +-- Zuordnung Verlag: fehlende anlegen, nicht mehr gewünschte löschen +with want(game_id, term) as (values + ('5189ad0e-ab5b-5500-a3e0-a0b3671cdf14'::uuid, 'Testverlag'), + ('6577717f-4c1f-5829-af22-ae6bb37c8394'::uuid, 'Testverlag'), + ('6577717f-4c1f-5829-af22-ae6bb37c8394'::uuid, 'Zweiter Testverlag'), + ('c3c6f0fd-43bc-5fb9-b692-3be7770b6cc3'::uuid, 'TESTVERLAG') +), resolved as ( + select w.game_id, t.id as publisher_id from want w join public.publishers t on lower(t.name) = lower(w.term) +), removed as ( + delete from public.game_publishers l + where l.game_id = any(array['5189ad0e-ab5b-5500-a3e0-a0b3671cdf14', '6577717f-4c1f-5829-af22-ae6bb37c8394', 'c3c6f0fd-43bc-5fb9-b692-3be7770b6cc3']::uuid[]) + and not exists (select 1 from resolved r where r.game_id = l.game_id and r.publisher_id = l.publisher_id) +) +insert into public.game_publishers (game_id, publisher_id) +select game_id, publisher_id from resolved +on conflict (game_id, publisher_id) do nothing; diff --git a/supabase/tests/support/vocabulary_fixture.inc b/supabase/tests/support/vocabulary_fixture.inc new file mode 100644 index 0000000..b365fd4 --- /dev/null +++ b/supabase/tests/support/vocabulary_fixture.inc @@ -0,0 +1,14 @@ +-- Erzeugt von supabase/catalog/build_startbestand.ts. Nicht von Hand bearbeiten. + +-- Vokabular (Genres, Mechaniken). Idempotent. Ein im Studio angelegter Begriff mit gleichem Namen +-- gewinnt (on conflict do nothing fängt ID- und Namenskonflikte); startbestand.sql löst über name_de auf. + +insert into public.genres (id, name_de, name_en) values + ('b685bcce-91da-5bdb-b5bc-5ccb45e2445c', 'Testgenre A', 'Test genre A'), + ('4af96f83-6ee7-51e6-8ee0-f9554ca9bd4e', 'Testgenre B', 'Test genre B') +on conflict do nothing; + +insert into public.mechanics (id, name_de, name_en) values + ('d27d168e-f05f-5781-8ccf-2443e4224795', 'Testmechanik A', 'Test mechanic A'), + ('17182118-57e3-520c-8ff9-d4cce37a17ce', 'Testmechanik B', 'Test mechanic B') +on conflict do nothing;