-- KAT-05 (PR 2b): Zusammenführen. repoint_game_references, admin_merge_games, Rechte, -- Fremdschlüssel-Wächter und die SQL-Bausteine aus docs/katalog-pflege.md, Abschnitt 5 -- (unverändert, ohne begin/commit; Auswahl immer per id). -- Läuft auch mit eingespieltem Startbestand: eigene Fixtures mit festen IDs (Präfix f7/7a1b), gezählt wird nur darüber. -- Hinweis für 2c: Der Fremdschlüssel-Wächter am Ende muss um game_reports.target_game_id (behandelt) und -- game_reports.provisional_game_id (bewusst ausgelassen) erweitert werden, sonst wird er rot. begin; select plan(89); 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"}'), ('33333333-3333-3333-3333-333333333333', 'cara@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Cara"}'), ('44444444-4444-4444-4444-444444444444', 'dora@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Dora"}'); -- --------------------------------------------------------------------------------------------- -- Rechte (KAT-05 Kriterium 9) -- --------------------------------------------------------------------------------------------- select is_empty($$select p.proname, r from pg_proc p join pg_namespace n on n.oid = p.pronamespace, unnest(array['anon', 'authenticated', 'service_role']) as r where n.nspname = 'public' and p.proname in ('repoint_game_references', 'admin_merge_games') and has_function_privilege(r, p.oid, 'execute')$$, 'KAT-05 Kriterium 9: repoint_game_references und admin_merge_games sind für anon, authenticated und service_role nicht ausführbar'); select is((select count(*) from pg_proc p join pg_namespace n on n.oid = p.pronamespace where n.nspname = 'public' and p.proname in ('repoint_game_references', 'admin_merge_games') and not p.prosecdef and p.proconfig = array['search_path=""']), 2::bigint, 'Beide Funktionen sind security invoker mit leerem search_path'); -- Aufruf wirklich versuchen, auch mit Admin-Claims im JWT (E10: kein is_admin()-Pfad) select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated","app_metadata":{"role":"admin"}}', true); set local role authenticated; select throws_ok($$select public.admin_merge_games(gen_random_uuid(), gen_random_uuid())$$, '42501', null, 'KAT-05 Kriterium 9: authenticated (auch mit app_metadata.role = admin) kann admin_merge_games nicht aufrufen'); select throws_ok($$select public.repoint_game_references(gen_random_uuid(), gen_random_uuid())$$, '42501', null, 'KAT-05 Kriterium 9: authenticated kann repoint_game_references nicht aufrufen'); reset role; set local role anon; select throws_ok($$select public.admin_merge_games(gen_random_uuid(), gen_random_uuid())$$, '42501', null, 'KAT-05 Kriterium 9: anon kann admin_merge_games nicht aufrufen'); reset role; set local role service_role; select throws_ok($$select public.admin_merge_games(gen_random_uuid(), gen_random_uuid())$$, '42501', null, 'KAT-05 Kriterium 9: service_role kann admin_merge_games nicht aufrufen'); select throws_ok($$select public.repoint_game_references(gen_random_uuid(), gen_random_uuid())$$, '42501', null, 'KAT-05 Kriterium 9: service_role kann repoint_game_references nicht aufrufen'); reset role; -- Die Rollenprüfung im Funktionskörper: eine Rolle MIT execute-Recht, aber ohne postgres/supabase_admin create role merge_tester nologin; grant merge_tester to postgres with set true; grant usage on schema public to merge_tester; grant execute on function public.admin_merge_games(uuid, uuid) to merge_tester; set local role merge_tester; do $$ begin perform public.admin_merge_games('f7000000-0000-0000-0000-000000000001', 'f7000000-0000-0000-0000-000000000002'); perform set_config('t.merge_err', 'kein Fehler', true); exception when others then perform set_config('t.merge_err', sqlstate || ':' || sqlerrm, true); end $$; reset role; select is(current_setting('t.merge_err'), 'P0001:admin_only', 'KAT-05 Kriterium 9: admin_merge_games prüft die Datenbankrolle (admin_only)'); -- --------------------------------------------------------------------------------------------- -- Fehlerfälle von admin_merge_games -- --------------------------------------------------------------------------------------------- insert into public.games (id, title, visibility) values ('f7000000-0000-0000-0000-000000000001', 'Fehler Eins', 'catalog'), ('f7000000-0000-0000-0000-000000000002', 'Fehler Zwei', 'catalog'); insert into public.games (id, title, visibility, hidden_at) values ('f7000000-0000-0000-0000-000000000003', 'Fehler Versteckt', 'catalog', '2020-01-01'); insert into public.games (id, title, visibility, hidden_at, merged_into_id) values ('f7000000-0000-0000-0000-000000000004', 'Fehler Zusammengeführt', 'catalog', '2020-01-01', 'f7000000-0000-0000-0000-000000000001'); insert into public.games (id, title, visibility, owner_id) values ('f7000000-0000-0000-0000-0000000000a1', 'Fehler Privat', 'private', '11111111-1111-1111-1111-111111111111'); select throws_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-000000000001', 'f7000000-0000-0000-0000-000000000001')$$, 'P0001', 'merge_same_game', 'KAT-05 Kriterium 4: Quelle gleich Ziel wird abgelehnt (merge_same_game)'); select throws_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-0000000000ff', 'f7000000-0000-0000-0000-000000000001')$$, 'P0001', 'merge_game_not_found', 'KAT-05 Kriterium 4: unbekannte Quelle (merge_game_not_found)'); select throws_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-000000000001', 'f7000000-0000-0000-0000-0000000000ff')$$, 'P0001', 'merge_game_not_found', 'KAT-05 Kriterium 4: unbekanntes Ziel (merge_game_not_found)'); select throws_ok($$select public.admin_merge_games(null, 'f7000000-0000-0000-0000-000000000001')$$, 'P0001', 'merge_game_not_found', 'KAT-05 Kriterium 4: fehlende Quelle (null) ist merge_game_not_found'); select throws_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-0000000000a1', 'f7000000-0000-0000-0000-000000000001')$$, 'P0001', 'merge_not_catalog', 'KAT-05 Kriterium 4: private Quelle wird abgelehnt (merge_not_catalog)'); select throws_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-000000000001', 'f7000000-0000-0000-0000-0000000000a1')$$, 'P0001', 'merge_not_catalog', 'KAT-05 Kriterium 4: privates Ziel wird abgelehnt (merge_not_catalog)'); select throws_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-000000000001', 'f7000000-0000-0000-0000-000000000003')$$, 'P0001', 'merge_target_not_listed', 'KAT-05 Kriterium 4: ausgeblendetes Ziel wird abgelehnt (merge_target_not_listed)'); select throws_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-000000000002', 'f7000000-0000-0000-0000-000000000004')$$, 'P0001', 'merge_target_not_listed', 'KAT-05 Kriterium 4: zusammengeführtes Ziel wird abgelehnt (merge_target_not_listed)'); select throws_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-000000000004', 'f7000000-0000-0000-0000-000000000002')$$, 'P0001', 'merge_source_already_merged', 'KAT-05 Kriterium 4: bereits zusammengeführte Quelle wird abgelehnt (merge_source_already_merged)'); select is((select count(*) from public.games where id in ('f7000000-0000-0000-0000-000000000001', 'f7000000-0000-0000-0000-000000000002') and hidden_at is null and merged_into_id is null), 2::bigint, 'Nach Fehlern ist nichts verändert'); -- repoint_game_references: eigene Prüfungen (admin_merge_games kommt ihnen sonst zuvor) select throws_ok($$select public.repoint_game_references('f7000000-0000-0000-0000-000000000001', 'f7000000-0000-0000-0000-000000000001')$$, 'P0001', 'repoint_same_game', 'KAT-05 Kriterium 4: repoint mit p_from = p_to (repoint_same_game)'); select throws_ok($$select public.repoint_game_references('f7000000-0000-0000-0000-000000000001', 'f7000000-0000-0000-0000-000000000003')$$, 'P0001', 'repoint_target_not_listed', 'KAT-05 Kriterium 4: repoint auf ein ausgeblendetes Ziel'); select throws_ok($$select public.repoint_game_references('f7000000-0000-0000-0000-000000000001', 'f7000000-0000-0000-0000-000000000004')$$, 'P0001', 'repoint_target_not_listed', 'KAT-05 Kriterium 4: repoint auf ein zusammengeführtes Ziel'); select throws_ok($$select public.repoint_game_references('f7000000-0000-0000-0000-000000000001', 'f7000000-0000-0000-0000-0000000000a1')$$, 'P0001', 'repoint_target_not_listed', 'KAT-05 Kriterium 4: repoint auf ein privates Ziel'); select throws_ok($$select public.repoint_game_references('f7000000-0000-0000-0000-000000000001', 'f7000000-0000-0000-0000-0000000000ff')$$, 'P0001', 'repoint_target_not_listed', 'KAT-05 Kriterium 4: repoint auf ein unbekanntes Ziel'); -- --------------------------------------------------------------------------------------------- -- Hauptfall nach Runbook Abschnitt 5: zwei Katalogspiele (Quelle ...0002, Ziel ...0003) -- --------------------------------------------------------------------------------------------- insert into public.catalog_sources (key, name) values ('test_src', 'Testquelle'); -- Quelle ist älter als das Ziel; Titel der Quelle mit Kleinschreibung und Leerzeichen am Rand insert into public.games (id, title, year, visibility, bgg_id, catalog_source_id, source_ref, source_url, source_synced_at, created_at) values ('7a1b2c3d-0000-4000-8000-000000000002', 'duplikat runbook ', 2021, 'catalog', 13, (select id from public.catalog_sources where key = 'test_src'), 'ref-13', 'https://example.org/13', '2026-01-01', '2020-01-01'); insert into public.games (id, title, year, visibility, created_at) values ('7a1b2c3d-0000-4000-8000-000000000003', 'Duplikat Runbook', 2021, 'catalog', '2021-01-01'); insert into public.genres (id, name_de, name_en) values ('f7000000-0000-0000-0000-0000000000e1', 'Merge-Genre', 'Merge genre'); insert into public.game_genres (game_id, genre_id) values ('7a1b2c3d-0000-4000-8000-000000000002', 'f7000000-0000-0000-0000-0000000000e1'); -- Varianten. Ziel: zwei gleichnamige ("deluxe"/"DELUXE"); die ÄLTERE hat die größere id (...0f9 > ...0f0). insert into public.game_variants (id, game_id, name, year, language, bgg_id, created_at) values ('f7000000-0000-0000-0000-0000000000f1', '7a1b2c3d-0000-4000-8000-000000000002', ' Deluxe ', 2021, 'de', null, '2020-01-01'), ('f7000000-0000-0000-0000-0000000000f2', '7a1b2c3d-0000-4000-8000-000000000002', 'Collector', 2022, 'en', 777, '2020-01-02'), ('f7000000-0000-0000-0000-0000000000f9', '7a1b2c3d-0000-4000-8000-000000000003', 'deluxe', null, null, null, '2020-01-01'), ('f7000000-0000-0000-0000-0000000000f0', '7a1b2c3d-0000-4000-8000-000000000003', 'DELUXE', null, null, null, '2021-01-01'); -- Sammlung: Anna (Wunschliste an Quelle, Sammlung am Ziel), Ben (nur Quelle), Cara (Einkauf/Wunsch, Notizen), Dora (Quelle besser) insert into public.collection_items (id, owner_id, game_id, variant_id, list, note, acquired_on) values ('f7000000-0000-0000-0000-0000000000c1', '11111111-1111-1111-1111-111111111111', '7a1b2c3d-0000-4000-8000-000000000002', 'f7000000-0000-0000-0000-0000000000f1', 'wishlist', 'Annas Notiz', '2020-02-02'), ('f7000000-0000-0000-0000-0000000000c2', '11111111-1111-1111-1111-111111111111', '7a1b2c3d-0000-4000-8000-000000000003', null, 'owned', null, null), ('f7000000-0000-0000-0000-0000000000c3', '22222222-2222-2222-2222-222222222222', '7a1b2c3d-0000-4000-8000-000000000002', 'f7000000-0000-0000-0000-0000000000f2', 'owned', 'Bens Notiz', null), ('f7000000-0000-0000-0000-0000000000c4', '33333333-3333-3333-3333-333333333333', '7a1b2c3d-0000-4000-8000-000000000002', null, 'shopping', 'Caras Quellnotiz', '2020-03-03'), ('f7000000-0000-0000-0000-0000000000c5', '33333333-3333-3333-3333-333333333333', '7a1b2c3d-0000-4000-8000-000000000003', null, 'wishlist', 'Caras Zielnotiz', '2019-03-03'), ('f7000000-0000-0000-0000-0000000000c6', '44444444-4444-4444-4444-444444444444', '7a1b2c3d-0000-4000-8000-000000000002', null, 'owned', 'Doras Quellnotiz', null), ('f7000000-0000-0000-0000-0000000000c7', '44444444-4444-4444-4444-444444444444', '7a1b2c3d-0000-4000-8000-000000000003', null, 'shopping', '', null); -- Favoriten: Anna hat beide (Quelle Platz 1, Ziel Platz 2), Ben nur die Quelle (Platz 3) insert into public.favorite_games (id, profile_id, game_id, position) values ('f7000000-0000-0000-0000-0000000000d1', '11111111-1111-1111-1111-111111111111', '7a1b2c3d-0000-4000-8000-000000000002', 1), ('f7000000-0000-0000-0000-0000000000d2', '11111111-1111-1111-1111-111111111111', '7a1b2c3d-0000-4000-8000-000000000003', 2), ('f7000000-0000-0000-0000-0000000000d3', '22222222-2222-2222-2222-222222222222', '7a1b2c3d-0000-4000-8000-000000000002', 3); -- Partien (mit zwei Editionen und ohne) und ein Wertungsbogen insert into public.plays (id, owner_id, game_id, variant_id, played_at, client_id) values ('f7000000-0000-0000-0000-0000000000b1', '11111111-1111-1111-1111-111111111111', '7a1b2c3d-0000-4000-8000-000000000002', 'f7000000-0000-0000-0000-0000000000f1', '2026-01-01', 'f7000000-0000-0000-0000-0000000000b1'), ('f7000000-0000-0000-0000-0000000000b2', '11111111-1111-1111-1111-111111111111', '7a1b2c3d-0000-4000-8000-000000000002', 'f7000000-0000-0000-0000-0000000000f2', '2026-01-02', 'f7000000-0000-0000-0000-0000000000b2'), ('f7000000-0000-0000-0000-0000000000b3', '22222222-2222-2222-2222-222222222222', '7a1b2c3d-0000-4000-8000-000000000002', null, '2026-01-03', 'f7000000-0000-0000-0000-0000000000b3'); insert into public.score_sheets (id, owner_id, game_id, name) values ('f7000000-0000-0000-0000-0000000000b5', '11111111-1111-1111-1111-111111111111', '7a1b2c3d-0000-4000-8000-000000000002', 'Bogen'); -- Runbook 5, Schritt 1: Duplikate finden (global; geprüft wird nur die Fixture-Zeile) create temp table rb_dup as select lower(btrim(title)) as titel_normalisiert, year, array_agg(id order by created_at, id) as ids, count(*) as anzahl from public.catalog_games group by lower(btrim(title)), year having count(*) > 1 order by 1, 2; select results_eq($$select titel_normalisiert, year, ids, anzahl from rb_dup where ids && array['7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003']::uuid[]$$, $$values ('duplikat runbook'::text, 2021::smallint, array['7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003']::uuid[], 2::bigint)$$, 'KAT-05 Kriterium 8: Runbook 5, Duplikate finden (Titel normalisiert, älteste ID zuerst)'); -- Runbook 5, Schritt 1: Kandidaten per Titel, IDs ablesen create temp table rb_lookup as select id, title, year, hidden_at, merged_into_id, bgg_id, created_at from public.games where visibility = 'catalog' and lower(btrim(title)) = lower(btrim('Duplikat Runbook')) order by created_at, id; select results_eq($$select id from rb_lookup$$, $$values ('7a1b2c3d-0000-4000-8000-000000000002'::uuid), ('7a1b2c3d-0000-4000-8000-000000000003'::uuid)$$, 'KAT-05 Kriterium 8: Runbook 5, Kandidaten per Titel (IDs, älteste zuerst)'); -- Runbook 5, Schritt 2: Vorschau Verweise an der Quelle create temp table rb_preview as select 'collection_items' as tabelle, count(*) as anzahl from public.collection_items where game_id = '7a1b2c3d-0000-4000-8000-000000000002' union all select 'favorite_games', count(*) from public.favorite_games where game_id = '7a1b2c3d-0000-4000-8000-000000000002' union all select 'plays', count(*) from public.plays where game_id = '7a1b2c3d-0000-4000-8000-000000000002' union all select 'score_sheets', count(*) from public.score_sheets where game_id = '7a1b2c3d-0000-4000-8000-000000000002' union all select 'game_variants', count(*) from public.game_variants where game_id = '7a1b2c3d-0000-4000-8000-000000000002'; select set_eq($$select tabelle, anzahl from rb_preview$$, $$values ('collection_items'::text, 4::bigint), ('favorite_games', 2), ('plays', 3), ('score_sheets', 1), ('game_variants', 2)$$, 'KAT-05 Kriterium 8: Runbook 5, Vorschau der Verweise an der Quelle'); -- Runbook 5, Schritt 2: Vorschau Doppelbesitz create temp table rb_dbl as select s.owner_id, s.list as liste_quelle, t.list as liste_ziel from public.collection_items s join public.collection_items t on t.owner_id = s.owner_id and t.game_id = '7a1b2c3d-0000-4000-8000-000000000003' where s.game_id = '7a1b2c3d-0000-4000-8000-000000000002'; select set_eq($$select owner_id, liste_quelle, liste_ziel from rb_dbl$$, $$values ('11111111-1111-1111-1111-111111111111'::uuid, 'wishlist'::text, 'owned'::text), ('33333333-3333-3333-3333-333333333333', 'shopping', 'wishlist'), ('44444444-4444-4444-4444-444444444444', 'owned', 'shopping')$$, 'KAT-05 Kriterium 8: Runbook 5, Vorschau Doppelbesitz'); -- Runbook 5, Schritt 3: Zusammenführen create temp table rb_merge as select public.admin_merge_games('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003'); select is((select admin_merge_games from rb_merge), jsonb_build_object( 'source_id', '7a1b2c3d-0000-4000-8000-000000000002', 'target_id', '7a1b2c3d-0000-4000-8000-000000000003', 'collection_items', 1, 'collection_conflicts', 3, 'favorite_games', 1, 'favorite_conflicts', 1, 'plays', 3, 'score_sheets', 1, 'variants_copied', 1, 'variants_mapped', 1, 'variants_cleared', 0, 'bgg_id_transferred', true, 'source_transferred', true, 'chain_flattened', 0), 'KAT-05 Kriterium 8: Runbook 5, das Ergebnis-JSON zählt alles richtig'); -- Runbook 5, Schritt 4: Nachprüfen (der Vorschau-Baustein aus Schritt 2 liefert überall 0) drop table rb_preview; create temp table rb_preview as select 'collection_items' as tabelle, count(*) as anzahl from public.collection_items where game_id = '7a1b2c3d-0000-4000-8000-000000000002' union all select 'favorite_games', count(*) from public.favorite_games where game_id = '7a1b2c3d-0000-4000-8000-000000000002' union all select 'plays', count(*) from public.plays where game_id = '7a1b2c3d-0000-4000-8000-000000000002' union all select 'score_sheets', count(*) from public.score_sheets where game_id = '7a1b2c3d-0000-4000-8000-000000000002' union all select 'game_variants', count(*) from public.game_variants where game_id = '7a1b2c3d-0000-4000-8000-000000000002'; select set_eq($$select tabelle, anzahl from rb_preview$$, $$values ('collection_items'::text, 0::bigint), ('favorite_games', 0), ('plays', 0), ('score_sheets', 0), ('game_variants', 0)$$, 'KAT-05 Kriterium 4: nach dem Zusammenführen hängt nichts mehr an der Quelle'); -- Runbook 5, Schritt 4: Zustand beider Einträge create temp table rb_state as select id, title, hidden_at is not null as ausgeblendet, merged_into_id, bgg_id, catalog_source_id from public.games where id in ('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003'); select set_eq($$select id, ausgeblendet, merged_into_id, bgg_id, catalog_source_id is not null from rb_state$$, $$values ('7a1b2c3d-0000-4000-8000-000000000002'::uuid, true, '7a1b2c3d-0000-4000-8000-000000000003'::uuid, null::integer, false), ('7a1b2c3d-0000-4000-8000-000000000003', false, null, 13, true)$$, 'KAT-05 Kriterium 4: Quelle ausgeblendet und verwiesen, Ziel sichtbar und mit BGG-Kennung und Quelle der Quelle'); -- Weitere Prüfungen am Ergebnis des Hauptfalls select is((select count(*) from public.catalog_games where id in ('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003')), 1::bigint, 'KAT-05 Kriterium 4: catalog_games enthält nur noch das Ziel'); select is((select count(*) from public.games where id = '7a1b2c3d-0000-4000-8000-000000000002'), 1::bigint, 'Die Quelle wird nicht gelöscht, ihre ID bleibt gültig'); select is((select row(catalog_source_id, source_ref, source_url, source_synced_at)::text from public.games where id = '7a1b2c3d-0000-4000-8000-000000000002'), '(,,,)', 'Die Quellen-Angaben sind an der Quelle geleert'); select is((select row(catalog_source_id = (select id from public.catalog_sources where key = 'test_src'), source_ref, source_url, source_synced_at)::text from public.games where id = '7a1b2c3d-0000-4000-8000-000000000003'), '(t,ref-13,https://example.org/13,"2026-01-01 00:00:00+00")', 'Das Ziel hat Quelle, Kennung, URL und Abgleichszeit übernommen'); select is((select title from public.games where id = '7a1b2c3d-0000-4000-8000-000000000003'), 'Duplikat Runbook', 'Das Ziel behält seine Fakten'); select is((select count(*) from public.game_genres where game_id = '7a1b2c3d-0000-4000-8000-000000000002'), 1::bigint, 'Zuordnungen bleiben am ausgeblendeten Quell-Eintrag'); select is((select count(*) from public.game_genres where game_id = '7a1b2c3d-0000-4000-8000-000000000003'), 0::bigint, 'Zuordnungen werden nicht auf das Ziel kopiert'); -- Sammlung (Spec-F1): Kollisionsregel select is((select row(list, note, acquired_on, variant_id)::text from public.collection_items where id = 'f7000000-0000-0000-0000-0000000000c2'), '(owned,"Annas Notiz",2020-02-02,f7000000-0000-0000-0000-0000000000f9)', 'Spec-F1: Anna: Ziel gewinnt die Liste (owned vor wishlist), leere Felder aus der Quelle, Edition über die Variantenzuordnung (älteste gleichnamige Zielvariante)'); select is((select count(*) from public.collection_items where id = 'f7000000-0000-0000-0000-0000000000c1'), 0::bigint, 'Spec-F1: der Quell-Eintrag der Kollision ist gelöscht'); select isnt((select variant_id from public.collection_items where id = 'f7000000-0000-0000-0000-0000000000c3'), 'f7000000-0000-0000-0000-0000000000f2'::uuid, 'Ben: die Edition zeigt nicht mehr auf die alte Variante'); select is((select row(c.game_id, c.list, c.note)::text || ':' || v.name || ':' || v.game_id::text || ':' || v.year::text || ':' || v.language || ':' || v.bgg_id::text from public.collection_items c join public.game_variants v on v.id = c.variant_id where c.id = 'f7000000-0000-0000-0000-0000000000c3'), '(7a1b2c3d-0000-4000-8000-000000000003,owned,"Bens Notiz"):Collector:7a1b2c3d-0000-4000-8000-000000000003:2022:en:777', 'Ben: ohne Kollision wechselt der Eintrag auf das Ziel, die Edition ist eine Kopie am Ziel (Name, Jahr, Sprache, BGG-Kennung)'); select is((select row(list, note, acquired_on)::text from public.collection_items where id = 'f7000000-0000-0000-0000-0000000000c5'), '(wishlist,"Caras Zielnotiz",2019-03-03)', 'Spec-F1: Cara: wishlist vor shopping; eine vorhandene Notiz und ein vorhandenes Datum am Ziel werden nicht überschrieben'); select is((select row(list, note, acquired_on)::text from public.collection_items where id = 'f7000000-0000-0000-0000-0000000000c7'), '(owned,"Doras Quellnotiz",)', 'Spec-F1: Dora: owned (Quelle) vor shopping (Ziel); eine leere Notiz am Ziel wird aus der Quelle gefüllt'); select is((select count(*) from public.collection_items where owner_id = '44444444-4444-4444-4444-444444444444'), 1::bigint, 'Spec-F1: Doras Quell-Eintrag ist gelöscht'); select is((select count(*) from public.collection_items where game_id = '7a1b2c3d-0000-4000-8000-000000000003'), 4::bigint, 'Jeder der vier Nutzer hat genau einen Eintrag am Ziel'); -- Favoriten select is((select array_agg(position order by position) from public.favorite_games where profile_id = '11111111-1111-1111-1111-111111111111'), array[2::smallint], 'Favoriten: Anna hatte beide; nur der Favorit am Ziel (Platz 2) bleibt'); select is((select row(game_id, position)::text from public.favorite_games where id = 'f7000000-0000-0000-0000-0000000000d3'), '(7a1b2c3d-0000-4000-8000-000000000003,3)', 'Favoriten: Ben wechselt auf das Ziel und behält seinen Platz'); -- Partien select is((select row(game_id, variant_id)::text from public.plays where id = 'f7000000-0000-0000-0000-0000000000b1'), '(7a1b2c3d-0000-4000-8000-000000000003,f7000000-0000-0000-0000-0000000000f9)', 'Partien: Edition wird auf die älteste gleichnamige Zielvariante abgebildet'); select is((select p.game_id::text || ':' || v.game_id::text || ':' || v.name from public.plays p join public.game_variants v on v.id = p.variant_id where p.id = 'f7000000-0000-0000-0000-0000000000b2'), '7a1b2c3d-0000-4000-8000-000000000003:7a1b2c3d-0000-4000-8000-000000000003:Collector', 'Partien: die Edition "Collector" zeigt auf die Kopie am Ziel'); select is((select row(game_id, variant_id)::text from public.plays where id = 'f7000000-0000-0000-0000-0000000000b3'), '(7a1b2c3d-0000-4000-8000-000000000003,)', 'Partien: ohne Edition bleibt es ohne Edition'); select is((select game_id from public.score_sheets where id = 'f7000000-0000-0000-0000-0000000000b5'), '7a1b2c3d-0000-4000-8000-000000000003'::uuid, 'Wertungsbogen am Ziel'); -- Varianten select is((select count(*) from public.game_variants where game_id = '7a1b2c3d-0000-4000-8000-000000000002'), 0::bigint, 'Die Varianten der Quelle sind gelöscht'); select is((select count(*) from public.game_variants where game_id = '7a1b2c3d-0000-4000-8000-000000000003'), 3::bigint, 'Das Ziel hat 2 eigene + 1 kopierte Variante (die gleichnamige wurde abgebildet, nicht kopiert)'); select is((select count(*) from public.game_variants where id in ('f7000000-0000-0000-0000-0000000000f9', 'f7000000-0000-0000-0000-0000000000f0')), 2::bigint, 'Eigene Zielvarianten bleiben erhalten'); select is((select count(distinct variant_id) from ( select variant_id from public.plays where id = 'f7000000-0000-0000-0000-0000000000b2' union all select variant_id from public.collection_items where id = 'f7000000-0000-0000-0000-0000000000c3') x), 1::bigint, 'Dieselbe Quellvariante wird nur einmal kopiert (Partie und Sammlung zeigen auf dieselbe Kopie)'); -- Löschen eines Ziels mit Verweis (nur der Verweis merged_into_id, ohne andere Verweise) insert into public.games (id, title, visibility) values ('f7000000-0000-0000-0000-0000000000b8', 'Löschziel', 'catalog'), ('f7000000-0000-0000-0000-0000000000b9', 'Löschquelle', 'catalog'); select public.admin_merge_games('f7000000-0000-0000-0000-0000000000b9', 'f7000000-0000-0000-0000-0000000000b8'); select throws_ok($$delete from public.games where id = 'f7000000-0000-0000-0000-0000000000b8'$$, '23503', null, 'KAT-05 Kriterium 4: Ein Ziel mit Verweis lässt sich nicht löschen (on delete restrict)'); -- --------------------------------------------------------------------------------------------- -- Konflikte bei BGG-Kennung und Quelle (W6), Ketten, bereits ausgeblendete Quelle -- --------------------------------------------------------------------------------------------- insert into public.games (id, title, visibility, bgg_id, catalog_source_id, source_ref, source_url, source_synced_at) values ('f7000000-0000-0000-0000-000000000101', 'Konflikt Quelle', 'catalog', 14, (select id from public.catalog_sources where key = 'test_src'), 'quelle-ref', 'https://example.org/q', '2026-02-02'), ('f7000000-0000-0000-0000-000000000102', 'Konflikt Ziel', 'catalog', 42, (select id from public.catalog_sources where key = 'test_src'), 'ziel-ref', 'https://example.org/z', '2026-03-03'); create temp table rb_conflict as select public.admin_merge_games('f7000000-0000-0000-0000-000000000101', 'f7000000-0000-0000-0000-000000000102') as r; select is((select r -> 'bgg_id_conflict' from rb_conflict), '{"source": 14, "target": 42}'::jsonb, 'KAT-05 Kriterium 4: bgg_id-Konflikt wird mit den alten Werten gemeldet'); select is((select r -> 'source_conflict' from rb_conflict), jsonb_build_object( 'source', jsonb_build_object('catalog_source_id', (select id from public.catalog_sources where key = 'test_src'), 'source_ref', 'quelle-ref', 'source_url', 'https://example.org/q', 'source_synced_at', '2026-02-02T00:00:00+00:00'), 'target', jsonb_build_object('catalog_source_id', (select id from public.catalog_sources where key = 'test_src'), 'source_ref', 'ziel-ref', 'source_url', 'https://example.org/z', 'source_synced_at', '2026-03-03T00:00:00+00:00')), 'KAT-05 Kriterium 4: Quellen-Konflikt wird mit den alten Werten gemeldet'); select is((select r -> 'bgg_id_transferred' from rb_conflict), 'false'::jsonb, 'Bei Konflikt wird nichts übertragen (bgg_id)'); select is((select r -> 'source_transferred' from rb_conflict), 'false'::jsonb, 'Bei Konflikt wird nichts übertragen (Quelle)'); select is((select row(bgg_id, source_ref, source_url)::text from public.games where id = 'f7000000-0000-0000-0000-000000000102'), '(42,ziel-ref,https://example.org/z)', 'Das Ziel behält bei Konflikt seine Werte'); select is((select row(bgg_id, catalog_source_id, source_ref, source_url, source_synced_at)::text from public.games where id = 'f7000000-0000-0000-0000-000000000101'), '(,,,,)', 'Die Quelle ist bei Konflikt trotzdem geleert'); -- Ohne Konflikt gibt es keine Konfliktschlüssel; nur BGG-Kennung auf der Quelle insert into public.games (id, title, visibility, bgg_id) values ('f7000000-0000-0000-0000-000000000111', 'Ohne Konflikt Quelle', 'catalog', 99), ('f7000000-0000-0000-0000-000000000112', 'Ohne Konflikt Ziel', 'catalog', null); create temp table rb_noconf as select public.admin_merge_games('f7000000-0000-0000-0000-000000000111', 'f7000000-0000-0000-0000-000000000112') as r; select is((select (r ? 'bgg_id_conflict') or (r ? 'source_conflict') from rb_noconf), false, 'Ohne Konflikt gibt es keine Konfliktschlüssel'); select is((select bgg_id from public.games where id = 'f7000000-0000-0000-0000-000000000112'), 99, 'BGG-Kennung geht auf das Ziel über, ohne den Unique-Index zu verletzen'); select is((select r -> 'bgg_id_transferred' from rb_noconf), 'true'::jsonb, 'bgg_id_transferred ist true'); select is((select r -> 'source_transferred' from rb_noconf), 'false'::jsonb, 'source_transferred ist false (die Quelle hatte keine)'); -- Ziel hat BGG-Kennung, Quelle nicht: Ziel bleibt, kein Konflikt insert into public.games (id, title, visibility, bgg_id) values ('f7000000-0000-0000-0000-000000000121', 'Ziel hat BGG Quelle', 'catalog', null), ('f7000000-0000-0000-0000-000000000122', 'Ziel hat BGG Ziel', 'catalog', 55); create temp table rb_tgt as select public.admin_merge_games('f7000000-0000-0000-0000-000000000121', 'f7000000-0000-0000-0000-000000000122') as r; select is((select bgg_id from public.games where id = 'f7000000-0000-0000-0000-000000000122'), 55, 'Ziel behält seine BGG-Kennung'); select is((select r ? 'bgg_id_conflict' from rb_tgt), false, 'Kein Konflikt, wenn nur das Ziel eine BGG-Kennung hat'); -- Bereits ausgeblendete Quelle behält ihren Zeitpunkt (coalesce) insert into public.games (id, title, visibility, hidden_at) values ('f7000000-0000-0000-0000-000000000131', 'Versteckte Quelle', 'catalog', '2020-05-05 00:00:00+00'), ('f7000000-0000-0000-0000-000000000132', 'Ziel zu Versteckt', 'catalog', null); select public.admin_merge_games('f7000000-0000-0000-0000-000000000131', 'f7000000-0000-0000-0000-000000000132'); select is((select hidden_at from public.games where id = 'f7000000-0000-0000-0000-000000000131'), '2020-05-05 00:00:00+00'::timestamptz, 'Eine bereits ausgeblendete Quelle behält ihr hidden_at'); select is((select merged_into_id from public.games where id = 'f7000000-0000-0000-0000-000000000131'), 'f7000000-0000-0000-0000-000000000132'::uuid, 'Die ausgeblendete Quelle ist zusammengeführt'); select is((select hidden_at is not null from public.games where id = 'f7000000-0000-0000-0000-000000000101'), true, 'Eine sichtbare Quelle wird ausgeblendet'); -- Ketten glätten: C -> A, dann A -> B ergibt C -> B insert into public.games (id, title, visibility) values ('f7000000-0000-0000-0000-000000000141', 'Kette A', 'catalog'), ('f7000000-0000-0000-0000-000000000142', 'Kette B', 'catalog'), ('f7000000-0000-0000-0000-000000000143', 'Kette C', 'catalog'); select public.admin_merge_games('f7000000-0000-0000-0000-000000000143', 'f7000000-0000-0000-0000-000000000141'); select is((select merged_into_id from public.games where id = 'f7000000-0000-0000-0000-000000000143'), 'f7000000-0000-0000-0000-000000000141'::uuid, 'Kette: C zeigt zuerst auf A'); create temp table rb_chain as select public.admin_merge_games('f7000000-0000-0000-0000-000000000141', 'f7000000-0000-0000-0000-000000000142') as r; select is((select merged_into_id from public.games where id = 'f7000000-0000-0000-0000-000000000143'), 'f7000000-0000-0000-0000-000000000142'::uuid, 'KAT-05 Kriterium 4: Ketten werden geglättet (C zeigt danach direkt auf B)'); select is((select merged_into_id from public.games where id = 'f7000000-0000-0000-0000-000000000141'), 'f7000000-0000-0000-0000-000000000142'::uuid, 'A zeigt auf B'); select is((select r -> 'chain_flattened' from rb_chain), '1'::jsonb, 'chain_flattened zählt die geglättete Kette'); -- --------------------------------------------------------------------------------------------- -- Namensabgleich: Gleichstand bei created_at -> kleinste id -- --------------------------------------------------------------------------------------------- insert into public.games (id, title, visibility) values ('f7000000-0000-0000-0000-000000000151', 'Gleichstand Quelle', 'catalog'), ('f7000000-0000-0000-0000-000000000152', 'Gleichstand Ziel', 'catalog'); -- zuerst die größeren ids einfügen, die kleinste zuletzt (die Zeilenreihenfolge darf nicht entscheiden) insert into public.game_variants (id, game_id, name, created_at) values ('f7000000-0000-0000-0000-0000000001a2', 'f7000000-0000-0000-0000-000000000152', 'Edition', '2022-01-01'), ('f7000000-0000-0000-0000-0000000001a3', 'f7000000-0000-0000-0000-000000000152', 'EDITION', '2022-01-01'), ('f7000000-0000-0000-0000-0000000001a1', 'f7000000-0000-0000-0000-000000000152', ' edition', '2022-01-01'), ('f7000000-0000-0000-0000-0000000001b1', 'f7000000-0000-0000-0000-000000000151', 'edition ', '2020-01-01'); insert into public.plays (id, owner_id, game_id, variant_id, played_at, client_id) values ('f7000000-0000-0000-0000-0000000001c1', '11111111-1111-1111-1111-111111111111', 'f7000000-0000-0000-0000-000000000151', 'f7000000-0000-0000-0000-0000000001b1', '2026-02-01', 'f7000000-0000-0000-0000-0000000001c1'); select public.admin_merge_games('f7000000-0000-0000-0000-000000000151', 'f7000000-0000-0000-0000-000000000152'); select is((select variant_id from public.plays where id = 'f7000000-0000-0000-0000-0000000001c1'), 'f7000000-0000-0000-0000-0000000001a1'::uuid, 'KAT-05 Kriterium 4: bei Gleichstand von created_at gilt die kleinste id'); select is((select count(*) from public.game_variants where game_id = 'f7000000-0000-0000-0000-000000000152'), 3::bigint, 'Eine gleichnamige Variante (ohne Groß-/Kleinschreibung und Randleerzeichen) wird nicht kopiert'); -- Quelle ohne Verweise: Zähler 0 insert into public.games (id, title, visibility) values ('f7000000-0000-0000-0000-000000000161', 'Leer Quelle', 'catalog'), ('f7000000-0000-0000-0000-000000000162', 'Leer Ziel', 'catalog'); select is(public.admin_merge_games('f7000000-0000-0000-0000-000000000161', 'f7000000-0000-0000-0000-000000000162') - 'source_id' - 'target_id', '{"collection_items":0,"collection_conflicts":0,"favorite_games":0,"favorite_conflicts":0,"plays":0,"score_sheets":0,"variants_copied":0,"variants_mapped":0,"variants_cleared":0,"bgg_id_transferred":false,"source_transferred":false,"chain_flattened":0}'::jsonb, 'Zusammenführen ohne Verweise liefert lauter Nullen'); -- --------------------------------------------------------------------------------------------- -- repoint_game_references: privater Eintrag -> Katalogspiel (RB1, Klärung N15): nichts kopieren -- --------------------------------------------------------------------------------------------- insert into public.games (id, title, visibility, owner_id) values ('f7000000-0000-0000-0000-0000000002a1', 'Vorläufig Anna', 'private', '11111111-1111-1111-1111-111111111111'); insert into public.games (id, title, visibility) values ('f7000000-0000-0000-0000-0000000002b1', 'Katalogziel', 'catalog'); insert into public.game_variants (id, game_id, name) values ('f7000000-0000-0000-0000-0000000002e1', 'f7000000-0000-0000-0000-0000000002a1', 'Deluxe'), ('f7000000-0000-0000-0000-0000000002e2', 'f7000000-0000-0000-0000-0000000002a1', 'Prototyp'), ('f7000000-0000-0000-0000-0000000002e3', 'f7000000-0000-0000-0000-0000000002b1', ' deluxe'); insert into public.collection_items (id, owner_id, game_id, variant_id, list) values ('f7000000-0000-0000-0000-0000000002c1', '11111111-1111-1111-1111-111111111111', 'f7000000-0000-0000-0000-0000000002a1', 'f7000000-0000-0000-0000-0000000002e2', 'owned'); insert into public.plays (id, owner_id, game_id, variant_id, played_at, client_id) values ('f7000000-0000-0000-0000-0000000002d1', '11111111-1111-1111-1111-111111111111', 'f7000000-0000-0000-0000-0000000002a1', 'f7000000-0000-0000-0000-0000000002e1', '2026-03-01', 'f7000000-0000-0000-0000-0000000002d1'), ('f7000000-0000-0000-0000-0000000002d2', '11111111-1111-1111-1111-111111111111', 'f7000000-0000-0000-0000-0000000002a1', 'f7000000-0000-0000-0000-0000000002e2', '2026-03-02', 'f7000000-0000-0000-0000-0000000002d2'); create temp table rb_priv as select public.repoint_game_references('f7000000-0000-0000-0000-0000000002a1', 'f7000000-0000-0000-0000-0000000002b1') as r; select is((select r from rb_priv), '{"collection_items":1,"collection_conflicts":0,"favorite_games":0,"favorite_conflicts":0,"plays":2,"score_sheets":0,"variants_copied":0,"variants_mapped":1,"variants_cleared":2}'::jsonb, 'KAT-05 Kriterium 4: privater Eintrag: nichts kopiert, eine Variante abgebildet, zwei Verweise geleert (variants_cleared)'); select is((select count(*) from public.game_variants where game_id = 'f7000000-0000-0000-0000-0000000002b1'), 1::bigint, 'Privater Eintrag: das Katalogziel bekommt keine Variante (RB1)'); select is((select count(*) from public.game_variants where game_id = 'f7000000-0000-0000-0000-0000000002a1'), 2::bigint, 'Privater Eintrag: die privaten Varianten bleiben am vorläufigen Eintrag'); select is((select row(game_id, variant_id)::text from public.collection_items where id = 'f7000000-0000-0000-0000-0000000002c1'), '(f7000000-0000-0000-0000-0000000002b1,)', 'Privater Eintrag: Edition ohne gleichnamige Zielvariante wird am Sammlungseintrag geleert'); select is((select row(game_id, variant_id)::text from public.plays where id = 'f7000000-0000-0000-0000-0000000002d1'), '(f7000000-0000-0000-0000-0000000002b1,f7000000-0000-0000-0000-0000000002e3)', 'Privater Eintrag: gleichnamige Zielvariante wird abgebildet'); select is((select row(game_id, variant_id)::text from public.plays where id = 'f7000000-0000-0000-0000-0000000002d2'), '(f7000000-0000-0000-0000-0000000002b1,)', 'Privater Eintrag: Edition der Partie ohne Gegenstück wird geleert'); -- Kollision bei privatem Eintrag: das Ziel hat die Edition schon; sie bleibt, nichts gilt als geleert insert into public.games (id, title, visibility, owner_id) values ('f7000000-0000-0000-0000-0000000002a2', 'Vorläufig Ben', 'private', '22222222-2222-2222-2222-222222222222'); insert into public.game_variants (id, game_id, name) values ('f7000000-0000-0000-0000-0000000002e4', 'f7000000-0000-0000-0000-0000000002a2', 'Nur privat'); insert into public.collection_items (id, owner_id, game_id, variant_id, list) values ('f7000000-0000-0000-0000-0000000002c2', '22222222-2222-2222-2222-222222222222', 'f7000000-0000-0000-0000-0000000002a2', 'f7000000-0000-0000-0000-0000000002e4', 'wishlist'), ('f7000000-0000-0000-0000-0000000002c3', '22222222-2222-2222-2222-222222222222', 'f7000000-0000-0000-0000-0000000002b1', 'f7000000-0000-0000-0000-0000000002e3', 'owned'); select is((select (r ->> 'variants_cleared')::int from (select public.repoint_game_references('f7000000-0000-0000-0000-0000000002a2', 'f7000000-0000-0000-0000-0000000002b1') as r) x), 0, 'Kollision: hat das Ziel schon eine Edition, geht keine verloren'); select is((select row(list, variant_id)::text from public.collection_items where id = 'f7000000-0000-0000-0000-0000000002c3'), '(owned,f7000000-0000-0000-0000-0000000002e3)', 'Kollision: die Edition des Zieleintrags bleibt'); -- Kollision mit leerer Zieledition: die Variante der Quelle kommt über die Zuordnung in den Zieleintrag (Spec-F1) insert into public.games (id, title, visibility) values ('f7000000-0000-0000-0000-0000000003a1', 'Kollision Quelle', 'catalog'), ('f7000000-0000-0000-0000-0000000003b1', 'Kollision Ziel', 'catalog'); insert into public.game_variants (id, game_id, name) values ('f7000000-0000-0000-0000-0000000003e1', 'f7000000-0000-0000-0000-0000000003a1', 'Jubiläum'); insert into public.collection_items (id, owner_id, game_id, variant_id, list) values ('f7000000-0000-0000-0000-0000000003c1', '33333333-3333-3333-3333-333333333333', 'f7000000-0000-0000-0000-0000000003a1', 'f7000000-0000-0000-0000-0000000003e1', 'owned'), ('f7000000-0000-0000-0000-0000000003c2', '33333333-3333-3333-3333-333333333333', 'f7000000-0000-0000-0000-0000000003b1', null, 'owned'); select public.admin_merge_games('f7000000-0000-0000-0000-0000000003a1', 'f7000000-0000-0000-0000-0000000003b1'); select is((select v.name || '/' || v.game_id::text from public.collection_items c join public.game_variants v on v.id = c.variant_id where c.id = 'f7000000-0000-0000-0000-0000000003c2'), 'Jubiläum/f7000000-0000-0000-0000-0000000003b1', 'Spec-F1: Kollision: die Edition der Quelle kommt über die Zuordnung (Kopie am Ziel) in den Zieleintrag'); -- --------------------------------------------------------------------------------------------- -- Fremdschlüssel-Wächter: jeder Verweis auf games ist entweder behandelt oder bewusst ausgelassen -- --------------------------------------------------------------------------------------------- create temp table fk_found as select (select relname from pg_class where oid = c.conrelid) || '.' || (select attname from pg_attribute where attrelid = c.conrelid and attnum = c.conkey[1]) as fk from pg_constraint c where c.contype = 'f' and c.confrelid = 'public.games'::regclass; select is((select count(*) from pg_constraint c where c.contype = 'f' and c.confrelid = 'public.games'::regclass and cardinality(c.conkey) > 1), 0::bigint, 'Fremdschlüssel-Wächter: alle Verweise auf games sind einspaltig'); select set_eq($$select fk from fk_found$$, $$values -- behandelt von repoint_game_references / admin_merge_games ('collection_items.game_id'), ('favorite_games.game_id'), ('plays.game_id'), ('score_sheets.game_id'), ('game_variants.game_id'), ('games.merged_into_id'), -- bewusst ausgelassen: bleiben am ausgeblendeten Quell-Eintrag ('game_genres.game_id'), ('game_mechanics.game_id'), ('game_categories.game_id'), ('game_publishers.game_id')$$, 'KAT-05 Kriterium 4: Fremdschlüssel-Wächter: neue Verweise auf games erzwingen eine Entscheidung im Zusammenführen'); select * from finish(); rollback;