From daeae999ddbb65d1279d281e76f2d72c988e9e65 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Tue, 3 Nov 2026 20:46:19 +0100 Subject: [PATCH] feat(db): add admin_merge_games and repoint_game_references (KAT-05, 2b) Refs #2 Co-Authored-By: Claude Sonnet 5.5 --- docs/katalog-pflege.md | 116 ++++- .../20261103193928_catalog_merge.sql | 280 ++++++++++ .../tests/database/07_catalog_merge.test.sql | 484 ++++++++++++++++++ 3 files changed, 875 insertions(+), 5 deletions(-) create mode 100644 supabase/migrations/20261103193928_catalog_merge.sql create mode 100644 supabase/tests/database/07_catalog_merge.test.sql diff --git a/docs/katalog-pflege.md b/docs/katalog-pflege.md index ea3d551..5d0b200 100644 --- a/docs/katalog-pflege.md +++ b/docs/katalog-pflege.md @@ -2,9 +2,9 @@ Dieses Dokument beschreibt, wie der Admin (Chris) den Katalog in R1 pflegt: ausschließlich über Supabase Studio bzw. `psql`, es gibt keine Admin-Oberfläche in der App (Klärungen 25/44). Grundlage ist die Spec `docs/specs/2-katalog-daten.md`. -Stand: PR 2a (Datenmodell, Startbestand, Ausblenden). Die Abschnitte **Zusammenführen** (PR 2b) und **Meldungen bearbeiten** (PR 2c) kommen mit den jeweiligen PRs dazu. +Stand: PR 2b (Datenmodell, Startbestand, Ausblenden, Zusammenführen). Der Abschnitt **Meldungen bearbeiten** (PR 2c) kommt mit dem jeweiligen PR dazu. -**Die SQL-Bausteine hier laufen unverändert in den pgTAP-Tests** (`supabase/tests/database/06_catalog_foundation.test.sql`, Kommentare mit Abschnittsverweis). Ändert sich ein Baustein, muss der Test mitgezogen werden, damit das Runbook nicht veraltet. Die Bausteine stehen ohne `begin;`/`commit;`, im Studio bzw. in `psql` wird alles in eine Transaktion gepackt (siehe 1). +**Die SQL-Bausteine hier laufen unverändert in den pgTAP-Tests** (Abschnitte 3 und 4 in `supabase/tests/database/06_catalog_foundation.test.sql`, Abschnitt 5 in `07_catalog_merge.test.sql`; Kommentare mit Abschnittsverweis). Ändert sich ein Baustein, muss der Test mitgezogen werden, damit das Runbook nicht veraltet. Die Bausteine stehen ohne `begin;`/`commit;`, im Studio bzw. in `psql` wird alles in eine Transaktion gepackt (siehe 1). ## 1. Zugang @@ -119,7 +119,7 @@ Je Regel eine Zeile (Soll, Ist, Status `ok`/`Verstoß`/`Information`). **Abnahme Jede Änderung an einem Fakt eines Katalogspiels (und jede Zuordnung zu Verlag, Genre, Mechanik oder Kategorie) trägt der Trigger automatisch in `games.manual_fields` ein, damit ein späterer Import sie nicht überschreibt. Im Studio muss dafür nichts zusätzlich gepflegt werden. Als Rolle `postgres` bleibt das Kennzeichnen auch dann aktiv, wenn jemand `shuffle.catalog_import` setzt. -**Spiel finden, bevor man es ändert.** Titel sind nicht eindeutig (ab PR 2b kann es gleichnamige Duplikate geben). Deshalb zuerst die ID nachschlagen und alle weiteren Bausteine über `id` ausführen: +**Spiel finden, bevor man es ändert.** Titel sind nicht eindeutig (es kann gleichnamige Duplikate geben, siehe 5). Deshalb zuerst die ID nachschlagen und alle weiteren Bausteine über `id` ausführen: ```sql select id, title, year, hidden_at from public.games @@ -164,7 +164,7 @@ where id = '7a1b2c3d-0000-4000-8000-000000000001' and visibility = 'catalog'; Regeln: - **Katalogspiele nie löschen, sondern ausblenden** (siehe 4). Zugeordnete Sammlungen, Favoriten und Partien verweisen auf sie. -- **`merged_into_id` nie von Hand setzen**; Zusammenführen kommt mit PR 2b über die Funktion `admin_merge_games`. Der Trigger lehnt einen Verweis auf ein privates Spiel ab (`merged_into_not_catalog`). +- **`merged_into_id` nie von Hand setzen**; Zusammenführen geht nur über die Funktion `admin_merge_games` (siehe 5). Der Trigger lehnt einen Verweis auf ein privates Spiel ab (`merged_into_not_catalog`). - Private Spiele von Nutzern im Studio nicht anfassen. - Zulässige Schlüssel in `manual_fields`: `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `genres`, `mechanics`, `categories`, `publishers`. @@ -186,7 +186,113 @@ Hinweis: Der Eintrag bleibt über die rohe Schnittstelle per ID lesbar (`games` ## 5. Zusammenführen -Kommt mit PR 2b. +Zwei Katalogspiele, die dasselbe Spiel beschreiben (Duplikat), führt man mit der Funktion `public.admin_merge_games(quelle, ziel)` zusammen. **Nie per Hand-SQL** (`update … set game_id`): Das Zusammenführen berührt Sammlungen, Favoriten, Partien (mit Edition), Wertungsbögen und Varianten, dazu eindeutige Schlüssel und zusammengesetzte Fremdschlüssel. Die Funktion macht alles in einer Transaktion und meldet, was passiert ist. + +- **Quelle** ist das Duplikat, das verschwindet. Sie wird **nicht gelöscht**, sondern ausgeblendet und auf das Ziel verwiesen (`hidden_at`, `merged_into_id`); ihre ID bleibt gültig, die App leitet alte Links auf das Ziel um (KAT-02). +- **Ziel** ist der Eintrag, der bleibt. Er muss sichtbar sein (nicht ausgeblendet, nicht selbst zusammengeführt). Das Ziel behält seine Fakten, Kurztexte und Zuordnungen (Verlag, Genre, Mechanik, Kategorie); die Zuordnungen der Quelle bleiben am ausgeblendeten Quell-Eintrag. Was am Ziel fehlt, ergänzt man vorher oder nachher mit den Bausteinen aus 3. +- Beide müssen **Katalogspiele** sein. Einen vorläufigen privaten Eintrag eines Nutzers führt man nicht hiermit zusammen (dafür gibt es ab PR 2c die Meldungs-Funktion). +- **Es gibt kein Rückgängigmachen.** Vor dem Zusammenführen den Stand des Backups prüfen; im Zweifel zuerst die Vorschau (unten) lesen. + +**Schritt 1: Duplikate finden** (normalisierter Titel + Jahr, nur sichtbare Katalogspiele; die älteste ID steht zuerst): + +```sql +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; +``` + +Ein Duplikat kann auch einen leicht anderen Titel haben. Dann gezielt suchen und **die IDs ablesen** (Titel sind nicht eindeutig, alle weiteren Bausteine laufen über `id`): + +```sql +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; +``` + +Welcher Eintrag das Ziel wird, entscheidet der Admin; sinnvoll ist der mit den besseren Fakten, der BGG-Kennung oder den meisten Verweisen. In den Bausteinen unten stehen Beispiel-IDs: `7a1b2c3d-0000-4000-8000-000000000002` ist die Quelle, `7a1b2c3d-0000-4000-8000-000000000003` das Ziel. Immer durch die echten IDs ersetzen. + +**Schritt 2: Vorschau.** Wie viele Verweise hängen an der Quelle? + +```sql +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'; +``` + +Wer hat beide Spiele in der Sammlung (Doppelbesitz)? Für diese Nutzer gilt die Kollisionsregel (siehe unten): + +```sql +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'; +``` + +**Schritt 3: Zusammenführen.** In einer Transaktion ausführen (`begin;` davor, nach dem Lesen des Ergebnisses `commit;`, bei Zweifel `rollback;`; die Bausteine stehen wie überall ohne `begin`/`commit`). Bei einem Fehler wird nichts geändert: + +```sql +select public.admin_merge_games('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003'); +``` + +**Schritt 4: Nachprüfen.** Der Vorschau-Baustein aus Schritt 2 (mit der Quelle) muss jetzt überall `0` liefern. Zustand beider Einträge: + +```sql +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'); +``` + +Die Quelle ist ausgeblendet und verweist auf das Ziel, das Ziel ist unverändert sichtbar. + +**Was die Funktion macht** + +| Was | Regel | +|---|---| +| Sammlung (`collection_items`) | Hat der Nutzer nur die Quelle, wechselt der Eintrag auf das Ziel. Hat er beide (Doppelbesitz, Spec-F1): Der Zieleintrag bleibt; leere Felder (Notiz, Erwerbsdatum, Edition) füllt der Quell-Eintrag; bei unterschiedlicher Liste gilt Sammlung vor Wunschliste vor Einkaufsliste; der Quell-Eintrag wird gelöscht. Eine nicht leere Notiz am Ziel wird nie überschrieben. | +| Favoriten | Hat der Nutzer das Ziel auch als Favorit, entfällt der Favorit der Quelle (die Lücke in der Reihenfolge ist zulässig); sonst wechselt er auf das Ziel. | +| Partien, Wertungsbögen | wechseln auf das Ziel; die Edition einer Partie wird über die Variantenzuordnung übertragen. | +| Varianten (Editionen) | Für jede Variante der Quelle: Hat das Ziel eine gleichnamige Variante (Vergleich ohne Groß-/Kleinschreibung und ohne Leerzeichen am Rand), wird darauf abgebildet; gibt es mehrere gleichnamige, gilt die älteste (bei Gleichstand die kleinste ID). Sonst wird die Variante am Ziel neu angelegt (Name, Jahr, Sprache, BGG-Kennung). Danach werden die Varianten der Quelle gelöscht. | +| BGG-Kennung und Quelle | Hat das Ziel keine, aber die Quelle, übernimmt das Ziel sie (`bgg_id`; ebenso Quelle, `source_ref`, `source_url`, `source_synced_at` als Gruppe). An der Quelle werden sie in jedem Fall geleert, damit der BGG-Abgleich später den ausgeblendeten Eintrag nicht trifft. | +| Ketten | Spiele, die bisher auf die Quelle verwiesen, verweisen jetzt auf das Ziel. | + +**Ergebnis lesen** (Beispiel): + +``` +{"plays": 7, "target_id": "…", "source_id": "…", "score_sheets": 1, "favorite_games": 1, "collection_items": 3, + "variants_copied": 2, "variants_mapped": 1, "variants_cleared": 0, "chain_flattened": 0, + "favorite_conflicts": 0, "bgg_id_transferred": true, "source_transferred": false, + "collection_conflicts": 1} +``` + +| Schlüssel | Bedeutung | +|---|---| +| `collection_items` | Sammlungseinträge, die ohne Kollision auf das Ziel gewechselt sind | +| `collection_conflicts` | Doppelbesitz: in den Zieleintrag eingearbeitet, Quell-Eintrag gelöscht | +| `favorite_games`, `favorite_conflicts` | Favoriten umgehängt bzw. wegen vorhandenem Favoriten am Ziel entfallen | +| `plays`, `score_sheets` | umgehängte Partien und Wertungsbögen | +| `variants_copied`, `variants_mapped` | Varianten, die am Ziel neu angelegt bzw. auf eine gleichnamige abgebildet wurden | +| `variants_cleared` | Sammlungseinträge und Partien, deren Edition geleert wurde. Beim Zusammenführen zweier Katalogspiele bleibt das `0`; es kommt nur beim Umhängen privater Einträge vor (PR 2c) | +| `bgg_id_transferred`, `source_transferred` | `true`, wenn das Ziel die BGG-Kennung bzw. die Quelle der Quelle übernommen hat | +| `bgg_id_conflict` | nur vorhanden, wenn beide eine **unterschiedliche** BGG-Kennung hatten: `{"source": 13, "target": 42}`. Das Ziel behält seinen Wert, der Wert der Quelle ist geleert und steht **nur noch hier**. Bei Bedarf notieren und prüfen, ob das Ziel das richtige Spiel ist | +| `source_conflict` | wie `bgg_id_conflict` für die Quelle (`source` und `target` mit `catalog_source_id`, `source_ref`, `source_url`, `source_synced_at`) | +| `chain_flattened` | Anzahl früher zusammengeführter Spiele, die jetzt direkt auf das Ziel zeigen | + +**Fehlermeldungen** (alle ohne Änderung, `P0001`): + +| Meldung | Bedeutung | +|---|---| +| `admin_only` | Die Sitzung läuft nicht als `postgres` bzw. `supabase_admin` (`select current_user;` prüfen) | +| `merge_same_game` | Quelle und Ziel sind dieselbe ID | +| `merge_game_not_found` | Quelle oder Ziel gibt es nicht (ID vertippt?) | +| `merge_not_catalog` | Quelle oder Ziel ist ein privater Eintrag; Zusammenführen gilt nur für Katalogspiele | +| `merge_target_not_listed` | Das Ziel ist ausgeblendet oder selbst schon zusammengeführt; das endgültige Ziel wählen oder zuerst wieder einblenden (siehe 4) | +| `merge_source_already_merged` | Die Quelle ist bereits zusammengeführt | + +Ein Aufruf über die App-Schnittstelle (als `anon` oder `authenticated`, auch mit Admin-Claims) scheitert mit `42501`; die Funktion ist nur für die Datenbankrolle ausführbar (E10). Bei Zeitüberschreitung oder anderem Fehler `rollback;` und erneut prüfen. Löscht man ein Zielspiel, auf das etwas verweist, schlägt das mit `23503` fehl (gewollt: ausblenden statt löschen). ## 6. Meldungen bearbeiten diff --git a/supabase/migrations/20261103193928_catalog_merge.sql b/supabase/migrations/20261103193928_catalog_merge.sql new file mode 100644 index 0000000..bf7e096 --- /dev/null +++ b/supabase/migrations/20261103193928_catalog_merge.sql @@ -0,0 +1,280 @@ +-- KAT-05 / PR 2b: Zusammenführen (Spec docs/specs/2-katalog-daten.md, Abschnitt 3.3). +-- Nur additiv: zwei neue Funktionen, keine neuen Tabellen. +-- repoint_game_references(p_from, p_to) interne Funktion: hängt alle Verweise um +-- admin_merge_games(p_source, p_target) Admin-Funktion: zwei Katalogspiele zusammenführen +-- Beide sind security invoker mit leerem search_path (alles schema-qualifiziert) und nur für den +-- Eigentümer (postgres) ausführbar. Es gibt in R1 keinen API-Aufrufer (E10). +-- admin_resolve_report (privater Eintrag -> Katalogspiel) und der Verweis game_reports.target_game_id +-- kommen mit PR 2c; repoint_game_references ist dafür schon vorbereitet. + +-- --------------------------------------------------------------------------------------------- +-- Interne Funktion: alle Verweise von p_from auf p_to umhängen (in der Transaktion des Aufrufers) +-- --------------------------------------------------------------------------------------------- +create function public.repoint_game_references(p_from uuid, p_to uuid) +returns jsonb +language plpgsql +security invoker +set search_path = '' +as $$ +declare + from_visibility text; + -- Zuordnung "alte Variante -> neue Variante" (Schlüssel: alte ID als Text; JSON null = geleert). + -- Bewusst keine temporäre Tabelle: mehrere Aufrufe in einer Transaktion bleiben möglich. + vmap jsonb := '{}'::jsonb; + existing_variants uuid[]; + v record; + match_id uuid; + new_id uuid; + r record; + n_copied integer := 0; + n_mapped integer := 0; + n_cleared integer := 0; + n_cleared_part integer; + n_ci integer := 0; + n_ci_conflicts integer := 0; + n_fav integer := 0; + n_fav_conflicts integer := 0; + n_plays integer; + n_sheets integer; + new_variant uuid; + new_list text; + list_rank constant jsonb := '{"owned": 1, "wishlist": 2, "shopping": 3}'::jsonb; +begin + if p_from = p_to then + raise exception 'repoint_same_game' using errcode = 'P0001'; + end if; + if not exists ( + select 1 from public.games g + where g.id = p_to and g.visibility = 'catalog' and g.hidden_at is null and g.merged_into_id is null + ) then + raise exception 'repoint_target_not_listed' using errcode = 'P0001'; + end if; + + select g.visibility into from_visibility from public.games g where g.id = p_from; + + -- Varianten: Ob kopiert wird, folgt aus der Sichtbarkeit der Quelle (Spec 3.3, RB1). + -- Katalogspiel -> gleichnamige Zielvariante, sonst Kopie am Ziel + -- privater Eintrag -> gleichnamige Zielvariante, sonst keine (null); nichts wird kopiert + -- Namensabgleich: lower(trim(name)). Mehrere gleichnamige Zielvarianten: immer die älteste + -- (created_at), bei Gleichstand die kleinste id. Abgeglichen wird nur gegen die Varianten, die + -- das Ziel vor diesem Aufruf schon hatte (nicht gegen eben kopierte). + existing_variants := array(select gv.id from public.game_variants gv where gv.game_id = p_to); + for v in + select gv.id, gv.name, gv.year, gv.language, gv.bgg_id + from public.game_variants gv + where gv.game_id = p_from + order by gv.created_at, gv.id + loop + select gv.id into match_id + from public.game_variants gv + where gv.id = any(existing_variants) and lower(btrim(gv.name)) = lower(btrim(v.name)) + order by gv.created_at, gv.id + limit 1; + + if match_id is not null then + vmap := vmap || jsonb_build_object(v.id::text, match_id); + n_mapped := n_mapped + 1; + elsif from_visibility = 'catalog' then + new_id := gen_random_uuid(); + insert into public.game_variants (id, game_id, name, year, language, bgg_id) + values (new_id, p_to, v.name, v.year, v.language, v.bgg_id); + vmap := vmap || jsonb_build_object(v.id::text, new_id); + n_copied := n_copied + 1; + else + vmap := vmap || jsonb_build_object(v.id::text, null); + end if; + end loop; + + -- 1. collection_items. Kollision (derselbe Nutzer hat p_to schon, Spec-F1): Der Zieleintrag + -- bleibt, leere Felder füllt der Quell-Eintrag, die Liste gewinnt in der Reihenfolge + -- owned > wishlist > shopping (Klärung 34); die Variante läuft immer über vmap. Danach wird der + -- Quell-Eintrag gelöscht. Felder aus SAM-02/SAM-04 gibt es noch nicht; sie kommen dort dazu. + for r in + select s.id as sid, s.list as slist, s.note as snote, s.acquired_on as sacq, s.variant_id as svar, + t.id as tid, t.list as tlist, t.note as tnote, t.acquired_on as tacq, t.variant_id as tvar + from public.collection_items s + join public.collection_items t on t.owner_id = s.owner_id and t.game_id = p_to + where s.game_id = p_from + order by s.id + loop + new_variant := coalesce(r.tvar, (vmap ->> r.svar::text)::uuid); + if r.tvar is null and r.svar is not null and new_variant is null then + n_cleared := n_cleared + 1; + end if; + new_list := case when (list_rank ->> r.slist)::int < (list_rank ->> r.tlist)::int + then r.slist else r.tlist end; + update public.collection_items c + set list = new_list, + note = case when c.note is null or c.note = '' then r.snote else c.note end, + acquired_on = coalesce(c.acquired_on, r.sacq), + variant_id = new_variant + where c.id = r.tid; + delete from public.collection_items c where c.id = r.sid; + n_ci_conflicts := n_ci_conflicts + 1; + end loop; + + select count(*) into n_cleared_part from public.collection_items c + where c.game_id = p_from and c.variant_id is not null and (vmap ->> c.variant_id::text) is null; + n_cleared := n_cleared + n_cleared_part; + update public.collection_items c + set game_id = p_to, variant_id = (vmap ->> c.variant_id::text)::uuid + where c.game_id = p_from; + get diagnostics n_ci = row_count; + + -- 2. favorite_games: Hat der Nutzer p_to schon als Favorit, entfällt die Zeile zu p_from + -- (die Lücke in position ist zulässig). + delete from public.favorite_games f + where f.game_id = p_from + and exists (select 1 from public.favorite_games t + where t.profile_id = f.profile_id and t.game_id = p_to); + get diagnostics n_fav_conflicts = row_count; + update public.favorite_games f set game_id = p_to where f.game_id = p_from; + get diagnostics n_fav = row_count; + + -- 3. plays (mit Variante über vmap) + select count(*) into n_cleared_part from public.plays p + where p.game_id = p_from and p.variant_id is not null and (vmap ->> p.variant_id::text) is null; + n_cleared := n_cleared + n_cleared_part; + update public.plays p + set game_id = p_to, variant_id = (vmap ->> p.variant_id::text)::uuid + where p.game_id = p_from; + get diagnostics n_plays = row_count; + + -- 4. score_sheets. Sobald PAR-04 (#18) "ein Bogen je Spiel und Nutzer" erzwingt, braucht + -- dieser Schritt eine Kollisionsregel (Spec 9.5). + update public.score_sheets s set game_id = p_to where s.game_id = p_from; + get diagnostics n_sheets = row_count; + + -- 5. Nur Katalog -> Katalog: die alten Varianten sind jetzt unreferenziert (kopiert oder + -- abgebildet) und werden gelöscht. Private Varianten bleiben am ausgeblendeten vorläufigen + -- Eintrag, sie gehören dem Nutzer. + if from_visibility = 'catalog' then + delete from public.game_variants gv where gv.game_id = p_from; + end if; + + -- 6. Zuordnungen (Genre, Mechanik, Kategorie, Verlag) bleiben am Quell-Eintrag. + + return jsonb_build_object( + 'collection_items', n_ci, + 'collection_conflicts', n_ci_conflicts, + 'favorite_games', n_fav, + 'favorite_conflicts', n_fav_conflicts, + 'plays', n_plays, + 'score_sheets', n_sheets, + 'variants_copied', n_copied, + 'variants_mapped', n_mapped, + 'variants_cleared', n_cleared + ); +end; +$$; + +-- --------------------------------------------------------------------------------------------- +-- Admin-Funktion: zwei Katalogspiele zusammenführen +-- security invoker (kein security definer): Die Rollenprüfung ist die einzige Schranke, und der +-- Aufrufer braucht ohnehin die Rechte von postgres/supabase_admin. Kein is_admin()-Pfad (E10). +-- --------------------------------------------------------------------------------------------- +create function public.admin_merge_games(p_source uuid, p_target uuid) +returns jsonb +language plpgsql +security invoker +set search_path = '' +as $$ +declare + s public.games; + t public.games; + result jsonb; + n_chain integer; + bgg_transferred boolean := false; + source_transferred boolean := false; +begin + if current_user not in ('postgres', 'supabase_admin') then + raise exception 'admin_only' using errcode = 'P0001'; + end if; + if p_source = p_target then + raise exception 'merge_same_game' using errcode = 'P0001'; + end if; + if p_source is null or p_target is null then + raise exception 'merge_game_not_found' using errcode = 'P0001'; + end if; + + -- Beide Zeilen in fester Reihenfolge sperren, damit zwei gleichzeitige Aufrufe nicht verklemmen. + perform 1 from public.games g where g.id in (p_source, p_target) order by g.id for update; + select * into s from public.games g where g.id = p_source; + if not found then + raise exception 'merge_game_not_found' using errcode = 'P0001'; + end if; + select * into t from public.games g where g.id = p_target; + if not found then + raise exception 'merge_game_not_found' using errcode = 'P0001'; + end if; + if s.visibility <> 'catalog' or t.visibility <> 'catalog' then + raise exception 'merge_not_catalog' using errcode = 'P0001'; + end if; + if t.hidden_at is not null or t.merged_into_id is not null then + raise exception 'merge_target_not_listed' using errcode = 'P0001'; + end if; + if s.merged_into_id is not null then + raise exception 'merge_source_already_merged' using errcode = 'P0001'; + end if; + + -- 1. Verweise umhängen (beide Katalog -> Varianten werden kopiert) + result := public.repoint_game_references(p_source, p_target); + + -- 2. BGG-Kennung und Quelle übernehmen (W6). Wegen der eindeutigen Indizes (bgg_id im Katalog, + -- (Quelle, source_ref) im Katalog) zuerst an der Quelle leeren, dann am Ziel setzen. + -- Gleichzeitig wird die Quelle als zusammengeführt und ausgeblendet markiert (ein Statement: + -- merged_into_id verlangt hidden_at). Bereits ausgeblendete Quellen behalten ihr hidden_at. + update public.games g + set bgg_id = null, + catalog_source_id = null, source_ref = null, source_url = null, source_synced_at = null, + merged_into_id = p_target, + hidden_at = coalesce(g.hidden_at, now()) + where g.id = p_source; + + if s.bgg_id is not null then + if t.bgg_id is null then + update public.games g set bgg_id = s.bgg_id where g.id = p_target; + bgg_transferred := true; + elsif t.bgg_id <> s.bgg_id then + -- Das Ziel behält seinen Wert; der geleerte Wert der Quelle bleibt hier erhalten. + result := result || jsonb_build_object('bgg_id_conflict', + jsonb_build_object('source', s.bgg_id, 'target', t.bgg_id)); + end if; + end if; + + if s.catalog_source_id is not null then + if t.catalog_source_id is null then + update public.games g + set catalog_source_id = s.catalog_source_id, source_ref = s.source_ref, + source_url = s.source_url, source_synced_at = s.source_synced_at + where g.id = p_target; + source_transferred := true; + elsif (t.catalog_source_id, t.source_ref) is distinct from (s.catalog_source_id, s.source_ref) then + result := result || jsonb_build_object('source_conflict', jsonb_build_object( + 'source', jsonb_build_object('catalog_source_id', s.catalog_source_id, 'source_ref', s.source_ref, + 'source_url', s.source_url, 'source_synced_at', s.source_synced_at), + 'target', jsonb_build_object('catalog_source_id', t.catalog_source_id, 'source_ref', t.source_ref, + 'source_url', t.source_url, 'source_synced_at', t.source_synced_at))); + end if; + end if; + + -- 3. Ketten glätten: Spiele, die bisher auf die Quelle zeigten, zeigen jetzt auf das Ziel. + update public.games g set merged_into_id = p_target where g.merged_into_id = p_source; + get diagnostics n_chain = row_count; + + -- 4. Ab 2c zusätzlich: game_reports.target_game_id von p_source auf p_target. + + return result || jsonb_build_object( + 'source_id', p_source, + 'target_id', p_target, + 'bgg_id_transferred', bgg_transferred, + 'source_transferred', source_transferred, + 'chain_flattened', n_chain + ); +end; +$$; + +-- Rechte-Regel: nur der Eigentümer darf ausführen (Supabase vergibt sonst an anon, authenticated, +-- service_role). Der Import und spätere Stories ergänzen gezielt Rechte, falls nötig. +revoke execute on function public.repoint_game_references(uuid, uuid) from public, anon, authenticated, service_role; +revoke execute on function public.admin_merge_games(uuid, uuid) from public, anon, authenticated, service_role; diff --git a/supabase/tests/database/07_catalog_merge.test.sql b/supabase/tests/database/07_catalog_merge.test.sql new file mode 100644 index 0000000..e157d3e --- /dev/null +++ b/supabase/tests/database/07_catalog_merge.test.sql @@ -0,0 +1,484 @@ +-- 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') as ergebnis; + +select is((select ergebnis 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;