Shuffle/supabase/tests/database/09_catalog_search.test.sql
cschlaefke 683b5c830e perf(db): set-based rank 4 and filters in search_catalog_games, harden tests (KAT-01, 3a)
Rank 4 and the genre/mechanic filters ran a correlated subplan per game
(13 s at 10 000 games). Now: one set-based word coverage, filters as one hashed
IN set, 'like all' patterns, similarity only for hits. Adds pgTAP cases found
by mutation testing (paging by relevance, alt title choice, duplicate words,
mechanic duplicates) and a Deno case for mark characters.

Refs #3

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 01:40:43 +01:00

812 lines
76 KiB
PL/PgSQL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- 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(211);
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'),
('f9000000-0000-0000-0000-0000000000e4', 'Qxz Genre R', 'Qxz genre R');
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-0000000000b2', 'Qxz Hersteller Eins'),
('f9000000-0000-0000-0000-0000000000b1', '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'),
-- Sortierung ist binär (collate "C"): "qxz b" kommt vor "qxzab". Hinweis: Mit ICU-Sortierung (wie lokal) gilt das auch
-- ohne collate "C"; der Test ist dort nicht mutationsempfindlich und schützt vor libc-Sortierungen, die Leerzeichen ignorieren.
('f9000000-0000-0000-0000-000000000081', 'Qxzab', 'catalog'),
('f9000000-0000-0000-0000-000000000095', 'Qxz Zwilling', 'catalog'),
('f9000000-0000-0000-0000-000000000093', 'Qxz Zwilling', 'catalog'),
('f9000000-0000-0000-0000-000000000091', 'Qxz Zwilling', 'catalog'),
('f9000000-0000-0000-0000-000000000094', 'Qxz Zwilling', 'catalog'),
('f9000000-0000-0000-0000-000000000092', 'Qxz Zwilling', 'catalog'),
('f9000000-0000-0000-0000-000000000090', 'Qxz Zwilling', 'catalog'),
('f9000000-0000-0000-0000-000000000017', 'Qxz Wanderer', 'catalog'),
('f9000000-0000-0000-0000-000000000080', 'Qxz B', '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'),
('f9000000-0000-0000-0000-000000000080', 'f9000000-0000-0000-0000-0000000000e4'),
('f9000000-0000-0000-0000-000000000081', 'f9000000-0000-0000-0000-0000000000e4');
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-0000000000b2'),
('f9000000-0000-0000-0000-000000000040', 'f9000000-0000-0000-0000-0000000000b1'),
('f9000000-0000-0000-0000-000000000040', 'f9000000-0000-0000-0000-0000000000b2'),
('f9000000-0000-0000-0000-000000000041', 'f9000000-0000-0000-0000-0000000000b1'),
('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-0000000000b2'),
('f9000000-0000-0000-0000-000000000033', 'f9000000-0000-0000-0000-0000000000b2');
-- 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'),
-- drei Treffer-Titel für "qxz nomad": Stufe 2, Stufe 1 (länger, weniger ähnlich), Stufe 1 (kürzer, ähnlicher)
('f9000000-0000-0000-0000-0000000000c1', 'f9000000-0000-0000-0000-000000000017', 'de', 'Stern Qxz Nomad'),
('f9000000-0000-0000-0000-0000000000c2', 'f9000000-0000-0000-0000-000000000017', 'en', 'Qxz Nomad Sternenwanderer'),
('f9000000-0000-0000-0000-0000000000c3', 'f9000000-0000-0000-0000-000000000017', 'de', 'Qxz Nomadisch');
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-0000000000b1'), '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-0000000000b1';
select is((select name_search from public.publishers where id = 'f9000000-0000-0000-0000-0000000000b1'), '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-0000000000b1';
-- 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 results_eq($$select id from public.search_catalog_games('qxz zorb', null, null, null, null, null, 4, 0)$$,
$$values ('f9000000-0000-0000-0000-000000000001'::uuid), ('f9000000-0000-0000-0000-000000000002'), ('f9000000-0000-0000-0000-000000000003'), ('f9000000-0000-0000-0000-000000000004')$$,
'KAT-01 Kriterium 5: die erste Seite enthält die relevantesten Treffer (Stufe, dann Ähnlichkeit), nicht die ersten nach Titel');
select results_eq($$select id from public.search_catalog_games('qxz zorb', null, null, null, null, null, 2, 0)
union all select id from public.search_catalog_games('qxz zorb', null, null, null, null, null, 2, 2)
union all select id from public.search_catalog_games('qxz zorb', null, null, null, null, null, 2, 4)$$,
$$select id from public.search_catalog_games('qxz zorb')$$,
'KAT-01 Kriterium 5: Seiten zu je zwei ergeben dieselbe Reihenfolge wie die ganze Liste');
select results_eq($$select id from public.search_catalog_games('qxz twin', null, null, null, null, null, 1, 0)
union all select id from public.search_catalog_games('qxz twin', null, null, null, null, null, 1, 1)$$,
$$values ('f9000000-0000-0000-0000-000000000007'::uuid), ('f9000000-0000-0000-0000-000000000008')$$,
'KAT-01 Kriterium 5: bei völligem Gleichstand entscheidet die ID auch über die Seitengrenze hinweg');
select results_eq($$select id from public.search_catalog_games('qxz zwilling', null, null, null, null, null, 3, 0)
union all select id from public.search_catalog_games('qxz zwilling', null, null, null, null, null, 3, 3)$$,
$$select id from public.games where title = 'Qxz Zwilling' order by id$$,
'KAT-01 Kriterium 5: sechs gleich gute Treffer erscheinen seitenübergreifend in ID-Reihenfolge');
select results_eq($$select id, match_rank::int, matched_alt_title from public.search_catalog_games('qxz nomad')$$,
$$values ('f9000000-0000-0000-0000-000000000017'::uuid, 1, 'Qxz Nomadisch'::text)$$,
'KAT-01 Kriterium 2: von mehreren treffenden alternativen Titeln gewinnt die beste Stufe, dann die größte Ähnlichkeit');
select results_eq($$select id, match_rank::int, matched_alt_title from public.search_catalog_games('qxz einzig')$$,
$$values ('f9000000-0000-0000-0000-000000000012'::uuid, 1, null::text)$$,
'KAT-01 Kriterium 2: bei gleicher Stufe von Haupttitel und alternativem Titel gilt der Haupttitel (matched_alt_title null)');
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 zorb eins')$$,
$$values ('f9000000-0000-0000-0000-000000000001'::uuid, 4)$$,
'KAT-01 Kriterium 3: ein doppelt eingegebenes Wort zählt einfach (Verlagstreffer bleibt auffindbar)');
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-0000000000d2', 'f9000000-0000-0000-0000-0000000000d2']::uuid[]) h order by 1$$,
$$select h from pg_temp.hits(p_mechanic_ids := array['f9000000-0000-0000-0000-0000000000d2']::uuid[]) h order by 1$$,
'KAT-01 Kriterium 4: Duplikate im Mechanik-Array zählen einfach');
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 id from public.search_catalog_games(null, null, null, null, array['f9000000-0000-0000-0000-0000000000e3']::uuid[], null, 1, 0)
union all select id from public.search_catalog_games(null, null, null, null, array['f9000000-0000-0000-0000-0000000000e3']::uuid[], null, 1, 1)$$,
$$values ('f9000000-0000-0000-0000-000000000060'::uuid), ('f9000000-0000-0000-0000-000000000005')$$,
'KAT-01 Kriterium 5: ohne Suchbegriff bestimmt der Titel (nicht die ID) auch die Seitengrenze');
select results_eq($$select id from public.search_catalog_games(null, null, null, null, array['f9000000-0000-0000-0000-0000000000e4']::uuid[])$$,
$$values ('f9000000-0000-0000-0000-000000000080'::uuid), ('f9000000-0000-0000-0000-000000000081')$$,
'KAT-01 Kriterium 8: ohne Suchbegriff wird binär nach dem normalisierten Titel sortiert (Leerzeichen vor Buchstaben)');
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;