Shuffle/supabase/tests/database/07_catalog_merge.test.sql
cschlaefke 147dd1870e feat(db): game reports table, admin_resolve_report and visibility trigger (KAT-05 2c) Refs #2
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 21:05:02 +01:00

486 lines
42 KiB
PL/PgSQL

-- 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.
-- Ab 2c kennt der Fremdschlüssel-Wächter am Ende auch game_reports.target_game_id (behandelt) und
-- game_reports.provisional_game_id (bewusst ausgelassen); admin_merge_games meldet reports_repointed.
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, 'reports_repointed', 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,"reports_repointed":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'), ('game_reports.target_game_id'),
-- bewusst ausgelassen: bleiben am ausgeblendeten Quell-Eintrag
('game_genres.game_id'), ('game_mechanics.game_id'), ('game_categories.game_id'), ('game_publishers.game_id'),
-- bewusst ausgelassen: zeigt auf ein privates Spiel, das Zusammenführen zweier Katalogspiele betrifft ihn nicht
('game_reports.provisional_game_id')$$,
'KAT-05 Kriterium 4: Fremdschlüssel-Wächter: neue Verweise auf games erzwingen eine Entscheidung im Zusammenführen');
select * from finish();
rollback;