feat(db): catalog search with search_normalize, alt titles and search_catalog_games RPC (KAT-01, 3a)

Adds pg_trgm/unaccent, search_normalize, generated title_search/name_search
columns, game_alt_titles (read-only for API roles), manual_fields key
alt_titles and the RPC search_catalog_games. pgTAP 09_catalog_search covers
rights, normalization, relevance, filters, paging and the runbook snippets.

Refs #3

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
cschlaefke 2026-11-04 01:12:25 +01:00
parent 9a63985d74
commit 3bb1c28831
6 changed files with 1082 additions and 3 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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