diff --git a/supabase/migrations/20261103235947_catalog_search.sql b/supabase/migrations/20261103235947_catalog_search.sql new file mode 100644 index 0000000..0124840 --- /dev/null +++ b/supabase/migrations/20261103235947_catalog_search.sql @@ -0,0 +1,314 @@ +-- KAT-01 / PR 3a: Katalogsuche (Spec docs/specs/3-katalog-suche.md, Abschnitt 3.2 und 4.2). +-- Nur additiv. Enthält: +-- pg_trgm, unaccent Erweiterungen im Schema extensions +-- search_normalize(text) Basisfunktion für alle normalisierten Titel (auch für #13) +-- title_search, name_search generierte Spalten an games, publishers und game_alt_titles + Trigramm-Indizes +-- game_alt_titles alternative Titel mit Sprache (Klärung 3); nur lesbar für API-Rollen +-- games_manual_fields_check vollständige Schlüsselliste von main plus 'alt_titles' +-- search_catalog_games RPC für die Suche (Vertrag: Spec 4.2) + +-- --------------------------------------------------------------------------------------------- +-- 1. Erweiterungen (Bestandteil des Supabase-Postgres-Images) +-- --------------------------------------------------------------------------------------------- +create extension if not exists pg_trgm with schema extensions; +create extension if not exists unaccent with schema extensions; + +-- --------------------------------------------------------------------------------------------- +-- 2. search_normalize: die einzige Basisfunktion für normalisierte Titel (E3) +-- Schritte: Marken- und Schutzzeichen entfernen, Akzente/Umlaute entfernen, Kleinschreibung, alles +-- außer a-z/0-9 zu einem Leerzeichen, Ränder kürzen, leer = null. +-- --------------------------------------------------------------------------------------------- +create function public.search_normalize(p text) +returns text +language sql +immutable +strict +parallel safe +set search_path = '' +as $$ + select nullif( + btrim( + regexp_replace( + lower(extensions.unaccent('extensions.unaccent'::regdictionary, regexp_replace(p, '[®©™℗℠]', '', 'g'))), + '[^a-z0-9]+', ' ', 'g')), + '') +$$; + +comment on function public.search_normalize(text) is + 'Normalisierung für Suche und Titelvergleich (KAT-01, gemeinsame Basis mit KAT-04). ' + 'Rein rechnend, ohne Tabellenzugriff: deshalb für anon, authenticated und service_role ausführbar ' + '(generierte Spalten und security-invoker-Funktionen rechnen mit den Rechten des Aufrufers). ' + 'immutable ist eine Zusicherung: unaccent selbst ist nur stable; der Wrapper nennt das Wörterbuch ausdrücklich. ' + 'Ändert sich diese Funktion oder die Datei unaccent.rules (Image-Upgrade), müssen die generierten Spalten ' + 'games.title_search, game_alt_titles.title_search und publishers.name_search per Migration neu angelegt werden ' + '(Prüfabfrage: docs/katalog-pflege.md, Abschnitt Image-Upgrade). Artikel werden hier nie entfernt.'; + +-- Rechte-Regel: zuerst allen entziehen, dann nur die vorgesehenen Grants. +revoke execute on function public.search_normalize(text) from public, anon, authenticated, service_role; +grant execute on function public.search_normalize(text) to anon, authenticated, service_role; + +-- --------------------------------------------------------------------------------------------- +-- 3. Generierte Spalten und Trigramm-Indizes +-- Die Indizes sind keine Zusage für Geschwindigkeit: Bei 2-Zeichen-Wörtern, der Regel ohne Leerzeichen +-- und den RLS-Prüfungen für anon werden sie weitgehend nicht genutzt (Messung: supabase/perf/). +-- --------------------------------------------------------------------------------------------- +alter table public.games + add column title_search text generated always as (public.search_normalize(title)) stored; +alter table public.publishers + add column name_search text generated always as (public.search_normalize(name)) stored; + +-- Der Partial-Index passt nur zu Abfragen mit genau den Bedingungen der View catalog_games. +create index games_title_search_trgm_idx on public.games + using gin (title_search extensions.gin_trgm_ops) + where visibility = 'catalog' and hidden_at is null and merged_into_id is null; +create index publishers_name_search_trgm_idx on public.publishers + using gin (name_search extensions.gin_trgm_ops); + +-- --------------------------------------------------------------------------------------------- +-- 4. Alternative Titel (Klärung 3, E2). Pflege nur über Studio und das Startbestand-Werkzeug. +-- --------------------------------------------------------------------------------------------- +create table public.game_alt_titles ( + id uuid primary key default gen_random_uuid(), + game_id uuid not null references public.games (id) on delete cascade, + title text not null check (char_length(title) between 1 and 200), + locale text not null check (locale in ('de', 'en')), + title_search text generated always as (public.search_normalize(title)) stored + constraint game_alt_titles_title_search_check check (title_search is not null), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + -- Konfliktziel des Startbestand-Werkzeugs; derselbe Titel in derselben Sprache nur einmal. + constraint game_alt_titles_game_locale_title_key unique (game_id, locale, title_search) +); + +create trigger set_updated_at before update on public.game_alt_titles + for each row execute function public.set_updated_at(); +create trigger game_links_mark_manual after insert or update or delete on public.game_alt_titles + for each row execute function public.game_links_mark_manual('alt_titles'); + +create index game_alt_titles_title_search_trgm_idx on public.game_alt_titles + using gin (title_search extensions.gin_trgm_ops); + +alter table public.game_alt_titles enable row level security; +revoke all on public.game_alt_titles from anon, authenticated; +grant select on public.game_alt_titles to anon, authenticated; + +-- Lesen: wer das Spiel sehen darf (Muster game_publishers_select). Schreiben: keine Policy, kein Recht. +create policy game_alt_titles_select on public.game_alt_titles + for select to anon, authenticated + using (exists (select 1 from public.games g where g.id = game_id)); + +-- --------------------------------------------------------------------------------------------- +-- 5. manual_fields: vollständige Liste von main (Migration catalog_foundation) plus 'alt_titles' +-- --------------------------------------------------------------------------------------------- +alter table public.games + drop constraint games_manual_fields_check, + 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', 'alt_titles']::text[]); + +-- --------------------------------------------------------------------------------------------- +-- 6. RPC search_catalog_games (Vertrag zwischen 3a und 3b: Spec 4.2) +-- security invoker: Für den Aufrufer (auch anon) gelten die Policies von games, catalog_sources, +-- game_publishers, publishers, game_alt_titles, game_genres und game_mechanics; die Funktion umgeht +-- nichts. Gelesen wird über die View catalog_games (ausgeblendete und zusammengeführte Spiele fehlen, +-- Bild und Text sind maskiert); title_search kommt per Primärschlüssel aus games. +-- Der Partial-Index games_title_search_trgm_idx ist nur über die Bedingungen der View nutzbar. +-- Ein set statement_timeout an dieser Funktion wirkt nicht (gilt erst für die nächste Anweisung); +-- es gelten die Zeitlimits der Rollen. Suchbegriffe werden nirgends gespeichert oder protokolliert. +-- --------------------------------------------------------------------------------------------- +create function public.search_catalog_games( + p_query text default null, + p_players smallint default null, + p_max_minutes smallint default null, + p_age smallint default null, + p_genre_ids uuid[] default null, + p_mechanic_ids uuid[] default null, + p_limit integer default 30, + p_offset integer default 0 +) +returns table ( + id uuid, + title text, + year smallint, + min_players smallint, + max_players smallint, + min_minutes smallint, + max_minutes smallint, + min_age smallint, + image_path text, + publisher_names text[], + matched_alt_title text, + match_rank smallint, + total_count bigint +) +language plpgsql +stable +set search_path = '' +as $$ +#variable_conflict use_column +declare + v_limit integer := coalesce(p_limit, 30); + v_offset integer := coalesce(p_offset, 0); + v_q text; + v_qc text; + v_words text[] := '{}'; + v_has_query boolean := false; + v_genres uuid[]; + v_mechanics uuid[]; + v_genre_n integer; + v_mech_n integer; +begin + -- Eingabegrenzen (Fehler statt stilles Abschneiden) + if p_query is not null and char_length(p_query) > 100 then + raise exception 'search_query_too_long' using errcode = 'P0001'; + end if; + if (p_players is not null and p_players not between 1 and 99) + or (p_max_minutes is not null and p_max_minutes not between 1 and 1440) + or (p_age is not null and p_age not between 0 and 99) + or cardinality(p_genre_ids) > 20 + or cardinality(p_mechanic_ids) > 20 then + raise exception 'search_filter_invalid' using errcode = 'P0001'; + end if; + if v_limit not between 1 and 50 or v_offset not between 0 and 10000 then + raise exception 'search_paging_invalid' using errcode = 'P0001'; + end if; + + -- Suchbegriff: null/nur Leerraum = kein Suchbegriff; normalisiert null (z. B. '!!!') = keine Treffer; + -- weniger als 2 Zeichen = kein Suchbegriff. + if p_query is not null and p_query ~ '\S' then + v_q := public.search_normalize(p_query); + if v_q is null then + return; + end if; + if cardinality(string_to_array(v_q, ' ')) > 10 then + raise exception 'search_query_too_long' using errcode = 'P0001'; + end if; + if char_length(v_q) >= 2 then + v_has_query := true; + v_qc := replace(v_q, ' ', ''); + v_words := coalesce( + array(select distinct w from unnest(string_to_array(v_q, ' ')) as w where char_length(w) >= 2), '{}'); + end if; + end if; + + v_genres := array(select distinct x from unnest(p_genre_ids) as x where x is not null); + v_mechanics := array(select distinct x from unnest(p_mechanic_ids) as x where x is not null); + v_genre_n := cardinality(v_genres); + v_mech_n := cardinality(v_mechanics); + + if not v_has_query then + -- Ohne Suchbegriff: Filter wirken, Sortierung nach Titel, match_rank ist null. + return query + select s.id, s.title, s.year, s.min_players, s.max_players, s.min_minutes, s.max_minutes, s.min_age, + s.image_path, + coalesce((select array_agg(p.name order by p.name_search, p.name) + from public.game_publishers gp + join public.publishers p on p.id = gp.publisher_id + where gp.game_id = s.id), '{}'::text[]), + null::text, null::smallint, s.total + from ( + select c.id, c.title, c.year, c.min_players, c.max_players, c.min_minutes, c.max_minutes, c.min_age, + c.image_path, g.title_search, count(*) over () as total + from public.catalog_games c + join public.games g on g.id = c.id + where (p_players is null or (c.min_players <= p_players and p_players <= c.max_players)) + and (p_max_minutes is null or coalesce(c.max_minutes, c.min_minutes) <= p_max_minutes) + and (p_age is null or c.min_age <= p_age) + and (v_genre_n = 0 or (select count(*) from public.game_genres gg + where gg.game_id = c.id and gg.genre_id = any (v_genres)) = v_genre_n) + and (v_mech_n = 0 or (select count(*) from public.game_mechanics gm + where gm.game_id = c.id and gm.mechanic_id = any (v_mechanics)) = v_mech_n) + order by g.title_search collate "C", c.id + limit v_limit offset v_offset + ) s + order by s.title_search collate "C", s.id; + return; + end if; + + return query + with base as ( + select c.id, c.title, c.year, c.min_players, c.max_players, c.min_minutes, c.max_minutes, c.min_age, + c.image_path, g.title_search + from public.catalog_games c + join public.games g on g.id = c.id + where (p_players is null or (c.min_players <= p_players and p_players <= c.max_players)) + and (p_max_minutes is null or coalesce(c.max_minutes, c.min_minutes) <= p_max_minutes) + and (p_age is null or c.min_age <= p_age) + and (v_genre_n = 0 or (select count(*) from public.game_genres gg + where gg.game_id = c.id and gg.genre_id = any (v_genres)) = v_genre_n) + and (v_mech_n = 0 or (select count(*) from public.game_mechanics gm + where gm.game_id = c.id and gm.mechanic_id = any (v_mechanics)) = v_mech_n) + ), + -- Kandidaten-Titel: Haupttitel (alt_title null) und alle alternativen Titel + cand as ( + select b.id, b.title_search as ts, null::text as alt_title from base b + union all + select b.id, a.title_search, a.title + from base b join public.game_alt_titles a on a.game_id = b.id + ), + -- Relevanzstufe je Kandidaten-Titel (null = kein Titeltreffer) + ranked as ( + select t.id, t.ts, t.alt_title, + case + when t.ts = v_q or replace(t.ts, ' ', '') = v_qc then 0 + when t.ts like v_q || '%' then 1 + when t.ts like '% ' || v_q || '%' then 2 + when replace(t.ts, ' ', '') like '%' || v_qc || '%' + or (cardinality(v_words) > 0 + and not exists (select 1 from unnest(v_words) as w where t.ts not like '%' || w || '%')) + then 3 + end as rk, + extensions.similarity(t.ts, v_q) as sim + from cand t + ), + agg as ( + select r.id, + min(r.rk) as best_rk, + min(r.rk) filter (where r.alt_title is null) as main_rk, + min(r.rk) filter (where r.alt_title is not null) as alt_rk, + (array_agg(r.alt_title order by r.rk, r.sim desc nulls last, r.ts, r.alt_title) + filter (where r.alt_title is not null and r.rk is not null))[1] as alt_title, + max(r.sim) as sim + from ranked r + group by r.id + ), + hit as ( + select b.*, a.best_rk, a.main_rk, a.alt_rk, a.alt_title, a.sim + from base b + join agg a on a.id = b.id + where a.best_rk is not null + -- Rang 4: jedes Wort kommt in einem Titel oder einem Verlagsnamen des Spiels vor + or (cardinality(v_words) > 0 + and not exists ( + select 1 from unnest(v_words) as w + where not (exists (select 1 from ranked r2 where r2.id = b.id and r2.ts like '%' || w || '%') + or exists (select 1 from public.game_publishers gp + join public.publishers p on p.id = gp.publisher_id + where gp.game_id = b.id and p.name_search like '%' || w || '%')))) + ), + paged as ( + select h.*, coalesce(h.best_rk, 4) as rk, count(*) over () as total + from hit h + order by coalesce(h.best_rk, 4), h.sim desc nulls last, h.title_search collate "C", h.id + limit v_limit offset v_offset + ) + select s.id, s.title, s.year, s.min_players, s.max_players, s.min_minutes, s.max_minutes, s.min_age, + s.image_path, + coalesce((select array_agg(p.name order by p.name_search, p.name) + from public.game_publishers gp + join public.publishers p on p.id = gp.publisher_id + where gp.game_id = s.id), '{}'::text[]), + -- nur, wenn ein alternativer Titel besser getroffen hat als der Haupttitel + case when s.alt_rk is not null and (s.main_rk is null or s.alt_rk < s.main_rk) then s.alt_title end, + s.rk::smallint, s.total + from paged s + order by s.rk, s.sim desc nulls last, s.title_search collate "C", s.id; +end; +$$; + +-- Rechte-Regel: erst allen entziehen, dann nur anon und authenticated (erste Abfrage-RPC für API-Rollen). +revoke execute on function public.search_catalog_games(text, smallint, smallint, smallint, uuid[], uuid[], integer, integer) + from public, anon, authenticated, service_role; +grant execute on function public.search_catalog_games(text, smallint, smallint, smallint, uuid[], uuid[], integer, integer) + to anon, authenticated; diff --git a/supabase/tests/database/00_schema.test.sql b/supabase/tests/database/00_schema.test.sql index c6ee175..d0999af 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'), 21::bigint, - 'R1 hat genau 21 Tabellen (17 + catalog_sources, publishers, game_publishers, game_reports)'); +select is((select count(*) from pg_tables where schemaname = 'public'), 22::bigint, + 'R1 hat genau 22 Tabellen (17 + catalog_sources, publishers, game_publishers, game_reports, game_alt_titles)'); 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 index e9c8241..3d95ec5 100644 --- a/supabase/tests/database/06_catalog_foundation.test.sql +++ b/supabase/tests/database/06_catalog_foundation.test.sql @@ -149,6 +149,10 @@ select is((select manual_fields from public.games where id = 'f6000000-0000-0000 create role catalog_importer nologin; grant catalog_importer to postgres with set true; grant usage on schema public to catalog_importer; +-- Seit KAT-01 berechnet jeder Schreibende title_search selbst: auch der Import braucht EXECUTE auf +-- search_normalize und USAGE auf das Schema extensions (unaccent). Hinweis für SAM-08. +grant execute on function public.search_normalize(text) to catalog_importer; +grant usage on schema extensions 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); @@ -584,7 +588,7 @@ select is_empty($$select * from snap_rows s where not exists (select 1 from ( -- 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ß'), + $$values ('Info'::text, 1, 'Information'::text), ('Info-Alt', 1, 'Information'), ('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ß bei SB2, SB4, SB6 (SB1 liegt lokal erwartbar unter 180)'); update public.games set min_minutes = null where title = 'Testspiel A'; diff --git a/supabase/tests/database/07_catalog_merge.test.sql b/supabase/tests/database/07_catalog_merge.test.sql index 192b9b6..1b7613b 100644 --- a/supabase/tests/database/07_catalog_merge.test.sql +++ b/supabase/tests/database/07_catalog_merge.test.sql @@ -633,6 +633,7 @@ select set_eq($$select fk from fk_found$$, ('game_variants.game_id'), ('games.merged_into_id'), ('game_reports.target_game_id'), -- bewusst ausgelassen: bleiben am ausgeblendeten Quell-Eintrag ('game_genres.game_id'), ('game_mechanics.game_id'), ('game_categories.game_id'), ('game_publishers.game_id'), + ('game_alt_titles.game_id'), -- bewusst ausgelassen: zeigt auf ein privates Spiel, das Zusammenführen zweier Katalogspiele betrifft ihn nicht ('game_reports.provisional_game_id')$$, 'KAT-05 Kriterium 4: Fremdschlüssel-Wächter: neue Verweise auf games erzwingen eine Entscheidung im Zusammenführen'); diff --git a/supabase/tests/database/08_game_reports.test.sql b/supabase/tests/database/08_game_reports.test.sql index c585d3d..adad298 100644 --- a/supabase/tests/database/08_game_reports.test.sql +++ b/supabase/tests/database/08_game_reports.test.sql @@ -731,6 +731,7 @@ select set_eq($$select fk from fk_found$$, ('game_variants.game_id'), ('games.merged_into_id'), ('game_reports.target_game_id'), -- bewusst ausgelassen: bleiben am ausgeblendeten Quell-Eintrag ('game_genres.game_id'), ('game_mechanics.game_id'), ('game_categories.game_id'), ('game_publishers.game_id'), + ('game_alt_titles.game_id'), -- bewusst ausgelassen: zeigt auf ein privates Spiel, das Zusammenführen zweier Katalogspiele betrifft ihn nicht ('game_reports.provisional_game_id')$$, 'KAT-05 Kriterium 4: Fremdschlüssel-Wächter: neue Verweise auf games erzwingen eine Entscheidung im Zusammenführen'); diff --git a/supabase/tests/database/09_catalog_search.test.sql b/supabase/tests/database/09_catalog_search.test.sql new file mode 100644 index 0000000..80f595b --- /dev/null +++ b/supabase/tests/database/09_catalog_search.test.sql @@ -0,0 +1,759 @@ +-- KAT-01 (PR 3a): Katalogsuche. search_normalize, generierte Spalten, game_alt_titles, RPC +-- search_catalog_games, Rechte, Idempotenz der alternativen Titel im Startbestand-Werkzeug und die +-- SQL-Bausteine aus docs/katalog-pflege.md (Abschnitte 3.1, 5 und 8; Kommentare mit Abschnittsverweis). +-- Läuft auch mit eingespieltem Startbestand: eigene Fixtures mit festen IDs (Präfix f9) und dem +-- Kunstwort "qxz" in allen Titeln und Verlagen, damit Suchen nie Startbestandsspiele treffen. +-- Ohne Suchbegriff wird nur auf Fixture-IDs geprüft (Hilfsfunktion pg_temp.hits blättert durch). +-- Die Include-Dateien in ../support/ (\ir) erzeugt supabase/catalog/build_startbestand.ts. +begin; +select plan(201); + +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"}'); + +-- --------------------------------------------------------------------------------------------- +-- Erweiterungen und Rechte (Sicherheit Kriterium 2, Spec 3.2) +-- --------------------------------------------------------------------------------------------- +select is((select string_agg(extname || ':' || extnamespace::regnamespace::text, ',' order by extname) + from pg_extension where extname in ('pg_trgm', 'unaccent')), + 'pg_trgm:extensions,unaccent:extensions', 'KAT-01: pg_trgm und unaccent liegen im Schema extensions'); +select ok(has_function_privilege('anon', 'extensions.unaccent(regdictionary, text)', 'execute') + and has_function_privilege('authenticated', 'extensions.unaccent(regdictionary, text)', 'execute') + and has_function_privilege('anon', 'extensions.similarity(text, text)', 'execute') + and has_function_privilege('authenticated', 'extensions.similarity(text, text)', 'execute') + and has_schema_privilege('anon', 'extensions', 'usage') and has_schema_privilege('authenticated', 'extensions', 'usage'), + 'KAT-01: anon und authenticated dürfen unaccent und similarity im Schema extensions ausführen (B5)'); + +select ok(has_function_privilege('anon', 'public.search_normalize(text)', 'execute') + and has_function_privilege('authenticated', 'public.search_normalize(text)', 'execute') + and has_function_privilege('service_role', 'public.search_normalize(text)', 'execute'), + 'KAT-01: search_normalize ist für anon, authenticated und service_role ausführbar'); +select is((select p.proacl is not null and not exists (select 1 from aclexplode(p.proacl) a where a.grantee = 0) + from pg_proc p where p.oid = 'public.search_normalize(text)'::regprocedure), true, + 'KAT-01: search_normalize ist für public (alle) nicht ausführbar (Rechteliste ausdrücklich gesetzt)'); +select ok(has_function_privilege('anon', 'public.search_catalog_games(text, smallint, smallint, smallint, uuid[], uuid[], integer, integer)', 'execute') + and has_function_privilege('authenticated', 'public.search_catalog_games(text, smallint, smallint, smallint, uuid[], uuid[], integer, integer)', 'execute'), + 'KAT-01 Sicherheit Kriterium 2: anon und authenticated dürfen search_catalog_games ausführen'); +select ok(not has_function_privilege('service_role', 'public.search_catalog_games(text, smallint, smallint, smallint, uuid[], uuid[], integer, integer)', 'execute'), + 'KAT-01: service_role darf search_catalog_games nicht ausführen'); +select is((select p.proacl is not null and not exists (select 1 from aclexplode(p.proacl) a where a.grantee = 0) + from pg_proc p where p.oid = 'public.search_catalog_games(text, smallint, smallint, smallint, uuid[], uuid[], integer, integer)'::regprocedure), true, + 'KAT-01: search_catalog_games ist für public (alle) nicht ausführbar (Rechteliste ausdrücklich gesetzt)'); +select is((select count(*) from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'search_normalize' + and p.provolatile = 'i' and p.proisstrict and p.proparallel = 's' and not p.prosecdef + and p.proconfig = array['search_path=""']), 1::bigint, + 'KAT-01: search_normalize ist immutable, strict, parallel safe, security invoker mit leerem search_path'); +select is((select count(*) from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'search_catalog_games' + and p.provolatile = 's' and not p.prosecdef and p.proconfig = array['search_path=""']), 1::bigint, + 'KAT-01: search_catalog_games ist stable, security invoker mit leerem search_path'); +select is((select count(*) from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'search_catalog_games'), 1::bigint, + 'KAT-01: es gibt genau eine Überladung von search_catalog_games'); +select ok(exists (select 1 from pg_roles where rolname = 'anon' and exists (select 1 from unnest(rolconfig) c where c like 'statement_timeout=%')) + and exists (select 1 from pg_roles where rolname = 'authenticated' and exists (select 1 from unnest(rolconfig) c where c like 'statement_timeout=%')), + 'KAT-01 Sicherheit (R4): anon und authenticated haben ein statement_timeout (schützt die Suche ohne Anmeldung)'); + +-- --------------------------------------------------------------------------------------------- +-- search_normalize: Pflichtfälle (Spec 3.2 Nr. 2) und weitere Fälle +-- --------------------------------------------------------------------------------------------- +select is(public.search_normalize('Die Siedler von Catan'), 'die siedler von catan', 'KAT-01 Kriterium 1: Pflichtfall Die Siedler von Catan'); +select is(public.search_normalize('CATAN®'), 'catan', 'KAT-01 Kriterium 1: Pflichtfall CATAN® (Schutzzeichen entfernt, kein "catan r")'); +select is(public.search_normalize('Spiel™ ½'), 'spiel 1 2', 'KAT-01 Kriterium 1: Pflichtfall Spiel™ ½ (Bruch wie von unaccent umgeschrieben)'); +select is(public.search_normalize('Æon’s End'), 'aeon s end', 'KAT-01 Kriterium 1: Pflichtfall Æon’s End (Apostroph wird Leerzeichen)'); +select is(public.search_normalize('7 Wonders: Duel'), '7 wonders duel', 'KAT-01 Kriterium 1: Pflichtfall 7 Wonders: Duel'); +select is(public.search_normalize('Café Größe'), 'cafe grosse', 'KAT-01 Kriterium 1: Pflichtfall Café Größe (é, ö, ß)'); +select is(public.search_normalize('Ticket to Ride – Europe'), 'ticket to ride europe', 'KAT-01 Kriterium 1: Pflichtfall Gedankenstrich'); +select is(public.search_normalize('!!!'), null, 'KAT-01 Kriterium 1: nur Sonderzeichen ergibt null'); +select is(public.search_normalize(''), null, 'KAT-01 Kriterium 1: leerer Text ergibt null'); +select is(public.search_normalize(null), null, 'KAT-01: null ergibt null (strict)'); +select is(public.search_normalize(' ©Ärger℠ ℗ Ü '), 'arger u', 'KAT-01 Kriterium 1: alle Schutzzeichen, Umlaute und Ränder'); +select is(public.search_normalize('Die Siedler, von Catan'), 'die siedler von catan', 'KAT-01: mehrere Trenner werden zu einem Leerzeichen'); + +-- --------------------------------------------------------------------------------------------- +-- Generierte Spalten, Indizes, Tabelle game_alt_titles (Kriterium 2) +-- --------------------------------------------------------------------------------------------- +select is((select string_agg(attrelid::regclass::text || '.' || attname, ',' order by attrelid::regclass::text) + from pg_attribute where attgenerated = 's' and attname in ('title_search', 'name_search') + and attrelid in ('public.games'::regclass, 'public.publishers'::regclass, 'public.game_alt_titles'::regclass)), + 'game_alt_titles.title_search,games.title_search,publishers.name_search', + 'KAT-01: title_search und name_search sind gespeicherte generierte Spalten'); +select is((select count(*) from pg_indexes where schemaname = 'public' + and indexdef like '%USING gin (title_search %gin_trgm_ops)%' + and indexname in ('games_title_search_trgm_idx', 'game_alt_titles_title_search_trgm_idx')), 2::bigint, + 'KAT-01: Trigramm-Indizes auf games.title_search und game_alt_titles.title_search'); +select is((select count(*) from pg_indexes where schemaname = 'public' and indexname = 'games_title_search_trgm_idx' + and indexdef like '%WHERE%visibility%catalog%hidden_at IS NULL%merged_into_id IS NULL%'), 1::bigint, + 'KAT-01: der Index auf games.title_search ist ein Partial-Index mit den Bedingungen der View catalog_games'); +select is((select count(*) from pg_indexes where schemaname = 'public' and indexname = 'publishers_name_search_trgm_idx' + and indexdef like '%USING gin (name_search %gin_trgm_ops)%'), 1::bigint, + 'KAT-01: Trigramm-Index auf publishers.name_search'); +select is((select confdeltype::text from pg_constraint where conrelid = 'public.game_alt_titles'::regclass and contype = 'f' + and confrelid = 'public.games'::regclass), 'c', 'KAT-01 Kriterium 2: game_alt_titles.game_id löscht mit dem Spiel (on delete cascade)'); +select ok(exists (select 1 from pg_trigger where tgrelid = 'public.game_alt_titles'::regclass and tgname = 'game_links_mark_manual'), + 'KAT-01: game_alt_titles hat den Trigger game_links_mark_manual'); + +-- Rechte der Tabelle: nur Lesen für API-Rollen +select ok(has_table_privilege('anon', 'public.game_alt_titles', 'select') and has_table_privilege('authenticated', 'public.game_alt_titles', 'select'), + 'KAT-01 Kriterium 2: anon und authenticated dürfen game_alt_titles lesen'); +select is_empty($$select r, p from unnest(array['anon', 'authenticated']) r, + unnest(array['insert', 'update', 'delete', 'truncate', 'references', 'trigger']) p + where has_table_privilege(r, 'public.game_alt_titles', p)$$, + 'KAT-01 Kriterium 2: anon und authenticated haben kein Schreibrecht und keine weiteren Rechte auf game_alt_titles'); +select is_empty($$select r, c from unnest(array['anon', 'authenticated']) r, unnest(array['game_id', 'title', 'locale', 'id']) c + where has_column_privilege(r, 'public.game_alt_titles', c, 'insert') or has_column_privilege(r, 'public.game_alt_titles', c, 'update')$$, + 'KAT-01 Kriterium 2: auch auf Spaltenebene kein Schreibrecht auf game_alt_titles'); +select results_eq($$select polcmd::text, array(select rolname from pg_roles where oid = any(polroles) order by 1) + from pg_policy where polrelid = 'public.game_alt_titles'::regclass$$, + $$values ('r'::text, array['anon', 'authenticated']::name[])$$, + 'KAT-01 Kriterium 2: game_alt_titles hat genau eine Policy, nur select für anon und authenticated'); + +-- --------------------------------------------------------------------------------------------- +-- Fixtures (alle Titel und Verlage mit "qxz") +-- --------------------------------------------------------------------------------------------- +insert into public.genres (id, name_de, name_en) values + ('f9000000-0000-0000-0000-0000000000e1', 'Qxz Genre A', 'Qxz genre A'), + ('f9000000-0000-0000-0000-0000000000e2', 'Qxz Genre B', 'Qxz genre B'), + ('f9000000-0000-0000-0000-0000000000e3', 'Qxz Genre Q', 'Qxz genre Q'); +insert into public.mechanics (id, name_de, name_en) values + ('f9000000-0000-0000-0000-0000000000d1', 'Qxz Mechanik A', 'Qxz mechanic A'), + ('f9000000-0000-0000-0000-0000000000d2', 'Qxz Mechanik B', 'Qxz mechanic B'); +insert into public.publishers (id, name) values + ('f9000000-0000-0000-0000-0000000000b1', 'Qxz Hersteller Eins'), + ('f9000000-0000-0000-0000-0000000000b2', 'Qxz Hersteller Zwei'), + ('f9000000-0000-0000-0000-0000000000b3', 'Qxz Zorb Verlag'); + +insert into public.games (id, title, visibility) values + ('f9000000-0000-0000-0000-000000000001', 'QXZ Zorb', 'catalog'), + ('f9000000-0000-0000-0000-000000000002', 'Qxz Zorb Deluxe', 'catalog'), + ('f9000000-0000-0000-0000-000000000003', 'Das Qxz Zorb', 'catalog'), + ('f9000000-0000-0000-0000-000000000004', 'Zorb of Qxz', 'catalog'), + ('f9000000-0000-0000-0000-000000000005', 'Qxz Great Zorb', 'catalog'), + ('f9000000-0000-0000-0000-000000000006', 'Qxz Spiel', 'catalog'), + -- Gleichstand: erst die größere ID eingefügt, damit die Heap-Reihenfolge nicht zufällig die ID-Reihenfolge ist + ('f9000000-0000-0000-0000-000000000008', 'Qxz Twin', 'catalog'), + ('f9000000-0000-0000-0000-000000000007', 'QXZ TWIN', 'catalog'), + ('f9000000-0000-0000-0000-000000000010', 'Qxz Hauptspiel Alpha', 'catalog'), + ('f9000000-0000-0000-0000-000000000011', 'Alpha Qxz Bird', 'catalog'), + ('f9000000-0000-0000-0000-000000000012', 'Qxz Einzigartig', 'catalog'), + ('f9000000-0000-0000-0000-000000000013', 'Qxz Käse Größe', 'catalog'), + ('f9000000-0000-0000-0000-000000000014', 'Qxz Æon’s End', 'catalog'), + ('f9000000-0000-0000-0000-000000000015', 'Qxz 7 Wonders', 'catalog'), + ('f9000000-0000-0000-0000-000000000016', 'QXZ CATAN®', 'catalog'), + ('f9000000-0000-0000-0000-000000000040', 'Qxz Doppelspiel', 'catalog'), + ('f9000000-0000-0000-0000-000000000041', 'Qxz Einzelspiel', 'catalog'), + ('f9000000-0000-0000-0000-000000000060', 'Qxz Cabin', 'catalog'); + +-- Filter-Fixtures (20-25); 22 ohne jede Angabe, 23 nur max_minutes, 24 nur min_minutes +insert into public.games (id, title, visibility, min_players, max_players, min_minutes, max_minutes, min_age) values + ('f9000000-0000-0000-0000-000000000020', 'Qxz Filter 20', 'catalog', 2, 4, 30, 60, 8), + ('f9000000-0000-0000-0000-000000000021', 'Qxz Filter 21', 'catalog', 3, 6, 60, 90, 12), + ('f9000000-0000-0000-0000-000000000022', 'Qxz Filter 22', 'catalog', null, null, null, null, null), + ('f9000000-0000-0000-0000-000000000023', 'Qxz Filter 23', 'catalog', 2, 2, null, 45, 6), + ('f9000000-0000-0000-0000-000000000024', 'Qxz Filter 24', 'catalog', 1, 8, 20, null, 0), + ('f9000000-0000-0000-0000-000000000025', 'Qxz Filter 25', 'catalog', 2, 5, 30, 30, 10); +insert into public.game_genres (game_id, genre_id) values + ('f9000000-0000-0000-0000-000000000020', 'f9000000-0000-0000-0000-0000000000e1'), + ('f9000000-0000-0000-0000-000000000020', 'f9000000-0000-0000-0000-0000000000e2'), + ('f9000000-0000-0000-0000-000000000021', 'f9000000-0000-0000-0000-0000000000e1'), + ('f9000000-0000-0000-0000-000000000022', 'f9000000-0000-0000-0000-0000000000e1'), + ('f9000000-0000-0000-0000-000000000025', 'f9000000-0000-0000-0000-0000000000e2'), + ('f9000000-0000-0000-0000-000000000005', 'f9000000-0000-0000-0000-0000000000e3'), + ('f9000000-0000-0000-0000-000000000060', 'f9000000-0000-0000-0000-0000000000e3'); +insert into public.game_mechanics (game_id, mechanic_id) values + ('f9000000-0000-0000-0000-000000000020', 'f9000000-0000-0000-0000-0000000000d1'), + ('f9000000-0000-0000-0000-000000000021', 'f9000000-0000-0000-0000-0000000000d1'), + ('f9000000-0000-0000-0000-000000000021', 'f9000000-0000-0000-0000-0000000000d2'), + ('f9000000-0000-0000-0000-000000000025', 'f9000000-0000-0000-0000-0000000000d2'); + +-- Verlage: 01 und 30 bei Eins; 40 bei Eins und Zwei; 41 bei Zwei; 06 bei "Qxz Zorb Verlag" +insert into public.game_publishers (game_id, publisher_id) values + ('f9000000-0000-0000-0000-000000000001', 'f9000000-0000-0000-0000-0000000000b1'), + ('f9000000-0000-0000-0000-000000000040', 'f9000000-0000-0000-0000-0000000000b2'), + ('f9000000-0000-0000-0000-000000000040', 'f9000000-0000-0000-0000-0000000000b1'), + ('f9000000-0000-0000-0000-000000000041', 'f9000000-0000-0000-0000-0000000000b2'), + ('f9000000-0000-0000-0000-000000000006', 'f9000000-0000-0000-0000-0000000000b3'); + +-- Ausgeblendet, zusammengeführt, privat +insert into public.games (id, title, visibility, hidden_at) values + ('f9000000-0000-0000-0000-000000000030', 'Qxz Verborgen', 'catalog', now()); +insert into public.games (id, title, visibility, hidden_at, merged_into_id) values + ('f9000000-0000-0000-0000-000000000031', 'Qxz Verschmolzen', 'catalog', now(), 'f9000000-0000-0000-0000-000000000001'); +insert into public.games (id, title, visibility, owner_id) values + ('f9000000-0000-0000-0000-000000000032', 'Qxz Privat', 'private', '11111111-1111-1111-1111-111111111111'), + ('f9000000-0000-0000-0000-000000000033', 'QXZ ZORB', 'private', '22222222-2222-2222-2222-222222222222'); +insert into public.game_publishers (game_id, publisher_id) values + ('f9000000-0000-0000-0000-000000000030', 'f9000000-0000-0000-0000-0000000000b1'), + ('f9000000-0000-0000-0000-000000000033', 'f9000000-0000-0000-0000-0000000000b1'); + +-- Bilder: 50 mit Quelle ohne Erlaubnis (maskiert), 51 manuell gepflegt, 52 ohne Quelle +insert into public.games (id, title, visibility, image_path, catalog_source_id, source_ref) values + ('f9000000-0000-0000-0000-000000000050', 'Qxz Cover Quelle', 'catalog', 'covers/quelle.png', (select id from public.catalog_sources where key = 'bgg'), '501'), + ('f9000000-0000-0000-0000-000000000051', 'Qxz Cover Manuell', 'catalog', 'covers/manuell.png', (select id from public.catalog_sources where key = 'bgg'), '502'), + ('f9000000-0000-0000-0000-000000000052', 'Qxz Cover Eigen', 'catalog', 'covers/eigen.png', null, null); +update public.games set manual_fields = '{}' where id = 'f9000000-0000-0000-0000-000000000050'; +update public.games set manual_fields = array['image_path'] where id = 'f9000000-0000-0000-0000-000000000051'; + +-- 40 Spiele für das Nachladen (Kriterium 5) +insert into public.games (id, title, visibility) +select ('f9000000-0000-0000-0000-0000000001' || lpad(n::text, 2, '0'))::uuid, 'Qxz Seite ' || lpad(n::text, 2, '0'), 'catalog' +from generate_series(1, 40) n; + +-- Alternative Titel (als postgres, wie im Studio) +insert into public.game_alt_titles (id, game_id, locale, title) values + ('f9000000-0000-0000-0000-0000000000a1', 'f9000000-0000-0000-0000-000000000010', 'de', 'Qxz Zugvogel'), + ('f9000000-0000-0000-0000-0000000000a2', 'f9000000-0000-0000-0000-000000000010', 'en', 'Qxz Migrant Bird'), + ('f9000000-0000-0000-0000-0000000000a3', 'f9000000-0000-0000-0000-000000000011', 'en', 'Qxz Bird'), + ('f9000000-0000-0000-0000-0000000000a4', 'f9000000-0000-0000-0000-000000000012', 'de', 'Qxz Einzigartig Alt'), + ('f9000000-0000-0000-0000-0000000000a5', 'f9000000-0000-0000-0000-000000000012', 'en', 'QXZ EINZIGARTIG ALT'), + ('f9000000-0000-0000-0000-0000000000a6', 'f9000000-0000-0000-0000-000000000030', 'de', 'Qxz Verborgen Alt'), + ('f9000000-0000-0000-0000-0000000000a7', 'f9000000-0000-0000-0000-000000000031', 'de', 'Qxz Verschmolzen Alt'), + ('f9000000-0000-0000-0000-0000000000a8', 'f9000000-0000-0000-0000-000000000032', 'de', 'Qxz Privat Alt'), + ('f9000000-0000-0000-0000-0000000000a9', 'f9000000-0000-0000-0000-000000000033', 'en', 'Qxz Bens Alt'); + +select is((select title_search from public.games where id = 'f9000000-0000-0000-0000-000000000014'), 'qxz aeon s end', + 'KAT-01 Kriterium 1: games.title_search wird beim Anlegen gefüllt'); +select is((select title_search from public.game_alt_titles where id = 'f9000000-0000-0000-0000-0000000000a5'), 'qxz einzigartig alt', + 'KAT-01 Kriterium 2: game_alt_titles.title_search wird gefüllt'); +select is((select name_search from public.publishers where id = 'f9000000-0000-0000-0000-0000000000b2'), 'qxz hersteller zwei', + 'KAT-01 Kriterium 3: publishers.name_search wird gefüllt'); +update public.publishers set name = 'Qxz Hersteller Zwei & Söhne' where id = 'f9000000-0000-0000-0000-0000000000b2'; +select is((select name_search from public.publishers where id = 'f9000000-0000-0000-0000-0000000000b2'), 'qxz hersteller zwei sohne', + 'KAT-01: publishers.name_search folgt einer Namensänderung'); +update public.publishers set name = 'Qxz Hersteller Zwei' where id = 'f9000000-0000-0000-0000-0000000000b2'; + +-- Hilfsfunktion: alle Fixture-Treffer durchblättern (nur als postgres, für Abfragen ohne Suchbegriff) +create function pg_temp.hits(p_query text default null, p_players smallint default null, p_max_minutes smallint default null, + p_age smallint default null, p_genre_ids uuid[] default null, p_mechanic_ids uuid[] default null) +returns setof uuid language sql as $$ + select r.id + from generate_series(0, (select coalesce(max(t.total_count), 0) + from public.search_catalog_games(p_query, p_players, p_max_minutes, p_age, p_genre_ids, p_mechanic_ids, 1, 0) t)::int, 50) o, + lateral public.search_catalog_games(p_query, p_players, p_max_minutes, p_age, p_genre_ids, p_mechanic_ids, 50, o) r + where r.id::text like 'f9000000%' +$$; + +-- --------------------------------------------------------------------------------------------- +-- Kriterium 1: Teilzeichenkette, Groß/Klein, Sonderzeichen, Relevanzstufen +-- --------------------------------------------------------------------------------------------- +select results_eq($$select id, match_rank::int, total_count::int from public.search_catalog_games('qxz zorb')$$, + $$values ('f9000000-0000-0000-0000-000000000001'::uuid, 0, 6), + ('f9000000-0000-0000-0000-000000000002', 1, 6), + ('f9000000-0000-0000-0000-000000000003', 2, 6), + ('f9000000-0000-0000-0000-000000000004', 3, 6), + ('f9000000-0000-0000-0000-000000000005', 3, 6), + ('f9000000-0000-0000-0000-000000000006', 4, 6)$$, + 'KAT-01 Kriterium 1: Relevanzstufen 0 bis 4 in der erwarteten Reihenfolge; gleiche Stufe nach Ähnlichkeit (nicht nach Titel); Verlagstreffer zuletzt'); +select results_eq($$select id from public.search_catalog_games('QXZ: ZORB!')$$, + $$select id from public.search_catalog_games('qxz zorb')$$, + 'KAT-01 Kriterium 1: Groß-/Kleinschreibung und Sonderzeichen ändern das Ergebnis nicht'); +select results_eq($$select id, match_rank::int from public.search_catalog_games('qxz zo')$$, + $$values ('f9000000-0000-0000-0000-000000000001'::uuid, 1), ('f9000000-0000-0000-0000-000000000002', 1), + ('f9000000-0000-0000-0000-000000000003', 2), ('f9000000-0000-0000-0000-000000000004', 3), + ('f9000000-0000-0000-0000-000000000005', 3), ('f9000000-0000-0000-0000-000000000006', 4)$$, + 'KAT-01 Kriterium 1: ein Teil des Titels genügt (Wort ab 2 Zeichen als Teilzeichenkette)'); +select results_eq($$select id from public.search_catalog_games('qxz twin')$$, + $$values ('f9000000-0000-0000-0000-000000000007'::uuid), ('f9000000-0000-0000-0000-000000000008')$$, + 'KAT-01 Kriterium 5: bei völligem Gleichstand entscheidet die ID (stabile Reihenfolge)'); +select is((select count(*) from public.search_catalog_games('qxz käse')), 1::bigint, 'KAT-01 Kriterium 1: Umlaut im Suchbegriff findet den Umlaut-Titel'); +select is((select count(*) from public.search_catalog_games('QXZ KASE GROSSE')), 1::bigint, 'KAT-01 Kriterium 1: ohne Umlaut und ß wird derselbe Titel gefunden'); +select is((select count(*) from public.search_catalog_games('qxz grösse')), 1::bigint, 'KAT-01 Kriterium 1: ö und ß in der Eingabe'); +select is((select count(*) from public.search_catalog_games('qxz kaese')), 0::bigint, 'KAT-01 Spec-F5: "ae" findet "ä" in R1 nicht'); +select results_eq($$select id, match_rank::int from public.search_catalog_games('qxz aeons end')$$, + $$values ('f9000000-0000-0000-0000-000000000014'::uuid, 0)$$, + 'KAT-01 Kriterium 1: "aeons end" findet "Æon’s End" über die Schreibweise ohne Leerzeichen'); +select is((select count(*) from public.search_catalog_games('qxzaeonsend')), 1::bigint, 'KAT-01 Kriterium 1: ohne jedes Leerzeichen eingegeben'); +select is((select count(*) from public.search_catalog_games('qxz æon’s end')), 1::bigint, 'KAT-01 Kriterium 1: Æ und Apostroph in der Eingabe'); +select results_eq($$select id, match_rank::int from public.search_catalog_games('qxz 7wonders')$$, + $$values ('f9000000-0000-0000-0000-000000000015'::uuid, 0)$$, + 'KAT-01 Kriterium 1: "7wonders" findet "7 Wonders"'); +select results_eq($$select id, match_rank::int from public.search_catalog_games('Qxz-Catan!')$$, + $$values ('f9000000-0000-0000-0000-000000000016'::uuid, 0)$$, + 'KAT-01 Kriterium 1: "CATAN®" wird über "catan" gefunden'); +select is((select count(*) from public.search_catalog_games('!!!')), 0::bigint, 'KAT-01 Kriterium 1: nur Sonderzeichen ergibt keine Treffer'); +select is((select count(*) from public.search_catalog_games('zzznichtvorhanden')), 0::bigint, 'KAT-01 Kriterium 6: Suche ohne Treffer liefert keine Zeilen'); +select results_eq($$select id from public.search_catalog_games('qxz 9 zorb') order by id$$, + $$values ('f9000000-0000-0000-0000-000000000001'::uuid), ('f9000000-0000-0000-0000-000000000002'), ('f9000000-0000-0000-0000-000000000003'), + ('f9000000-0000-0000-0000-000000000004'), ('f9000000-0000-0000-0000-000000000005'), ('f9000000-0000-0000-0000-000000000006')$$, + 'KAT-01 Kriterium 1: Wörter mit einem Zeichen werden verworfen ("qxz 9 zorb" = "qxz zorb")'); +select results_eq($$select id, match_rank::int from public.search_catalog_games('a b', null, null, null, array['f9000000-0000-0000-0000-0000000000e3']::uuid[])$$, + $$values ('f9000000-0000-0000-0000-000000000060'::uuid, 3)$$, + 'KAT-01 Kriterium 1: "a b" (nur Wörter mit einem Zeichen) trifft nur über die Schreibweise ohne Leerzeichen'); +select results_eq($$select id, match_rank::int from public.search_catalog_games('É', null, null, null, array['f9000000-0000-0000-0000-0000000000e3']::uuid[])$$, + $$values ('f9000000-0000-0000-0000-000000000060'::uuid, null::int), ('f9000000-0000-0000-0000-000000000005', null)$$, + 'KAT-01 Kriterium 1: ein normalisierter Begriff unter 2 Zeichen gilt als kein Suchbegriff (alles, match_rank null)'); +select results_eq($$select id, match_rank::int from public.search_catalog_games(' ', null, null, null, array['f9000000-0000-0000-0000-0000000000e3']::uuid[])$$, + $$values ('f9000000-0000-0000-0000-000000000060'::uuid, null::int), ('f9000000-0000-0000-0000-000000000005', null)$$, + 'KAT-01 Kriterium 8: nur Leerzeichen gelten als kein Suchbegriff'); +select results_eq($$select id, match_rank::int from public.search_catalog_games(null, null, null, null, array['f9000000-0000-0000-0000-0000000000e3']::uuid[])$$, + $$values ('f9000000-0000-0000-0000-000000000060'::uuid, null::int), ('f9000000-0000-0000-0000-000000000005', null)$$, + 'KAT-01 Kriterium 8: null ist kein Suchbegriff, Sortierung nach Titel'); + +-- --------------------------------------------------------------------------------------------- +-- Kriterium 2: alternative Titel +-- --------------------------------------------------------------------------------------------- +select results_eq($$select id, matched_alt_title, match_rank::int from public.search_catalog_games('qxz zugvogel')$$, + $$values ('f9000000-0000-0000-0000-000000000010'::uuid, 'Qxz Zugvogel'::text, 0)$$, + 'KAT-01 Kriterium 2: der deutsche alternative Titel findet das Spiel, matched_alt_title nennt ihn'); +select results_eq($$select id, matched_alt_title, match_rank::int from public.search_catalog_games('qxz migrant bird')$$, + $$values ('f9000000-0000-0000-0000-000000000010'::uuid, 'Qxz Migrant Bird'::text, 0)$$, + 'KAT-01 Kriterium 2: der englische alternative Titel findet dasselbe Spiel'); +select results_eq($$select id, matched_alt_title, match_rank::int from public.search_catalog_games('qxz hauptspiel')$$, + $$values ('f9000000-0000-0000-0000-000000000010'::uuid, null::text, 1)$$, + 'KAT-01 Kriterium 2: Treffer über den Haupttitel hat matched_alt_title null'); +select results_eq($$select id, matched_alt_title, match_rank::int, total_count::int from public.search_catalog_games('qxz bird')$$, + $$values ('f9000000-0000-0000-0000-000000000011'::uuid, 'Qxz Bird'::text, 0, 2), + ('f9000000-0000-0000-0000-000000000010', 'Qxz Migrant Bird', 3, 2)$$, + 'KAT-01 Kriterium 2: der alternative Titel zählt, wenn er besser trifft als der Haupttitel (Stufe 0 statt 2)'); +select results_eq($$select id, matched_alt_title, match_rank::int, total_count::int from public.search_catalog_games('qxz einzigartig')$$, + $$values ('f9000000-0000-0000-0000-000000000012'::uuid, null::text, 0, 1)$$, + 'KAT-01 Kriterium 2: ein Spiel erscheint nur einmal, auch wenn Haupt- und alternative Titel treffen; bei besserem Haupttitel null'); +select results_eq($$select id, lower(matched_alt_title), total_count::int from public.search_catalog_games('qxz einzigartig alt')$$, + $$values ('f9000000-0000-0000-0000-000000000012'::uuid, 'qxz einzigartig alt'::text, 1)$$, + 'KAT-01 Kriterium 2: zwei alternative Titel (de, en) mit gleicher Stufe ergeben eine Zeile'); + +-- --------------------------------------------------------------------------------------------- +-- Kriterium 3: Verlage +-- --------------------------------------------------------------------------------------------- +select results_eq($$select id, match_rank::int, publisher_names from public.search_catalog_games('qxz hersteller zwei')$$, + $$values ('f9000000-0000-0000-0000-000000000040'::uuid, 4, array['Qxz Hersteller Eins', 'Qxz Hersteller Zwei']::text[]), + ('f9000000-0000-0000-0000-000000000041', 4, array['Qxz Hersteller Zwei'])$$, + 'KAT-01 Kriterium 3: der Verlag findet alle seine Spiele (Stufe 4); ein Spiel mit zwei Verlagen über beide; publisher_names sortiert und vollständig'); +select results_eq($$select id from public.search_catalog_games('qxz hersteller eins') order by id$$, + $$values ('f9000000-0000-0000-0000-000000000001'::uuid), ('f9000000-0000-0000-0000-000000000040')$$, + 'KAT-01 Kriterium 3: der andere Verlag des Spiels findet es ebenfalls; ausgeblendete Spiele des Verlags fehlen'); +select results_eq($$select id, match_rank::int from public.search_catalog_games('qxz zorb eins')$$, + $$values ('f9000000-0000-0000-0000-000000000001'::uuid, 4)$$, + 'KAT-01 Kriterium 3: ein Wort aus dem Titel plus ein Wort aus dem Verlag (UND)'); +select results_eq($$select id, publisher_names from public.search_catalog_games('qxz cabin')$$, + $$values ('f9000000-0000-0000-0000-000000000060'::uuid, '{}'::text[])$$, + 'KAT-01 Kriterium 3: publisher_names ist leer statt null'); + +-- --------------------------------------------------------------------------------------------- +-- Kriterium 4 und 8: Filter, mit und ohne Suchbegriff +-- --------------------------------------------------------------------------------------------- +select results_eq($$select h from pg_temp.hits(p_players := 3::smallint) h order by 1$$, + $$values ('f9000000-0000-0000-0000-000000000020'::uuid), ('f9000000-0000-0000-0000-000000000021'), ('f9000000-0000-0000-0000-000000000024'), ('f9000000-0000-0000-0000-000000000025')$$, + 'KAT-01 Kriterium 8: Spielerzahl 3 ohne Suchbegriff (Spiele ohne Angabe fehlen, 2-2 passt nicht)'); +select results_eq($$select h from pg_temp.hits('qxz filter', p_players := 3::smallint) h order by 1$$, + $$values ('f9000000-0000-0000-0000-000000000020'::uuid), ('f9000000-0000-0000-0000-000000000021'), ('f9000000-0000-0000-0000-000000000024'), ('f9000000-0000-0000-0000-000000000025')$$, + 'KAT-01 Kriterium 4: Spielerzahl 3 mit Suchbegriff'); +select results_eq($$select h from pg_temp.hits(p_players := 2::smallint) h order by 1$$, + $$values ('f9000000-0000-0000-0000-000000000020'::uuid), ('f9000000-0000-0000-0000-000000000023'), ('f9000000-0000-0000-0000-000000000024'), ('f9000000-0000-0000-0000-000000000025')$$, + 'KAT-01 Kriterium 8: Spielerzahl 2 (untere und obere Grenze zählen mit)'); +select results_eq($$select h from pg_temp.hits(p_max_minutes := 45::smallint) h order by 1$$, + $$values ('f9000000-0000-0000-0000-000000000023'::uuid), ('f9000000-0000-0000-0000-000000000024'), ('f9000000-0000-0000-0000-000000000025')$$, + 'KAT-01 Spec-F2: höchstens 45 Minuten; nur max_minutes oder nur min_minutes bekannt zählt, ohne Angabe fehlt'); +select results_eq($$select h from pg_temp.hits('qxz filter', p_max_minutes := 60::smallint) h order by 1$$, + $$values ('f9000000-0000-0000-0000-000000000020'::uuid), ('f9000000-0000-0000-0000-000000000023'), ('f9000000-0000-0000-0000-000000000024'), ('f9000000-0000-0000-0000-000000000025')$$, + 'KAT-01 Kriterium 4: höchstens 60 Minuten (Grenze zählt mit) mit Suchbegriff'); +select results_eq($$select h from pg_temp.hits(p_age := 8::smallint) h order by 1$$, + $$values ('f9000000-0000-0000-0000-000000000020'::uuid), ('f9000000-0000-0000-0000-000000000023'), ('f9000000-0000-0000-0000-000000000024')$$, + 'KAT-01 Kriterium 8: Alter 8 (Mindestalter bis einschließlich 8)'); +select results_eq($$select h from pg_temp.hits(p_age := 0::smallint) h$$, + $$values ('f9000000-0000-0000-0000-000000000024'::uuid)$$, + 'KAT-01 Kriterium 4: Alter 0 findet nur Mindestalter 0'); +select results_eq($$select h from pg_temp.hits(p_genre_ids := array['f9000000-0000-0000-0000-0000000000e1']::uuid[]) h order by 1$$, + $$values ('f9000000-0000-0000-0000-000000000020'::uuid), ('f9000000-0000-0000-0000-000000000021'), ('f9000000-0000-0000-0000-000000000022')$$, + 'KAT-01 Kriterium 4: Genre allein'); +select results_eq($$select h from pg_temp.hits(p_genre_ids := array['f9000000-0000-0000-0000-0000000000e1', 'f9000000-0000-0000-0000-0000000000e2']::uuid[]) h$$, + $$values ('f9000000-0000-0000-0000-000000000020'::uuid)$$, + 'KAT-01 Spec-F1: zwei Genres: nur Spiele mit beiden'); +select results_eq($$select h from pg_temp.hits(p_genre_ids := array['f9000000-0000-0000-0000-0000000000e1', 'f9000000-0000-0000-0000-0000000000e1']::uuid[]) h order by 1$$, + $$select h from pg_temp.hits(p_genre_ids := array['f9000000-0000-0000-0000-0000000000e1']::uuid[]) h order by 1$$, + 'KAT-01 Kriterium 4: Duplikate im Array zählen einfach'); +select results_eq($$select h from pg_temp.hits(p_mechanic_ids := array['f9000000-0000-0000-0000-0000000000d2']::uuid[]) h order by 1$$, + $$values ('f9000000-0000-0000-0000-000000000021'::uuid), ('f9000000-0000-0000-0000-000000000025')$$, + 'KAT-01 Kriterium 4: Mechanik allein'); +select results_eq($$select h from pg_temp.hits(p_mechanic_ids := array['f9000000-0000-0000-0000-0000000000d1', 'f9000000-0000-0000-0000-0000000000d2']::uuid[]) h$$, + $$values ('f9000000-0000-0000-0000-000000000021'::uuid)$$, + 'KAT-01 Spec-F1: zwei Mechaniken: nur Spiele mit beiden'); +select results_eq($$select h from pg_temp.hits(p_players := 3::smallint, p_age := 12::smallint, + p_genre_ids := array['f9000000-0000-0000-0000-0000000000e1']::uuid[], p_mechanic_ids := array['f9000000-0000-0000-0000-0000000000d2']::uuid[]) h$$, + $$values ('f9000000-0000-0000-0000-000000000021'::uuid)$$, + 'KAT-01 Kriterium 4: alle Filter zusammen sind UND-verknüpft'); +select results_eq($$select h from pg_temp.hits('qxz filter', p_players := 2::smallint, p_max_minutes := 60::smallint, + p_genre_ids := array['f9000000-0000-0000-0000-0000000000e2']::uuid[]) h order by 1$$, + $$values ('f9000000-0000-0000-0000-000000000020'::uuid), ('f9000000-0000-0000-0000-000000000025')$$, + 'KAT-01 Kriterium 4: Suchbegriff plus Spielerzahl, Dauer und Genre'); +select is((select count(*) from public.search_catalog_games(null, null, null, null, array[gen_random_uuid()])), 0::bigint, + 'KAT-01 Kriterium 4: unbekannte Genre-ID ergibt 0 Treffer'); +select is((select count(*) from public.search_catalog_games(null, null, null, null, null, array[gen_random_uuid()])), 0::bigint, + 'KAT-01 Kriterium 4: unbekannte Mechanik-ID ergibt 0 Treffer'); +select results_eq($$select id, match_rank::int, matched_alt_title, total_count::int from public.search_catalog_games(null, null, null, null, array['f9000000-0000-0000-0000-0000000000e3']::uuid[])$$, + $$values ('f9000000-0000-0000-0000-000000000060'::uuid, null::int, null::text, 2), + ('f9000000-0000-0000-0000-000000000005', null, null, 2)$$, + 'KAT-01 Kriterium 8: ohne Suchbegriff nach Titel sortiert, match_rank und matched_alt_title null'); +select results_eq($$select h from pg_temp.hits() h order by 1$$, + $$select id from public.games where id::text like 'f9000000%' and visibility = 'catalog' and hidden_at is null and merged_into_id is null order by id$$, + 'KAT-01 Kriterium 8: ohne Suchbegriff und ohne Filter erscheinen alle sichtbaren Fixture-Spiele (ohne ausgeblendete, zusammengeführte, private)'); + +-- --------------------------------------------------------------------------------------------- +-- Kriterium 5: Nachladen +-- --------------------------------------------------------------------------------------------- +select results_eq($$select id from public.search_catalog_games('qxz seite', null, null, null, null, null, 15, 0) + union all select id from public.search_catalog_games('qxz seite', null, null, null, null, null, 15, 15) + union all select id from public.search_catalog_games('qxz seite', null, null, null, null, null, 15, 30)$$, + $$select id from public.games where title like 'Qxz Seite %' order by title$$, + 'KAT-01 Kriterium 5: drei Seiten ergeben ohne Überschneidung genau die 40 Treffer in stabiler Reihenfolge'); +select results_eq($$select count(*), count(distinct id), count(distinct total_count), min(total_count) + from (select * from public.search_catalog_games('qxz seite', null, null, null, null, null, 15, 0) + union all select * from public.search_catalog_games('qxz seite', null, null, null, null, null, 15, 15) + union all select * from public.search_catalog_games('qxz seite', null, null, null, null, null, 15, 30)) s$$, + $$values (40::bigint, 40::bigint, 1::bigint, 40::bigint)$$, + 'KAT-01 Kriterium 5: total_count ist in jeder Zeile jeder Seite gleich (40)'); +select is((select count(*) from public.search_catalog_games('qxz seite', null, null, null, null, null, 15, 45)), 0::bigint, + 'KAT-01 Kriterium 5: hinter dem Ende keine Zeilen (hasMore false)'); +select is((select count(*) from public.search_catalog_games('qxz seite')), 30::bigint, 'KAT-01 Kriterium 5: Standard-Seitengröße 30'); +select is((select count(*) from public.search_catalog_games('qxz seite', p_limit := 50)), 40::bigint, 'KAT-01 Kriterium 5: Seitengröße bis 50 erlaubt'); +select results_eq($$select id from public.search_catalog_games('qxz seite', null, null, null, null, null, 10, 0)$$, + $$select id from public.search_catalog_games('qxz seite', null, null, null, null, null, 10, 0)$$, + 'KAT-01 Kriterium 5: wiederholter Aufruf liefert dieselbe Reihenfolge'); + +-- --------------------------------------------------------------------------------------------- +-- KAT-05 Kriterium 4 und 10: ausgeblendet, zusammengeführt, privat, Bild +-- --------------------------------------------------------------------------------------------- +select is((select count(*) from public.search_catalog_games('qxz verborgen')), 0::bigint, 'KAT-05 Kriterium 4: ein ausgeblendetes Spiel erscheint nicht über den Titel'); +select is((select count(*) from public.search_catalog_games('qxz verborgen alt')), 0::bigint, 'KAT-05 Kriterium 4: ... nicht über den alternativen Titel'); +select is((select count(*) from public.search_catalog_games('qxz verschmolzen alt')), 0::bigint, 'KAT-05 Kriterium 4: ein zusammengeführtes Spiel erscheint nicht über seinen alternativen Titel'); +select is((select count(*) from public.search_catalog_games('qxz verschmolzen')), 0::bigint, 'KAT-05 Kriterium 4: ... und nicht über den Titel'); +select is((select count(*) from public.search_catalog_games('qxz privat')), 0::bigint, 'KAT-01 Sicherheit: ein privates Spiel erscheint nie (Titel)'); +select is((select count(*) from public.search_catalog_games('qxz bens alt')), 0::bigint, 'KAT-01 Sicherheit: ... auch nicht über einen alternativen Titel'); +select is((select count(*) from public.search_catalog_games(null, null, null, null, null, null, 50, 0) where id::text in ('f9000000-0000-0000-0000-000000000030', 'f9000000-0000-0000-0000-000000000031', 'f9000000-0000-0000-0000-000000000032', 'f9000000-0000-0000-0000-000000000033')), 0::bigint, + 'KAT-05 Kriterium 4: ohne Suchbegriff fehlen ausgeblendete, zusammengeführte und private Spiele ebenso (erste Seite)'); +select is((select count(*) from pg_temp.hits() h where h in ('f9000000-0000-0000-0000-000000000030', 'f9000000-0000-0000-0000-000000000031', 'f9000000-0000-0000-0000-000000000032', 'f9000000-0000-0000-0000-000000000033')), 0::bigint, + 'KAT-05 Kriterium 4: ohne Suchbegriff fehlen ausgeblendete, zusammengeführte und private Spiele auch beim Durchblättern'); +select results_eq($$select id, image_path from public.search_catalog_games('qxz cover') order by id$$, + $$values ('f9000000-0000-0000-0000-000000000050'::uuid, null::text), ('f9000000-0000-0000-0000-000000000051', 'covers/manuell.png'), + ('f9000000-0000-0000-0000-000000000052', 'covers/eigen.png')$$, + 'KAT-05 Kriterium 10: image_path kommt aus catalog_games (maskiert bei Quelle ohne Erlaubnis, sichtbar bei manueller Pflege und ohne Quelle)'); +select results_eq($$select title, year, min_players, max_players, min_minutes, max_minutes, min_age from public.search_catalog_games('qxz filter 20')$$, + $$values ('Qxz Filter 20'::text, null::smallint, 2::smallint, 4::smallint, 30::smallint, 60::smallint, 8::smallint)$$, + 'KAT-01: die Ausgabe enthält Titel und Kurzangaben'); + +-- --------------------------------------------------------------------------------------------- +-- Eingabegrenzen +-- --------------------------------------------------------------------------------------------- +select throws_ok($$select * from public.search_catalog_games(repeat('a', 101))$$, 'P0001', 'search_query_too_long', 'KAT-01: 101 Zeichen sind zu lang'); +select lives_ok($$select * from public.search_catalog_games(repeat('a', 100))$$, 'KAT-01: 100 Zeichen sind erlaubt'); +select throws_ok($$select * from public.search_catalog_games('aa bb cc dd ee ff gg hh ii jj kk')$$, 'P0001', 'search_query_too_long', 'KAT-01: 11 Wörter sind zu viel'); +select throws_ok($$select * from public.search_catalog_games('a b c d e f g h i j k')$$, 'P0001', 'search_query_too_long', 'KAT-01: 11 Wörter zählen auch, wenn sie nur ein Zeichen haben'); +select lives_ok($$select * from public.search_catalog_games('aa bb cc dd ee ff gg hh ii jj')$$, 'KAT-01: 10 Wörter sind erlaubt'); +select throws_ok($$select * from public.search_catalog_games(null, 0::smallint)$$, 'P0001', 'search_filter_invalid', 'KAT-01: Spielerzahl 0'); +select throws_ok($$select * from public.search_catalog_games(null, 100::smallint)$$, 'P0001', 'search_filter_invalid', 'KAT-01: Spielerzahl 100'); +select lives_ok($$select * from public.search_catalog_games(null, 1::smallint); select * from public.search_catalog_games(null, 99::smallint)$$, 'KAT-01: Spielerzahl 1 und 99 sind erlaubt'); +select throws_ok($$select * from public.search_catalog_games(null, null, 0::smallint)$$, 'P0001', 'search_filter_invalid', 'KAT-01: Dauer 0'); +select throws_ok($$select * from public.search_catalog_games(null, null, 1441::smallint)$$, 'P0001', 'search_filter_invalid', 'KAT-01: Dauer 1441'); +select lives_ok($$select * from public.search_catalog_games(null, null, 1::smallint); select * from public.search_catalog_games(null, null, 1440::smallint)$$, 'KAT-01: Dauer 1 und 1440 sind erlaubt'); +select throws_ok($$select * from public.search_catalog_games(null, null, null, (-1)::smallint)$$, 'P0001', 'search_filter_invalid', 'KAT-01: Alter -1'); +select throws_ok($$select * from public.search_catalog_games(null, null, null, 100::smallint)$$, 'P0001', 'search_filter_invalid', 'KAT-01: Alter 100'); +select lives_ok($$select * from public.search_catalog_games(null, null, null, 0::smallint); select * from public.search_catalog_games(null, null, null, 99::smallint)$$, 'KAT-01: Alter 0 und 99 sind erlaubt'); +select throws_ok($$select * from public.search_catalog_games(null, null, null, null, (select array_agg(gen_random_uuid()) from generate_series(1, 21)))$$, 'P0001', 'search_filter_invalid', 'KAT-01: 21 Genre-IDs'); +select throws_ok($$select * from public.search_catalog_games(null, null, null, null, null, (select array_agg(gen_random_uuid()) from generate_series(1, 21)))$$, 'P0001', 'search_filter_invalid', 'KAT-01: 21 Mechanik-IDs'); +select lives_ok($$select * from public.search_catalog_games(null, null, null, null, (select array_agg(gen_random_uuid()) from generate_series(1, 20)), (select array_agg(gen_random_uuid()) from generate_series(1, 20)))$$, 'KAT-01: je 20 IDs sind erlaubt'); +select throws_ok($$select * from public.search_catalog_games(null, null, null, null, null, null, 0, 0)$$, 'P0001', 'search_paging_invalid', 'KAT-01: Seitengröße 0'); +select throws_ok($$select * from public.search_catalog_games(null, null, null, null, null, null, 51, 0)$$, 'P0001', 'search_paging_invalid', 'KAT-01: Seitengröße 51'); +select throws_ok($$select * from public.search_catalog_games(null, null, null, null, null, null, 30, -1)$$, 'P0001', 'search_paging_invalid', 'KAT-01: Versatz -1'); +select throws_ok($$select * from public.search_catalog_games(null, null, null, null, null, null, 30, 10001)$$, 'P0001', 'search_paging_invalid', 'KAT-01: Versatz 10001'); +select lives_ok($$select * from public.search_catalog_games(null, null, null, null, null, null, 1, 0); select * from public.search_catalog_games(null, null, null, null, null, null, 50, 10000)$$, 'KAT-01: Seitengröße 1 und 50, Versatz 0 und 10000 sind erlaubt'); + +-- --------------------------------------------------------------------------------------------- +-- Tabelle game_alt_titles: Checks, Unique, manual_fields, Kaskade +-- --------------------------------------------------------------------------------------------- +select throws_ok($$insert into public.game_alt_titles (game_id, locale, title) values ('f9000000-0000-0000-0000-000000000013', 'fr', 'Titre')$$, + '23514', null, 'KAT-01 Kriterium 2: Sprache nur de oder en (Check)'); +select throws_ok($$insert into public.game_alt_titles (game_id, locale, title) values ('f9000000-0000-0000-0000-000000000013', 'de', repeat('x', 201))$$, + '23514', null, 'KAT-01 Kriterium 2: Titel höchstens 200 Zeichen (Check)'); +select throws_ok($$insert into public.game_alt_titles (game_id, locale, title) values ('f9000000-0000-0000-0000-000000000013', 'de', '')$$, + '23514', null, 'KAT-01 Kriterium 2: Titel nicht leer (Check)'); +select throws_ok($$insert into public.game_alt_titles (game_id, locale, title) values ('f9000000-0000-0000-0000-000000000013', 'de', '!!! ®')$$, + '23514', null, 'KAT-01 Kriterium 2: ein Titel nur aus Sonderzeichen ist nicht zulässig (title_search nicht null)'); +select lives_ok($$insert into public.game_alt_titles (game_id, locale, title) values ('f9000000-0000-0000-0000-000000000013', 'de', repeat('x', 200))$$, + 'KAT-01 Kriterium 2: 200 Zeichen sind erlaubt'); +select throws_ok($$insert into public.game_alt_titles (game_id, locale, title) values ('f9000000-0000-0000-0000-000000000012', 'de', 'QXZ einzigartig alt!')$$, + '23505', null, 'KAT-01 Kriterium 2: derselbe Titel in derselben Sprache nur einmal, unabhängig von der Schreibweise (Unique)'); +select lives_ok($$insert into public.game_alt_titles (game_id, locale, title) values ('f9000000-0000-0000-0000-000000000013', 'de', 'Qxz Zweitname')$$, + 'KAT-01 Kriterium 2: weiterer Titel in einer Sprache'); +select lives_ok($$insert into public.game_alt_titles (game_id, locale, title) values ('f9000000-0000-0000-0000-000000000013', 'en', 'QXZ zweitname!')$$, + 'KAT-01 Kriterium 2: derselbe Titel in der anderen Sprache ist zulässig'); +select lives_ok($$insert into public.game_alt_titles (game_id, locale, title) values ('f9000000-0000-0000-0000-000000000013', 'de', 'Qxz Dritter Name')$$, + 'KAT-01 Kriterium 2: mehrere Titel je Sprache sind erlaubt'); +select throws_ok($$insert into public.game_alt_titles (game_id, locale, title) values (gen_random_uuid(), 'de', 'Qxz Waise')$$, + '23503', null, 'KAT-01 Kriterium 2: alternative Titel brauchen ein vorhandenes Spiel (Fremdschlüssel)'); +select throws_ok($$insert into public.game_alt_titles (game_id, locale, title, title_search) values ('f9000000-0000-0000-0000-000000000013', 'de', 'Qxz Eigen', 'x')$$, + '428C9', null, 'KAT-01: title_search ist generiert und nicht beschreibbar (auch nicht als postgres)'); +select throws_ok($$update public.games set title_search = 'x' where id = 'f9000000-0000-0000-0000-000000000013'$$, + '428C9', null, 'KAT-01: games.title_search ist nicht beschreibbar'); + +-- manual_fields: Einfügen, Ändern und Löschen markieren alt_titles (nur Katalogspiele) +select ok((select 'alt_titles' = any(manual_fields) from public.games where id = 'f9000000-0000-0000-0000-000000000010'), + 'KAT-01 Kriterium 2: Einfügen alternativer Titel markiert alt_titles in manual_fields (Katalogspiel)'); +select ok(not ((select 'alt_titles' = any(manual_fields) from public.games where id = 'f9000000-0000-0000-0000-000000000040')), + 'KAT-01: ein Spiel ohne alternative Titel ist nicht als alt_titles markiert'); +select is((select manual_fields from public.games where id = 'f9000000-0000-0000-0000-000000000032'), '{}'::text[], + 'KAT-01: alternative Titel eines privaten Spiels markieren nichts'); +insert into public.games (id, title, visibility) values ('f9000000-0000-0000-0000-000000000071', 'Qxz Markierung', 'catalog'); +select ok(not ((select 'alt_titles' = any(manual_fields) from public.games where id = 'f9000000-0000-0000-0000-000000000071')), 'Ausgangslage: Markierungsspiel ohne alt_titles'); +insert into public.game_alt_titles (id, game_id, locale, title) values ('f9000000-0000-0000-0000-0000000000b9', 'f9000000-0000-0000-0000-000000000071', 'de', 'Qxz Mark Alt'); +select ok((select 'alt_titles' = any(manual_fields) from public.games where id = 'f9000000-0000-0000-0000-000000000071'), 'KAT-01: insert markiert alt_titles'); +update public.games set manual_fields = array_remove(manual_fields, 'alt_titles') where id = 'f9000000-0000-0000-0000-000000000071'; +update public.game_alt_titles set title = 'Qxz Mark Alt Zwei' where id = 'f9000000-0000-0000-0000-0000000000b9'; +select ok((select 'alt_titles' = any(manual_fields) from public.games where id = 'f9000000-0000-0000-0000-000000000071'), 'KAT-01: update markiert alt_titles'); +update public.games set manual_fields = array_remove(manual_fields, 'alt_titles') where id = 'f9000000-0000-0000-0000-000000000071'; +delete from public.game_alt_titles where id = 'f9000000-0000-0000-0000-0000000000b9'; +select ok((select 'alt_titles' = any(manual_fields) from public.games where id = 'f9000000-0000-0000-0000-000000000071'), 'KAT-01: delete markiert alt_titles'); + +-- manual_fields-Check: vollständige Liste von main plus alt_titles +select lives_ok($$update public.games set manual_fields = array['title', 'description', 'description_locale', 'year', + 'min_players', 'max_players', 'min_minutes', 'max_minutes', 'min_age', 'image_path', + 'genres', 'mechanics', 'categories', 'publishers', 'alt_titles'] where id = 'f9000000-0000-0000-0000-000000000071'$$, + 'KAT-01: der Check akzeptiert alle 14 bisherigen Schlüssel und alt_titles'); +select throws_ok($$update public.games set manual_fields = array['alt_titles', 'unbekannt'] where id = 'f9000000-0000-0000-0000-000000000071'$$, + '23514', null, 'KAT-01: der Check lehnt unbekannte Schlüssel weiterhin ab'); + +-- Kaskade: Spiel löschen löscht seine alternativen Titel +insert into public.games (id, title, visibility) values ('f9000000-0000-0000-0000-000000000070', 'Qxz Löschtest', 'catalog'); +insert into public.game_alt_titles (game_id, locale, title) values + ('f9000000-0000-0000-0000-000000000070', 'de', 'Qxz Löschtest Alt'), ('f9000000-0000-0000-0000-000000000070', 'en', 'Qxz Delete test'); +delete from public.games where id = 'f9000000-0000-0000-0000-000000000070'; +select is((select count(*) from public.game_alt_titles where game_id = 'f9000000-0000-0000-0000-000000000070'), 0::bigint, + 'KAT-01 Kriterium 2: Spiel löschen löscht seine alternativen Titel'); + +-- updated_at: Trigger setzt ihn bei Änderung (ctid statt now(), die Transaktion hat eine feste Zeit) +select ok(exists (select 1 from pg_trigger where tgrelid = 'public.game_alt_titles'::regclass and tgname = 'set_updated_at'), + 'KAT-01: game_alt_titles setzt updated_at per Trigger'); + +-- --------------------------------------------------------------------------------------------- +-- Rollen: anon, authenticated (Sicherheit Kriterium 2) +-- --------------------------------------------------------------------------------------------- +set local role anon; +select lives_ok($$select * from public.search_catalog_games('qxz zorb')$$, 'KAT-01 Sicherheit Kriterium 2: die Suche als anon läuft ohne Rechtefehler'); +select results_eq($$select id, match_rank::int from public.search_catalog_games('qxz zorb')$$, + $$values ('f9000000-0000-0000-0000-000000000001'::uuid, 0), ('f9000000-0000-0000-0000-000000000002', 1), + ('f9000000-0000-0000-0000-000000000003', 2), ('f9000000-0000-0000-0000-000000000004', 3), + ('f9000000-0000-0000-0000-000000000005', 3), ('f9000000-0000-0000-0000-000000000006', 4)$$, + 'KAT-01 Sicherheit Kriterium 2: anon sieht dieselben Treffer; das private Spiel "QXZ ZORB" eines Nutzers fehlt'); +select results_eq($$select id, matched_alt_title from public.search_catalog_games('qxz zugvogel')$$, + $$values ('f9000000-0000-0000-0000-000000000010'::uuid, 'Qxz Zugvogel'::text)$$, 'KAT-01 Kriterium 2: alternative Titel funktionieren für anon'); +select results_eq($$select id, publisher_names from public.search_catalog_games('qxz doppelspiel')$$, + $$values ('f9000000-0000-0000-0000-000000000040'::uuid, array['Qxz Hersteller Eins', 'Qxz Hersteller Zwei']::text[])$$, + 'KAT-01 Kriterium 3: Verlage funktionieren für anon'); +select results_eq($$select id, image_path from public.search_catalog_games('qxz cover') order by id$$, + $$values ('f9000000-0000-0000-0000-000000000050'::uuid, null::text), ('f9000000-0000-0000-0000-000000000051', 'covers/manuell.png'), + ('f9000000-0000-0000-0000-000000000052', 'covers/eigen.png')$$, 'KAT-05 Kriterium 10: Maskierung des Bildes gilt für anon'); +select is((select count(*) from public.search_catalog_games(null, null, null, null, array['f9000000-0000-0000-0000-0000000000e1', 'f9000000-0000-0000-0000-0000000000e2']::uuid[])), 1::bigint, + 'KAT-01 Kriterium 4: Filter wirken auch für anon'); +select is((select count(*) from public.search_catalog_games('qxz seite', p_limit := 50)), 40::bigint, 'KAT-01 Kriterium 5: Nachladen für anon'); +select is((select count(*) from public.game_alt_titles where game_id = 'f9000000-0000-0000-0000-000000000010'), 2::bigint, + 'KAT-01 Kriterium 2: anon liest alternative Titel von Katalogspielen'); +select is((select count(*) from public.game_alt_titles where game_id = 'f9000000-0000-0000-0000-000000000032'), 0::bigint, + 'KAT-01 Kriterium 2: anon liest keine alternativen Titel eines privaten Spiels'); +select throws_ok($$insert into public.game_alt_titles (game_id, locale, title) values ('f9000000-0000-0000-0000-000000000013', 'de', 'Qxz Anon')$$, + '42501', null, 'KAT-01 Kriterium 2: anon kann keine alternativen Titel anlegen'); +select throws_ok($$update public.game_alt_titles set title = 'Qxz Anon' where id = 'f9000000-0000-0000-0000-0000000000a1'$$, + '42501', null, 'KAT-01 Kriterium 2: anon kann keine alternativen Titel ändern'); +select throws_ok($$delete from public.game_alt_titles where id = 'f9000000-0000-0000-0000-0000000000a1'$$, + '42501', null, 'KAT-01 Kriterium 2: anon kann keine alternativen Titel löschen'); +reset role; + +-- Anna (authentifiziert): legt ein privates Spiel an und ändert es (title_search wird mit den Rechten des Nutzers berechnet) +select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true); +set local role authenticated; +select lives_ok($$insert into public.games (owner_id, title, visibility) values ('11111111-1111-1111-1111-111111111111', 'Qxz Mölkky ®', 'private')$$, + 'KAT-01 Sicherheit: authenticated legt ein privates Spiel an (search_normalize ausführbar, kein 42501)'); +select is((select title_search from public.games where title = 'Qxz Mölkky ®'), 'qxz molkky', + 'KAT-01 Sicherheit: title_search ist nach dem Anlegen gefüllt'); +select is((select count(*) from public.search_catalog_games('qxz molkky')), 0::bigint, 'KAT-01 Sicherheit: Anna findet das gerade angelegte private Spiel nicht über die Katalogsuche'); +select lives_ok($$update public.games set title = 'Spiel™ ½' where title = 'Qxz Mölkky ®'$$, + 'KAT-01 Sicherheit: authenticated ändert den Titel seines privaten Spiels (kein 42501)'); +select is((select title_search from public.games where title = 'Spiel™ ½'), 'spiel 1 2', + 'KAT-01 Sicherheit: title_search ist nach dem Ändern aktualisiert'); +select throws_ok($$insert into public.games (owner_id, title, visibility, title_search) values ('11111111-1111-1111-1111-111111111111', 'Qxz X', 'private', 'x')$$, + '428C9', null, 'KAT-01: authenticated kann title_search nicht setzen (generierte Spalte)'); +select ok(not has_column_privilege('authenticated', 'public.games', 'title_search', 'insert') + and not has_column_privilege('authenticated', 'public.games', 'title_search', 'update') + and not has_column_privilege('anon', 'public.games', 'title_search', 'update') + and has_column_privilege('anon', 'public.games', 'title_search', 'select'), + 'KAT-01: title_search ist für API-Rollen nur lesbar (Spaltenrechte)'); +select lives_ok($$select * from public.search_catalog_games('qxz zorb')$$, 'KAT-01: die Suche als authenticated läuft'); +select results_eq($$select id from public.search_catalog_games('qxz zorb')$$, + $$values ('f9000000-0000-0000-0000-000000000001'::uuid), ('f9000000-0000-0000-0000-000000000002'), ('f9000000-0000-0000-0000-000000000003'), + ('f9000000-0000-0000-0000-000000000004'), ('f9000000-0000-0000-0000-000000000005'), ('f9000000-0000-0000-0000-000000000006')$$, + 'KAT-01 Sicherheit: Annas Suche zeigt nur Katalogspiele (Bens privates "QXZ ZORB" fehlt)'); +select is((select count(*) from public.search_catalog_games('qxz privat')), 0::bigint, 'KAT-01 Sicherheit: Anna findet ihr eigenes privates Spiel nicht über die Katalogsuche'); +select is((select count(*) from public.game_alt_titles where game_id = 'f9000000-0000-0000-0000-000000000032'), 1::bigint, 'KAT-01 Kriterium 2: Anna liest die alternativen Titel ihres privaten Spiels'); +select is((select count(*) from public.game_alt_titles where game_id = 'f9000000-0000-0000-0000-000000000033'), 0::bigint, 'KAT-01 Kriterium 2: Anna liest nicht die alternativen Titel von Bens privatem Spiel'); +select throws_ok($$insert into public.game_alt_titles (game_id, locale, title) values ('f9000000-0000-0000-0000-000000000032', 'de', 'Qxz Anna Neu')$$, + '42501', null, 'KAT-01 Kriterium 2: auch am eigenen privaten Spiel kann Anna keine alternativen Titel anlegen'); +select throws_ok($$update public.game_alt_titles set title = 'Qxz Anna' where id = 'f9000000-0000-0000-0000-0000000000a8'$$, + '42501', null, 'KAT-01 Kriterium 2: ... nicht ändern'); +select throws_ok($$delete from public.game_alt_titles where id = 'f9000000-0000-0000-0000-0000000000a8'$$, + '42501', null, 'KAT-01 Kriterium 2: ... nicht löschen'); +reset role; + +-- Ben: die eigene Suche zeigt Annas privates Spiel nicht und sieht keine fremden alternativen Titel +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_alt_titles where game_id = 'f9000000-0000-0000-0000-000000000032'), 0::bigint, 'KAT-01 Kriterium 2: Ben liest keine alternativen Titel von Annas privatem Spiel'); +select is((select count(*) from public.search_catalog_games('qxz privat')), 0::bigint, 'KAT-01 Sicherheit: Ben findet Annas privates Spiel nicht'); +select is((select count(*) from public.search_catalog_games('qxz zorb')), 6::bigint, 'KAT-01 Sicherheit: Ben findet sein eigenes privates "QXZ ZORB" nicht, nur die sechs Katalogspiele'); +reset role; + +-- --------------------------------------------------------------------------------------------- +-- Startbestand-Werkzeug: alternative Titel, Idempotenz (Spec 4.3, Krit. 2) +-- Testspiel B hat laut testdata/startbestand.csv zwei alternative Titel, Testspiel A keinen. +-- --------------------------------------------------------------------------------------------- +\ir ../support/vocabulary_fixture.inc +\ir ../support/startbestand_fixture.inc + +select results_eq($$select a.locale, a.title from public.game_alt_titles a join public.games g on g.id = a.game_id + where g.title = 'Testspiel B' order by a.locale$$, + $$values ('de'::text, 'Testspiel Bravo'::text), ('en', 'Test Game Bravo')$$, + 'KAT-01 Kriterium 2: Testspiel B bekommt aus der CSV einen deutschen und einen englischen alternativen Titel'); +select is((select count(*) from public.game_alt_titles a join public.games g on g.id = a.game_id where g.title = 'Testspiel A'), 0::bigint, + 'KAT-01 Kriterium 2: Testspiel A hat keine alternativen Titel'); +select ok((select 'alt_titles' = any(manual_fields) from public.games where title = 'Testspiel B'), + 'KAT-01 Kriterium 2: das Einspielen markiert alt_titles in manual_fields (SB8)'); +select results_eq($$select id, match_rank::int, matched_alt_title from public.search_catalog_games('test game bravo') + where id = extensions.uuid_generate_v5('6f1d9a52-3c4e-4b8a-9f27-5d0e8c71a3b4', 'game:testspiel-b')$$, + $$values (extensions.uuid_generate_v5('6f1d9a52-3c4e-4b8a-9f27-5d0e8c71a3b4', 'game:testspiel-b'), 0, 'Test Game Bravo'::text)$$, + 'KAT-01 Kriterium 2: ein eingespielter alternativer Titel ist über die Suche auffindbar'); + +create temp table snap_alt on commit drop as + select a.id, a.game_id, a.locale, a.title, a.ctid::text as c, a.updated_at as u from public.game_alt_titles a + where a.game_id in (select id from public.games where title like 'Testspiel %'); +\ir ../support/startbestand_fixture.inc +select is((select count(*) from snap_alt), 2::bigint, 'Schnappschuss der alternativen Titel enthält die zwei Zeilen von Testspiel B'); +select is_empty($$select 1 from snap_alt s where not exists ( + select 1 from public.game_alt_titles n where n.id = s.id and n.game_id = s.game_id and n.locale = s.locale + and n.title = s.title and n.ctid::text = s.c and n.updated_at = s.u)$$, + 'KAT-01 Kriterium 2: erneutes Einspielen ändert keine Zeile von game_alt_titles (ctid und updated_at gleich)'); +select is((select count(*) from public.game_alt_titles a join public.games g on g.id = a.game_id where g.title like 'Testspiel %'), 2::bigint, + 'KAT-01 Kriterium 2: erneutes Einspielen legt keine Duplikate an'); + +-- Ein in der CSV nicht (mehr) stehender Titel wird gelöscht, auch bei Testspiel A ohne alt_titles; eine geänderte +-- Schreibweise stellt nur den Titel wieder her (gleiche ID), unveränderte Zeilen bleiben unberührt. +insert into public.game_alt_titles (game_id, locale, title) + select id, 'en', 'Entfernter Titel' from public.games where title = 'Testspiel B'; +insert into public.game_alt_titles (game_id, locale, title) + select id, 'de', 'Fremder Titel' from public.games where title = 'Testspiel A'; +update public.game_alt_titles set title = 'TESTSPIEL BRAVO' where locale = 'de' and game_id = (select id from public.games where title = 'Testspiel B'); +create temp table snap_alt2 on commit drop as + select a.id, a.locale, a.title, a.ctid::text as c from public.game_alt_titles a where a.game_id in (select id from public.games where title like 'Testspiel %'); +\ir ../support/startbestand_fixture.inc +select results_eq($$select a.locale, a.title from public.game_alt_titles a where a.game_id in (select id from public.games where title like 'Testspiel %') order by a.locale$$, + $$values ('de'::text, 'Testspiel Bravo'::text), ('en', 'Test Game Bravo')$$, + 'KAT-01 Kriterium 2: ein in der CSV entfernter alternativer Titel wird gelöscht (auch bei einem Spiel ohne alt_titles); geänderte Schreibweise wird wiederhergestellt'); +select is((select count(*) from public.game_alt_titles a join snap_alt2 s on s.id = a.id and s.title = 'TESTSPIEL BRAVO'), 1::bigint, + 'KAT-01 Kriterium 2: die geänderte Schreibweise behält ihre ID (nur title aktualisiert)'); +select is((select count(*) from public.game_alt_titles a join snap_alt2 s on s.id = a.id and s.locale = 'en' and s.title = 'Test Game Bravo' and s.c = a.ctid::text), 1::bigint, + 'KAT-01 Kriterium 2: die unveränderte Zeile bleibt unberührt (gleiche ctid)'); +select is((select count(*) from public.game_alt_titles a join snap_alt2 s on s.id = a.id and s.locale = 'de' and s.c <> a.ctid::text), 1::bigint, + 'KAT-01 Kriterium 2: nur die geänderte Zeile wurde geschrieben'); + +-- --------------------------------------------------------------------------------------------- +-- SQL-Bausteine aus docs/katalog-pflege.md (unverändert, ohne begin/commit) +-- Abschnitt 3.1: Alternative Titel pflegen (Spiel 7a1b...0001 "Beispielspiel Runbook") +-- --------------------------------------------------------------------------------------------- +insert into public.games (id, title, year, min_players, max_players, min_minutes, max_minutes, min_age, description, description_locale, visibility) +values ('7a1b2c3d-0000-4000-8000-000000000001', 'Beispielspiel Runbook', 2020, 2, 4, 30, 60, 8, 'Eigener Kurztext.', 'de', 'catalog'); + +insert into public.game_alt_titles (game_id, locale, title) +values ('7a1b2c3d-0000-4000-8000-000000000001', 'de', 'Beispielspiel Deutsch'), + ('7a1b2c3d-0000-4000-8000-000000000001', 'en', 'Example game') +on conflict (game_id, locale, title_search) do update set title = excluded.title +where public.game_alt_titles.title is distinct from excluded.title; + +select results_eq($$select locale, title from public.game_alt_titles +where game_id = '7a1b2c3d-0000-4000-8000-000000000001' order by locale, title$$, + $$values ('de'::text, 'Beispielspiel Deutsch'::text), ('en', 'Example game')$$, + 'Runbook 3.1: Alternative Titel anlegen'); +insert into public.game_alt_titles (game_id, locale, title) +values ('7a1b2c3d-0000-4000-8000-000000000001', 'de', 'BEISPIELSPIEL DEUTSCH'), + ('7a1b2c3d-0000-4000-8000-000000000001', 'en', 'Example game') +on conflict (game_id, locale, title_search) do update set title = excluded.title +where public.game_alt_titles.title is distinct from excluded.title; +select results_eq($$select locale, title from public.game_alt_titles +where game_id = '7a1b2c3d-0000-4000-8000-000000000001' order by locale, title$$, + $$values ('de'::text, 'BEISPIELSPIEL DEUTSCH'::text), ('en', 'Example game')$$, + 'Runbook 3.1: derselbe Baustein mit geänderter Schreibweise aktualisiert den Titel statt einen zweiten anzulegen'); +select results_eq($$select id, match_rank::int, matched_alt_title from public.search_catalog_games('example game')$$, + $$values ('7a1b2c3d-0000-4000-8000-000000000001'::uuid, 0, 'Example game'::text)$$, + 'Runbook 3.1: der angelegte Titel ist sofort über die Suche auffindbar'); + +delete from public.game_alt_titles +where game_id = '7a1b2c3d-0000-4000-8000-000000000001' and locale = 'en' and title = 'Example game'; +select results_eq($$select locale from public.game_alt_titles where game_id = '7a1b2c3d-0000-4000-8000-000000000001'$$, + $$values ('de'::text)$$, 'Runbook 3.1: Alternativen Titel löschen'); + +-- Abschnitt 5, Schritt 2b: alternative Titel der Quelle am Ziel ergänzen, danach zusammenführen +insert into public.games (id, title, year, min_players, max_players, min_minutes, max_minutes, min_age, description, description_locale, visibility) values + ('7a1b2c3d-0000-4000-8000-000000000002', 'Duplikat Runbook', 2020, 2, 4, 30, 60, 8, 'Eigener Kurztext.', 'de', 'catalog'), + ('7a1b2c3d-0000-4000-8000-000000000003', 'Ziel Runbook', 2020, 2, 4, 30, 60, 8, 'Eigener Kurztext.', 'de', 'catalog'); +insert into public.game_alt_titles (game_id, locale, title) values + ('7a1b2c3d-0000-4000-8000-000000000002', 'de', 'Runbook Quellname'), + ('7a1b2c3d-0000-4000-8000-000000000002', 'en', 'Runbook source name'), + ('7a1b2c3d-0000-4000-8000-000000000003', 'en', 'RUNBOOK SOURCE NAME'); + +select locale, title from public.game_alt_titles +where game_id = '7a1b2c3d-0000-4000-8000-000000000002' order by locale, title; + +insert into public.game_alt_titles (game_id, locale, title) +select '7a1b2c3d-0000-4000-8000-000000000003', a.locale, a.title +from public.game_alt_titles a +where a.game_id = '7a1b2c3d-0000-4000-8000-000000000002' +on conflict (game_id, locale, title_search) do nothing; + +select results_eq($$select locale, title from public.game_alt_titles where game_id = '7a1b2c3d-0000-4000-8000-000000000003' order by locale, title$$, + $$values ('de'::text, 'Runbook Quellname'::text), ('en', 'RUNBOOK SOURCE NAME')$$, + 'Runbook 5 Schritt 2b: Titel der Quelle werden am Ziel ergänzt, vorhandene (auch in anderer Schreibweise) bleiben unverändert'); +select results_eq($$select id from public.search_catalog_games('runbook quellname')$$, + $$values ('7a1b2c3d-0000-4000-8000-000000000002'::uuid), ('7a1b2c3d-0000-4000-8000-000000000003')$$, + 'Runbook 5 Schritt 2b: das Ziel ist jetzt über den Titel der Quelle auffindbar (vor dem Zusammenführen zusammen mit der Quelle)'); + +select public.admin_merge_games('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003'); + +select is((select count(*) from public.game_alt_titles where game_id = '7a1b2c3d-0000-4000-8000-000000000002'), 2::bigint, + 'Spec-F6 A: nach dem Zusammenführen bleiben die alternativen Titel am ausgeblendeten Quell-Eintrag'); +select is((select count(*) from public.game_alt_titles where game_id = '7a1b2c3d-0000-4000-8000-000000000003'), 2::bigint, + 'Spec-F6 A: das Ziel behält seine alternativen Titel'); +select results_eq($$select id from public.search_catalog_games('runbook quellname')$$, + $$values ('7a1b2c3d-0000-4000-8000-000000000003'::uuid)$$, 'Spec-F6 A: nach dem Zusammenführen findet die Suche nur noch das Ziel, nie die Quelle'); +select is((select count(*) from public.search_catalog_games('duplikat runbook')), 0::bigint, 'KAT-05 Kriterium 4: die zusammengeführte Quelle erscheint nicht mehr'); + +-- --------------------------------------------------------------------------------------------- +-- Kaskade beim Konto-Löschen (QUE-04): alternative Titel privater Spiele verschwinden mit dem Konto +-- --------------------------------------------------------------------------------------------- +select is((select count(*) from public.game_alt_titles where game_id = 'f9000000-0000-0000-0000-000000000033'), 1::bigint, 'Ausgangslage: Bens privates Spiel hat einen alternativen Titel'); +delete from auth.users where id = '22222222-2222-2222-2222-222222222222'; +select is((select count(*) from public.game_alt_titles where game_id = 'f9000000-0000-0000-0000-000000000033'), 0::bigint, + 'KAT-01: Konto löschen löscht auch die alternativen Titel seiner privaten Spiele'); + +-- --------------------------------------------------------------------------------------------- +-- Abschnitt 8 des Runbooks: Normalisierung nach einem Image-Upgrade prüfen (R6) +-- --------------------------------------------------------------------------------------------- +select results_eq($$select 'games' as tabelle, count(*) as abweichend from public.games + where title_search is distinct from public.search_normalize(title) +union all select 'game_alt_titles', count(*) from public.game_alt_titles + where title_search is distinct from public.search_normalize(title) +union all select 'publishers', count(*) from public.publishers + where name_search is distinct from public.search_normalize(name)$$, + $$values ('games'::text, 0::bigint), ('game_alt_titles', 0), ('publishers', 0)$$, + 'Runbook 8: die Prüfabfrage meldet bei unverändertem Stand überall 0 Abweichungen'); + +-- Drift simulieren: Die Funktion wird geändert, die gespeicherten Spalten bleiben (wie nach einem Image-Upgrade) +create or replace function public.search_normalize(p text) returns text language sql immutable strict parallel safe set search_path = '' + as $$ select upper(p) $$; +select results_eq($$select 'games' as tabelle, count(*) > 0 as abweichend from public.games + where title_search is distinct from public.search_normalize(title) +union all select 'game_alt_titles', count(*) > 0 from public.game_alt_titles + where title_search is distinct from public.search_normalize(title) +union all select 'publishers', count(*) > 0 from public.publishers + where name_search is distinct from public.search_normalize(name)$$, + $$values ('games'::text, true), ('game_alt_titles', true), ('publishers', true)$$, + 'Runbook 8: die Prüfabfrage erkennt eine geänderte Normalisierung in allen drei Tabellen'); + +select * from finish(); +rollback;