feat(catalog): add catalog foundation migration with pgTAP tests

Sources, publishers, hidden/merged columns, manual_fields triggers, catalog_games view and column grants on games (KAT-05, PR 2a). Fixtures for the tests are generated from supabase/catalog/testdata.

Refs #2

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
cschlaefke 2026-11-03 15:38:28 +01:00
parent 79e06fbfe9
commit 3c762003b2
6 changed files with 1065 additions and 2 deletions

View file

@ -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;

View file

@ -2,8 +2,8 @@
begin; begin;
select plan(5); select plan(5);
select is((select count(*) from pg_tables where schemaname = 'public'), 17::bigint, select is((select count(*) from pg_tables where schemaname = 'public'), 20::bigint,
'R1 hat genau 17 Tabellen'); 'R1 hat genau 20 Tabellen (17 + catalog_sources, publishers, game_publishers)');
select is_empty($$ select is_empty($$
select c.relname from pg_class c join pg_namespace n on n.oid = c.relnamespace select c.relname from pg_class c join pg_namespace n on n.oid = c.relnamespace

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;