From daeae999ddbb65d1279d281e76f2d72c988e9e65 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Tue, 3 Nov 2026 20:46:19 +0100 Subject: [PATCH 1/4] 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; From 2c2546ab472d796e2253fcf5f08302801a8e600f Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Tue, 3 Nov 2026 20:49:42 +0100 Subject: [PATCH 2/4] docs(db): runbook section merging, data model and spec notes (KAT-05, 2b) Refs #2 Co-Authored-By: Claude Sonnet 5.5 --- docs/datenmodell-r1.md | 2 +- docs/specs/2-katalog-daten.md | 15 +++++++++++++++ supabase/README.md | 1 + supabase/tests/database/07_catalog_merge.test.sql | 4 ++-- 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/docs/datenmodell-r1.md b/docs/datenmodell-r1.md index 2e0d958..d4bba07 100644 --- a/docs/datenmodell-r1.md +++ b/docs/datenmodell-r1.md @@ -342,7 +342,7 @@ Drei Ansichten (Views) kommen dazu: `public_profiles` zeigt Mitspielern Name, Av Die Pflege läuft in R1 nur über Supabase Studio bzw. `psql` als Datenbankrolle `postgres` (Klärungen 25/44, Ablauf: `docs/katalog-pflege.md`). Spec: `docs/specs/2-katalog-daten.md`. - **Ausblenden:** `games.hidden_at` gesetzt = ausgeblendet. Die View `catalog_games` enthält nur Katalogspiele (`visibility = 'catalog'`) ohne `hidden_at` und ohne `merged_into_id`. **Suche und Entdecken lesen nur diese View.** Ein ausgeblendetes Spiel bleibt per ID lesbar (Sammlungen, Partien und Detailseite verweisen darauf). -- **Zusammenführen:** `games.merged_into_id` zeigt auf das Zielspiel (Spalte seit 2a; die Funktion `admin_merge_games` kommt mit PR 2b). Ein Trigger lässt nur Katalogspiele als Ziel zu (Fehler `merged_into_not_catalog`). +- **Zusammenführen** (Migration `catalog_merge`, PR 2b): `games.merged_into_id` zeigt auf das Zielspiel; ein Trigger lässt nur Katalogspiele als Ziel zu (Fehler `merged_into_not_catalog`). Gesetzt wird die Spalte nur von der Funktion `public.admin_merge_games(p_source, p_target) returns jsonb` (Ablauf und Ergebnis-JSON: `docs/katalog-pflege.md`, Abschnitt 5). Sie ist `security invoker`, prüft `current_user in ('postgres', 'supabase_admin')` (sonst `admin_only`) und ist für `anon`, `authenticated` und `service_role` nicht ausführbar; einen API-Aufruf gibt es nicht. Beide Spiele müssen Katalogspiele sein, das Ziel sichtbar. Die Funktion hängt über die interne Funktion `public.repoint_game_references(p_from, p_to)` Sammlung, Favoriten, Partien (mit Edition) und Wertungsbögen um, kopiert die Varianten der Quelle ans Ziel (gleichnamige Zielvarianten werden abgebildet: `lower(btrim(name))`, bei mehreren die älteste, dann die kleinste `id`) und löscht danach die alten Varianten. Bei Doppelbesitz bleibt der Zieleintrag, leere Felder füllt die Quelle, die Liste folgt `owned` vor `wishlist` vor `shopping`, der Quell-Eintrag wird gelöscht; ein Favorit der Quelle entfällt, wenn der Nutzer das Ziel schon als Favorit hat. `bgg_id` und `catalog_source_id`/`source_*` gehen aufs Ziel über, wenn es keine hat; sonst werden sie an der Quelle trotzdem geleert und der Konflikt mit den alten Werten im Ergebnis gemeldet. Die Quelle wird nie gelöscht, sondern ausgeblendet und verwiesen (`hidden_at`, `merged_into_id`); Ketten werden geglättet. Zuordnungen (Genre, Mechanik, Kategorie, Verlag) bleiben am Quell-Eintrag. Ein pgTAP-Wächter (`07`) listet alle Fremdschlüssel auf `games` und lässt neue Verweise erst nach einer Entscheidung zu. - **Manuell gepflegte Felder:** Der Trigger `games_mark_manual_fields` trägt jedes Feld eines Katalogspiels, das ein Mensch setzt oder ändert, in `manual_fields` ein (beim Anlegen alle gesetzten Felder). Der Trigger `game_links_mark_manual` ergänzt `genres`, `mechanics`, `categories` und `publishers`, wenn Zuordnungen angelegt, geändert oder gelöscht werden. Ausgenommen ist nur ein späterer Import: Rolle `catalog_importer` **und** Einstellung `shuffle.catalog_import = 'on'` (der Import setzt sie per `set local`; das ist Konvention, die Rolle ist die Sperre). Private Spiele werden nicht gekennzeichnet. Eine bewusst entfernte Markierung entfernt der Trigger nicht wieder. - **Anzeige-Erlaubnis:** `catalog_games.description` und `image_path` sind leer, wenn die Quelle (`catalog_sources`) Texte bzw. Bilder nicht erlaubt und das Feld nicht in `manual_fields` steht. Das ist nur die zweite Sicherung: Daten ohne Erlaubnis dürfen gar nicht erst gespeichert werden (Pflicht für den Import). - **Spaltenrechte auf `games`:** Nutzer dürfen nur `owner_id` (nur beim Anlegen), `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `bgg_id` und `visibility` schreiben, nicht `id`, `hidden_at`, `merged_into_id`, `manual_fields`, `catalog_source_id` und `source_*`. diff --git a/docs/specs/2-katalog-daten.md b/docs/specs/2-katalog-daten.md index b2e0134..db2e5c0 100644 --- a/docs/specs/2-katalog-daten.md +++ b/docs/specs/2-katalog-daten.md @@ -703,3 +703,18 @@ Umgesetzt: B1, B2, B3a, B3, B4 und die beiden CI-Schritte aus D1 (Deno-Tests und - **Ergänzung Testdaten:** `supabase/catalog/testdata/` (fiktives Vokabular und drei Testspiele) erzeugt `supabase/tests/support/*.inc`, die `06_catalog_foundation.test.sql` per `\ir` einbindet; so laufen Idempotenz und `checks.sql` im pgTAP-Test mit den echten erzeugten Skripten. - **`game_links_mark_manual`:** prüft zuerst per `select`, ob das Spiel ein Katalogspiel ist, und schreibt nur dann `manual_fields`. Ohne diese Prüfung scheiterte jede Zuordnung zu einem privaten Spiel mit 42501, weil Nutzer kein Schreibrecht auf `manual_fields` haben. - **Werkzeug:** Deno wird für das Skript gebraucht (lokal 2.9.8, in CI gepinnt über `DENO_VERSION`). Keine externen Importe. + +### 2026-11-03 – Umsetzung PR 2b (backend-dev) + +Umgesetzt: B5 (Migration `20261103193928_catalog_merge.sql`), B6 (pgTAP `07_catalog_merge.test.sql`, 89 Tests), B7 (Runbook Abschnitt 5). Abweichungen und Ergänzungen, jeweils zur Prüfung im PR: + +- **Branch:** `feature/2b-katalog-zusammenfuehren` statt `feature/2-katalog-pflege` (Vorgabe des Auftrags). +- **Keine neue Tabelle:** `00_schema.test.sql` bleibt unverändert (20 Tabellen). +- **Ergebnis-JSON (festgelegt):** `collection_items` (ohne Kollision umgehängt), `collection_conflicts`, `favorite_games`, `favorite_conflicts`, `plays`, `score_sheets`, `variants_copied`, `variants_mapped`, `variants_cleared`; `admin_merge_games` ergänzt `source_id`, `target_id`, `bgg_id_transferred`, `source_transferred`, `chain_flattened` und bei Konflikt `bgg_id_conflict` / `source_conflict`. **`variants_cleared` zählt Verweise** (Sammlungseinträge und Partien, deren Edition geleert wurde), nicht Varianten; so erkennt der Admin und später #13, ob bei einem Nutzer eine Edition verloren ging. Beim Zusammenführen zweier Katalogspiele ist er immer 0. `source_conflict` enthält zusätzlich `source_synced_at` (alle vier Werte der Gruppe bleiben erhalten). +- **Zusätzliche Prüfung:** `admin_merge_games(null, …)` / `(…, null)` meldet `merge_game_not_found`. `admin_merge_games` sperrt beide Zeilen in fester Reihenfolge (`for update`), damit parallele Aufrufe nicht verklemmen. +- **Variantenzuordnung** ist ein jsonb-Wert in der Funktion, keine temporäre Tabelle (mehrere Aufrufe in einer Transaktion bleiben möglich). Der Namensabgleich läuft nur gegen die Zielvarianten, die vor dem Aufruf existierten; jede Quellvariante wird einzeln kopiert, auch wenn zwei Quellvarianten gleich heißen (wörtliche Auslegung von 3.3). +- **Kollisionsregel Spec-F1** füllt nur `note`, `acquired_on` und `variant_id`; die Felder aus SAM-02/SAM-04 gibt es noch nicht (Hinweis 9.5 bleibt). Eine leere Notiz (`null` oder leerer Text) am Ziel gilt als leer. +- **`repoint_game_references` prüft nicht, ob `p_from` existiert** (nur die beiden Fehler aus 3.3); `admin_merge_games` prüft es. Der Fall „privater Eintrag auf Katalogspiel“ ist in 2b nur direkt gegen `repoint_game_references` getestet; `admin_resolve_report` kommt mit 2c. +- **Schritt 4 aus 3.3** (`game_reports.target_game_id` umhängen) entfällt bis 2c, wie vorgesehen. +- **Hinweis für 2c (B9):** Sobald `game_reports` zwei Fremdschlüssel auf `games` bekommt, wird der Wächter in `07` rot. Die erwartete Liste in `07` muss dort um `game_reports.target_game_id` (behandelt) und `game_reports.provisional_game_id` (bewusst ausgelassen) erweitert werden, nicht nur der Wächter in `08`. +- **Runbook:** Alle SQL-Bausteine aus Abschnitt 5 laufen wörtlich in `07` (einmalige Prüfung per Skript; keine CI-Prüfung). Abschnitt 5 enthält die Duplikatsuche, eine Vorschau (Verweise, Doppelbesitz), den Aufruf, die Nachprüfung, die Regeln, die Schlüssel des Ergebnis-JSON und die Fehlertexte. diff --git a/supabase/README.md b/supabase/README.md index 3a4af99..6f67b9d 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -32,6 +32,7 @@ supabase test db # alle pgTAP-Tests ausführen ## Katalog (KAT-05) - Pflege in R1 nur über Studio bzw. `psql` (Rolle `postgres`), Ablauf und Regeln: [`docs/katalog-pflege.md`](../docs/katalog-pflege.md). Spec: `docs/specs/2-katalog-daten.md`. - Suche und Entdecken lesen nur die View `catalog_games` (ohne ausgeblendete und zusammengeführte Spiele). Geänderte Fakten trägt ein Trigger in `games.manual_fields` ein. +- Zusammenführen von Duplikaten nur über die Funktion `public.admin_merge_games(quelle, ziel)` (nur Datenbankrolle `postgres`/`supabase_admin`, nicht über die API aufrufbar); Ablauf in `docs/katalog-pflege.md`, Abschnitt 5. Jede neue Tabelle mit Verweis auf `games` muss dort behandelt werden (Wächter in `tests/database/07_catalog_merge.test.sql`). - `db reset` spielt `seed.sql`, `catalog/vocabulary.sql` und `catalog/startbestand.sql` ein (`[db.seed] sql_paths` in `config.toml`). Die SQL-Dateien in `catalog/` sind **erzeugt**; Quellen sind die CSV-Dateien. Neu erzeugen und testen (Deno, `brew install deno`): ```bash deno run --allow-read=supabase --allow-write=supabase supabase/catalog/build_startbestand.ts diff --git a/supabase/tests/database/07_catalog_merge.test.sql b/supabase/tests/database/07_catalog_merge.test.sql index e157d3e..28f690f 100644 --- a/supabase/tests/database/07_catalog_merge.test.sql +++ b/supabase/tests/database/07_catalog_merge.test.sql @@ -202,9 +202,9 @@ select set_eq($$select owner_id, liste_quelle, liste_ziel from rb_dbl$$, -- 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 public.admin_merge_games('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003'); -select is((select ergebnis from rb_merge), +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, From 127f765d856e3094cefc45851ca01347bc63728e Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Tue, 3 Nov 2026 21:04:12 +0100 Subject: [PATCH 3/4] test(db): close surviving mutants in merge tests, extend runbook (KAT-05, 2b) Refs #2 Co-Authored-By: Claude Sonnet 5.5 --- docs/datenmodell-r1.md | 2 +- docs/katalog-pflege.md | 28 ++++- docs/specs/2-katalog-daten.md | 2 + .../tests/database/07_catalog_merge.test.sql | 100 ++++++++++++++++-- 4 files changed, 120 insertions(+), 12 deletions(-) diff --git a/docs/datenmodell-r1.md b/docs/datenmodell-r1.md index d4bba07..cdfb50b 100644 --- a/docs/datenmodell-r1.md +++ b/docs/datenmodell-r1.md @@ -342,7 +342,7 @@ Drei Ansichten (Views) kommen dazu: `public_profiles` zeigt Mitspielern Name, Av Die Pflege läuft in R1 nur über Supabase Studio bzw. `psql` als Datenbankrolle `postgres` (Klärungen 25/44, Ablauf: `docs/katalog-pflege.md`). Spec: `docs/specs/2-katalog-daten.md`. - **Ausblenden:** `games.hidden_at` gesetzt = ausgeblendet. Die View `catalog_games` enthält nur Katalogspiele (`visibility = 'catalog'`) ohne `hidden_at` und ohne `merged_into_id`. **Suche und Entdecken lesen nur diese View.** Ein ausgeblendetes Spiel bleibt per ID lesbar (Sammlungen, Partien und Detailseite verweisen darauf). -- **Zusammenführen** (Migration `catalog_merge`, PR 2b): `games.merged_into_id` zeigt auf das Zielspiel; ein Trigger lässt nur Katalogspiele als Ziel zu (Fehler `merged_into_not_catalog`). Gesetzt wird die Spalte nur von der Funktion `public.admin_merge_games(p_source, p_target) returns jsonb` (Ablauf und Ergebnis-JSON: `docs/katalog-pflege.md`, Abschnitt 5). Sie ist `security invoker`, prüft `current_user in ('postgres', 'supabase_admin')` (sonst `admin_only`) und ist für `anon`, `authenticated` und `service_role` nicht ausführbar; einen API-Aufruf gibt es nicht. Beide Spiele müssen Katalogspiele sein, das Ziel sichtbar. Die Funktion hängt über die interne Funktion `public.repoint_game_references(p_from, p_to)` Sammlung, Favoriten, Partien (mit Edition) und Wertungsbögen um, kopiert die Varianten der Quelle ans Ziel (gleichnamige Zielvarianten werden abgebildet: `lower(btrim(name))`, bei mehreren die älteste, dann die kleinste `id`) und löscht danach die alten Varianten. Bei Doppelbesitz bleibt der Zieleintrag, leere Felder füllt die Quelle, die Liste folgt `owned` vor `wishlist` vor `shopping`, der Quell-Eintrag wird gelöscht; ein Favorit der Quelle entfällt, wenn der Nutzer das Ziel schon als Favorit hat. `bgg_id` und `catalog_source_id`/`source_*` gehen aufs Ziel über, wenn es keine hat; sonst werden sie an der Quelle trotzdem geleert und der Konflikt mit den alten Werten im Ergebnis gemeldet. Die Quelle wird nie gelöscht, sondern ausgeblendet und verwiesen (`hidden_at`, `merged_into_id`); Ketten werden geglättet. Zuordnungen (Genre, Mechanik, Kategorie, Verlag) bleiben am Quell-Eintrag. Ein pgTAP-Wächter (`07`) listet alle Fremdschlüssel auf `games` und lässt neue Verweise erst nach einer Entscheidung zu. +- **Zusammenführen** (Migration `catalog_merge`, PR 2b): `games.merged_into_id` zeigt auf das Zielspiel; ein Trigger lässt nur Katalogspiele als Ziel zu (Fehler `merged_into_not_catalog`). **Regel: `merged_into_id` wird nur über `public.admin_merge_games(p_source, p_target) returns jsonb` gesetzt, nie von Hand.** (Ablauf und Ergebnis-JSON: `docs/katalog-pflege.md`, Abschnitt 5). Sie ist `security invoker`, prüft `current_user in ('postgres', 'supabase_admin')` (sonst `admin_only`) und ist für `anon`, `authenticated` und `service_role` nicht ausführbar; einen API-Aufruf gibt es nicht. Beide Spiele müssen Katalogspiele sein, das Ziel sichtbar. Die Funktion hängt über die interne Funktion `public.repoint_game_references(p_from, p_to)` Sammlung, Favoriten, Partien (mit Edition) und Wertungsbögen um, kopiert die Varianten der Quelle ans Ziel (gleichnamige Zielvarianten werden abgebildet: `lower(btrim(name))`, bei mehreren die älteste, dann die kleinste `id`) und löscht danach die alten Varianten. Bei Doppelbesitz bleibt der Zieleintrag, leere Felder füllt die Quelle, die Liste folgt `owned` vor `wishlist` vor `shopping`, der Quell-Eintrag wird gelöscht; ein Favorit der Quelle entfällt, wenn der Nutzer das Ziel schon als Favorit hat. `bgg_id` und `catalog_source_id`/`source_*` gehen aufs Ziel über, wenn es keine hat; sonst werden sie an der Quelle trotzdem geleert und der Konflikt mit den alten Werten im Ergebnis gemeldet. Die Quelle wird nie gelöscht, sondern ausgeblendet und verwiesen (`hidden_at`, `merged_into_id`); Ketten werden geglättet. Zuordnungen (Genre, Mechanik, Kategorie, Verlag) bleiben am Quell-Eintrag. Ein pgTAP-Wächter (`07`) listet alle Fremdschlüssel auf `games` und lässt neue Verweise erst nach einer Entscheidung zu. - **Manuell gepflegte Felder:** Der Trigger `games_mark_manual_fields` trägt jedes Feld eines Katalogspiels, das ein Mensch setzt oder ändert, in `manual_fields` ein (beim Anlegen alle gesetzten Felder). Der Trigger `game_links_mark_manual` ergänzt `genres`, `mechanics`, `categories` und `publishers`, wenn Zuordnungen angelegt, geändert oder gelöscht werden. Ausgenommen ist nur ein späterer Import: Rolle `catalog_importer` **und** Einstellung `shuffle.catalog_import = 'on'` (der Import setzt sie per `set local`; das ist Konvention, die Rolle ist die Sperre). Private Spiele werden nicht gekennzeichnet. Eine bewusst entfernte Markierung entfernt der Trigger nicht wieder. - **Anzeige-Erlaubnis:** `catalog_games.description` und `image_path` sind leer, wenn die Quelle (`catalog_sources`) Texte bzw. Bilder nicht erlaubt und das Feld nicht in `manual_fields` steht. Das ist nur die zweite Sicherung: Daten ohne Erlaubnis dürfen gar nicht erst gespeichert werden (Pflicht für den Import). - **Spaltenrechte auf `games`:** Nutzer dürfen nur `owner_id` (nur beim Anlegen), `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `bgg_id` und `visibility` schreiben, nicht `id`, `hidden_at`, `merged_into_id`, `manual_fields`, `catalog_source_id` und `source_*`. diff --git a/docs/katalog-pflege.md b/docs/katalog-pflege.md index 5d0b200..0da7bcb 100644 --- a/docs/katalog-pflege.md +++ b/docs/katalog-pflege.md @@ -233,12 +233,22 @@ join public.collection_items t on t.owner_id = s.owner_id and t.game_id = '7a1b2 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: +**Schritt 3: Zusammenführen.** Der Baustein steht wie überall ohne `begin`/`commit`; er wird in einer Transaktion ausgeführt. Bei einem Fehler wird nichts geändert: ```sql select public.admin_merge_games('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003'); ``` +**Trockenlauf (empfohlen vor dem ersten echten Merge).** Das Ergebnis lesen und alles zurückrollen, **in einem einzigen Lauf**: + +```sql +begin; +select public.admin_merge_games('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003'); +rollback; +``` + +Danach ist nichts verändert; mit `commit;` statt `rollback;` wird der Lauf endgültig. Achtung: `begin;` und `commit;`/`rollback;` in getrennten Läufen funktionieren nur in `psql` (eine Sitzung). Der SQL-Editor in Studio führt jeden Lauf womöglich in einer eigenen Verbindung bzw. Transaktion aus; dort immer alles in einem Lauf ausführen. (Dieser Trockenlauf enthält Transaktionssteuerung und kann deshalb nicht wörtlich in pgTAP laufen; der enthaltene Aufruf ist identisch zu Schritt 3 und dort getestet.) + **Schritt 4: Nachprüfen.** Der Vorschau-Baustein aus Schritt 2 (mit der Quelle) muss jetzt überall `0` liefern. Zustand beider Einträge: ```sql @@ -246,7 +256,11 @@ select id, title, hidden_at is not null as ausgeblendet, merged_into_id, bgg_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. +Die Quelle ist ausgeblendet und verweist auf das Ziel. Das Ziel bleibt sichtbar; Fakten, Kurztext und Zuordnungen sind unverändert. **Ändern können sich `bgg_id` und die Quelle (`catalog_source_id`, `source_*`)**, wenn das Ziel sie von der Quelle übernommen hat (siehe `bgg_id_transferred` und `source_transferred` im Ergebnis). Außerdem darf kein Spiel mehr auf die Quelle verweisen; diese Abfrage muss leer sein (Ketten sind geglättet): + +```sql +select id from public.games where merged_into_id = '7a1b2c3d-0000-4000-8000-000000000002'; +``` **Was die Funktion macht** @@ -255,7 +269,7 @@ Die Quelle ist ausgeblendet und verweist auf das Ziel, das Ziel ist unverändert | 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. | +| Varianten (Editionen) | Für jede Variante der Quelle: Hat das Ziel eine gleichnamige Variante (Vergleich ohne Groß-/Kleinschreibung und ohne **Leerzeichen** am Rand; Tabulatoren und Zeilenumbrüche am Rand zählen als Teil des Namens), 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. | @@ -289,9 +303,15 @@ Die Quelle ist ausgeblendet und verweist auf das Ziel, das Ziel ist unverändert | `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_target_not_listed` | Das Ziel ist ausgeblendet oder selbst schon zusammengeführt. Ist es zusammengeführt, das endgültige Ziel über `merged_into_id` nachschlagen (Baustein unten, hier mit der Quelle aus dem Beispiel; Ketten sind geglättet, ein Schritt genügt) oder ein ausgeblendetes Ziel zuerst wieder einblenden (siehe 4) | | `merge_source_already_merged` | Die Quelle ist bereits zusammengeführt | +Nachschlagen, wohin ein zusammengeführtes Spiel verweist (`merged_into_id` ist das endgültige Ziel): + +```sql +select id, title, hidden_at, merged_into_id from public.games where id = '7a1b2c3d-0000-4000-8000-000000000002'; +``` + 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/docs/specs/2-katalog-daten.md b/docs/specs/2-katalog-daten.md index db2e5c0..6fb2d58 100644 --- a/docs/specs/2-katalog-daten.md +++ b/docs/specs/2-katalog-daten.md @@ -718,3 +718,5 @@ Umgesetzt: B5 (Migration `20261103193928_catalog_merge.sql`), B6 (pgTAP `07_cata - **Schritt 4 aus 3.3** (`game_reports.target_game_id` umhängen) entfällt bis 2c, wie vorgesehen. - **Hinweis für 2c (B9):** Sobald `game_reports` zwei Fremdschlüssel auf `games` bekommt, wird der Wächter in `07` rot. Die erwartete Liste in `07` muss dort um `game_reports.target_game_id` (behandelt) und `game_reports.provisional_game_id` (bewusst ausgelassen) erweitert werden, nicht nur der Wächter in `08`. - **Runbook:** Alle SQL-Bausteine aus Abschnitt 5 laufen wörtlich in `07` (einmalige Prüfung per Skript; keine CI-Prüfung). Abschnitt 5 enthält die Duplikatsuche, eine Vorschau (Verweise, Doppelbesitz), den Aufruf, die Nachprüfung, die Regeln, die Schlüssel des Ergebnis-JSON und die Fehlertexte. + +- **Nachtrag Review 2b:** Tests ergänzt (Spec-F1-Edition am Ziel, `variants_cleared` im Kollisionszweig, gleichnamige Quellvarianten, zweiter Fremdschlüssel-Wächter auf `game_variants`, unbeteiligte Spiele, Ziel `null`); Runbook um Trockenlauf (`begin; …; rollback;` in einem Lauf, nur dieser Baustein läuft nicht wörtlich in pgTAP), Ketten-Nachprüfung und Nachschlagen von `merged_into_id` ergänzt. Der Null-Check in `admin_merge_games` ist ein äquivalenter Mutant (das spätere `not found` liefert denselben Fehler) und bleibt als ausdrückliche Absicherung. Offen für Chris: Verweise, die nach dem Merge noch auf die Quelle entstehen (W3). diff --git a/supabase/tests/database/07_catalog_merge.test.sql b/supabase/tests/database/07_catalog_merge.test.sql index 28f690f..c70e81e 100644 --- a/supabase/tests/database/07_catalog_merge.test.sql +++ b/supabase/tests/database/07_catalog_merge.test.sql @@ -5,7 +5,7 @@ -- 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); +select plan(107); 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"}'), @@ -156,6 +156,16 @@ insert into public.plays (id, owner_id, game_id, variant_id, played_at, client_i 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'); +-- Unbeteiligtes Spiel mit Verweisen: darf vom Zusammenführen nicht berührt werden +insert into public.games (id, title, visibility) values ('f7000000-0000-0000-0000-000000000501', 'Unbeteiligt', 'catalog'); +insert into public.game_variants (id, game_id, name) values ('f7000000-0000-0000-0000-0000000005e1', 'f7000000-0000-0000-0000-000000000501', 'Deluxe'); +insert into public.collection_items (id, owner_id, game_id, variant_id, list, note) values + ('f7000000-0000-0000-0000-0000000005c1', '44444444-4444-4444-4444-444444444444', 'f7000000-0000-0000-0000-000000000501', 'f7000000-0000-0000-0000-0000000005e1', 'wishlist', 'bleibt'); +insert into public.plays (id, owner_id, game_id, variant_id, played_at, client_id) values + ('f7000000-0000-0000-0000-0000000005b1', '44444444-4444-4444-4444-444444444444', 'f7000000-0000-0000-0000-000000000501', 'f7000000-0000-0000-0000-0000000005e1', '2026-04-01', 'f7000000-0000-0000-0000-0000000005b1'); +insert into public.favorite_games (id, profile_id, game_id, position) values + ('f7000000-0000-0000-0000-0000000005d1', '44444444-4444-4444-4444-444444444444', 'f7000000-0000-0000-0000-000000000501', 1); + -- 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, @@ -233,6 +243,17 @@ select set_eq($$select id, ausgeblendet, merged_into_id, bgg_id, catalog_source_ ('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'); +-- Runbook 5, Schritt 4: Nachprüfung auf Ketten (muss leer sein) +create temp table rb_ketten as +select id from public.games where merged_into_id = '7a1b2c3d-0000-4000-8000-000000000002'; +select is_empty($$select * from rb_ketten$$, 'KAT-05 Kriterium 8: Runbook 5, kein Spiel verweist mehr auf die Quelle'); + +-- Runbook 5, Fehlertext merge_target_not_listed: wohin verweist ein zusammengeführtes Spiel? +create temp table rb_verweis as +select id, title, hidden_at, merged_into_id from public.games where id = '7a1b2c3d-0000-4000-8000-000000000002'; +select results_eq($$select merged_into_id from rb_verweis$$, $$values ('7a1b2c3d-0000-4000-8000-000000000003'::uuid)$$, + 'KAT-05 Kriterium 8: Runbook 5, Nachschlagen des endgültigen Ziels über merged_into_id'); + -- 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'); @@ -268,8 +289,16 @@ select is((select row(list, note, acquired_on)::text from public.collection_item 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, +select is((select count(*) from public.collection_items where owner_id = '44444444-4444-4444-4444-444444444444' + and game_id in ('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003')), 1::bigint, 'Spec-F1: Doras Quell-Eintrag ist gelöscht'); +select is((select row(c.game_id, c.variant_id, c.list, c.note)::text || '|' || p.game_id::text || p.variant_id::text || '|' || f.game_id::text || f.position::text + from public.collection_items c, public.plays p, public.favorite_games f + where c.id = 'f7000000-0000-0000-0000-0000000005c1' and p.id = 'f7000000-0000-0000-0000-0000000005b1' and f.id = 'f7000000-0000-0000-0000-0000000005d1'), + '(f7000000-0000-0000-0000-000000000501,f7000000-0000-0000-0000-0000000005e1,wishlist,bleibt)|f7000000-0000-0000-0000-000000000501f7000000-0000-0000-0000-0000000005e1|f7000000-0000-0000-0000-0000000005011', + 'Daten unbeteiligter Spiele bleiben unverändert (Sammlung, Partie, Favorit, Variante)'); +select is((select count(*) from public.game_variants where game_id = 'f7000000-0000-0000-0000-000000000501'), 1::bigint, + 'Varianten unbeteiligter Spiele bleiben erhalten'); 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'); @@ -308,7 +337,7 @@ select is((select count(distinct variant_id) from ( -- 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 lives_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-0000000000b9', 'f7000000-0000-0000-0000-0000000000b8')$$, 'Zusammenführen läuft ohne Fehler (0000b8)'); 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)'); @@ -356,7 +385,7 @@ select is((select r ? 'bgg_id_conflict' from rb_tgt), false, 'Kein Konflikt, wen 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 lives_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-000000000131', 'f7000000-0000-0000-0000-000000000132')$$, 'Zusammenführen läuft ohne Fehler (000132)'); 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, @@ -369,7 +398,7 @@ 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 lives_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-000000000143', 'f7000000-0000-0000-0000-000000000141')$$, 'Zusammenführen läuft ohne Fehler (000141)'); 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, @@ -392,7 +421,7 @@ insert into public.game_variants (id, game_id, name, created_at) values 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 lives_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-000000000151', 'f7000000-0000-0000-0000-000000000152')$$, 'Zusammenführen läuft ohne Fehler (000152)'); 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, @@ -456,11 +485,59 @@ insert into public.game_variants (id, game_id, name) values 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 lives_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-0000000003a1', 'f7000000-0000-0000-0000-0000000003b1')$$, 'Zusammenführen läuft ohne Fehler (0003b1)'); 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'); +-- Spec-F1 Kernregel (W1): Doppelbesitz, beide Einträge haben eine eigene Edition -> der Zieleintrag behält seine +insert into public.games (id, title, visibility) values + ('f7000000-0000-0000-0000-000000000401', 'Edition Quelle', 'catalog'), ('f7000000-0000-0000-0000-000000000402', 'Edition Ziel', 'catalog'); +insert into public.game_variants (id, game_id, name) values + ('f7000000-0000-0000-0000-0000000004e1', 'f7000000-0000-0000-0000-000000000401', 'Y-Edition'), + ('f7000000-0000-0000-0000-0000000004e2', 'f7000000-0000-0000-0000-000000000402', 'X-Edition'); +insert into public.collection_items (id, owner_id, game_id, variant_id, list) values + ('f7000000-0000-0000-0000-0000000004c1', '11111111-1111-1111-1111-111111111111', 'f7000000-0000-0000-0000-000000000401', 'f7000000-0000-0000-0000-0000000004e1', 'owned'), + ('f7000000-0000-0000-0000-0000000004c2', '11111111-1111-1111-1111-111111111111', 'f7000000-0000-0000-0000-000000000402', 'f7000000-0000-0000-0000-0000000004e2', 'owned'); +select lives_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-000000000401', 'f7000000-0000-0000-0000-000000000402')$$, + 'Zusammenführen mit Doppelbesitz und zwei Editionen läuft'); +select is((select variant_id from public.collection_items where id = 'f7000000-0000-0000-0000-0000000004c2'), + 'f7000000-0000-0000-0000-0000000004e2'::uuid, 'Spec-F1: Doppelbesitz: der Zieleintrag behält seine eigene Edition (X), die der Quelle (Y) verdrängt sie nicht'); + +-- N15 (W2): privater Eintrag -> Katalog, Doppelbesitz, Ziel ohne Edition, Quelle mit Edition ohne Gegenstück -> geleert und gezählt +insert into public.games (id, title, visibility, owner_id) values + ('f7000000-0000-0000-0000-000000000411', 'Vorläufig Cara', 'private', '33333333-3333-3333-3333-333333333333'); +insert into public.games (id, title, visibility) values ('f7000000-0000-0000-0000-000000000412', 'Ziel Cara', 'catalog'); +insert into public.game_variants (id, game_id, name) values + ('f7000000-0000-0000-0000-0000000004e3', 'f7000000-0000-0000-0000-000000000411', 'Nur bei Cara'); +insert into public.collection_items (id, owner_id, game_id, variant_id, list) values + ('f7000000-0000-0000-0000-0000000004c3', '33333333-3333-3333-3333-333333333333', 'f7000000-0000-0000-0000-000000000411', 'f7000000-0000-0000-0000-0000000004e3', 'owned'), + ('f7000000-0000-0000-0000-0000000004c4', '33333333-3333-3333-3333-333333333333', 'f7000000-0000-0000-0000-000000000412', null, 'owned'); +create temp table rb_n15 as select public.repoint_game_references('f7000000-0000-0000-0000-000000000411', 'f7000000-0000-0000-0000-000000000412') as r; +select is((select r from rb_n15), + '{"collection_items":0,"collection_conflicts":1,"favorite_games":0,"favorite_conflicts":0,"plays":0,"score_sheets":0,"variants_copied":0,"variants_mapped":0,"variants_cleared":1}'::jsonb, + 'KAT-05 Kriterium 4 (N15): Doppelbesitz mit Edition ohne Gegenstück: variants_cleared = 1'); +select is((select variant_id from public.collection_items where id = 'f7000000-0000-0000-0000-0000000004c4'), null::uuid, + 'KAT-05 Kriterium 4 (N15): die Edition am Zieleintrag bleibt leer (nichts kopiert)'); +select is((select count(*) from public.game_variants where game_id = 'f7000000-0000-0000-0000-000000000412'), 0::bigint, + 'KAT-05 Kriterium 4 (N15): das Katalogspiel bekommt keine Variante'); + +-- Zwei gleichnamige Quellvarianten werden einzeln kopiert (nur gegen vorher vorhandene Zielvarianten abgeglichen) +insert into public.games (id, title, visibility) values + ('f7000000-0000-0000-0000-000000000421', 'Zwilling Quelle', 'catalog'), ('f7000000-0000-0000-0000-000000000422', 'Zwilling Ziel', 'catalog'); +insert into public.game_variants (id, game_id, name, created_at) values + ('f7000000-0000-0000-0000-0000000004f1', 'f7000000-0000-0000-0000-000000000421', 'Twin', '2020-01-01'), + ('f7000000-0000-0000-0000-0000000004f2', 'f7000000-0000-0000-0000-000000000421', 'twin ', '2021-01-01'); +create temp table rb_twin as select public.admin_merge_games('f7000000-0000-0000-0000-000000000421', 'f7000000-0000-0000-0000-000000000422') as r; +select is((select (r ->> 'variants_copied') || '/' || (r ->> 'variants_mapped') from rb_twin), '2/0', + 'Zwei gleichnamige Quellvarianten werden beide kopiert, keine auf die eben kopierte abgebildet'); +select is((select count(*) from public.game_variants where game_id = 'f7000000-0000-0000-0000-000000000422'), 2::bigint, + 'Das Ziel hat danach beide Kopien'); + +-- Ziel null +select throws_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-000000000001', null)$$, + 'P0001', 'merge_game_not_found', 'KAT-05 Kriterium 4: fehlendes Ziel (null) ist merge_game_not_found'); + -- --------------------------------------------------------------------------------------------- -- Fremdschlüssel-Wächter: jeder Verweis auf games ist entweder behandelt oder bewusst ausgelassen -- --------------------------------------------------------------------------------------------- @@ -480,5 +557,14 @@ select set_eq($$select fk from fk_found$$, ('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'); +-- Zweiter Wächter: Verweise auf game_variants. Sie werden über die Variantenzuordnung umgesetzt; +-- ein neuer Verweis (z. B. aus SAM-02/SAM-04) erzwingt eine Entscheidung in repoint_game_references. +select set_eq($$select (select relname from pg_class where oid = c.conrelid) || '(' || + (select string_agg(a.attname, ', ' order by k.ord) from unnest(c.conkey) with ordinality as k(attnum, ord) + join pg_attribute a on a.attrelid = c.conrelid and a.attnum = k.attnum) || ')' + from pg_constraint c where c.contype = 'f' and c.confrelid = 'public.game_variants'::regclass$$, + $$values ('collection_items(variant_id, game_id)'), ('plays(variant_id, game_id)')$$, + 'KAT-05 Kriterium 4: Fremdschlüssel-Wächter: Verweise auf game_variants sind genau Sammlung und Partien (beide über die Variantenzuordnung)'); + select * from finish(); rollback; From 500763d5ca9d5e3ab2b6f067bfeb926e0a720bd0 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Wed, 4 Nov 2026 00:24:49 +0100 Subject: [PATCH 4/4] docs(db): late references to merged source as known limitation, with test (KAT-05, 2b) Refs #2 Co-Authored-By: Claude Sonnet 5.5 --- docs/katalog-pflege.md | 29 +++++++ docs/specs/2-katalog-daten.md | 2 + .../tests/database/07_catalog_merge.test.sql | 80 ++++++++++++++++++- 3 files changed, 110 insertions(+), 1 deletion(-) diff --git a/docs/katalog-pflege.md b/docs/katalog-pflege.md index 0da7bcb..700cda7 100644 --- a/docs/katalog-pflege.md +++ b/docs/katalog-pflege.md @@ -262,6 +262,35 @@ Die Quelle ist ausgeblendet und verweist auf das Ziel. Das Ziel bleibt sichtbar; select id from public.games where merged_into_id = '7a1b2c3d-0000-4000-8000-000000000002'; ``` +### Bekannte Einschränkung: nachträgliche Verweise auf eine zusammengeführte Quelle + +Nach dem Zusammenführen kann sich noch Neues an die ausgeblendete Quelle hängen, zum Beispiel durch einen veralteten Client oder eine offline erfasste Partie (`plays.client_id`), die erst nach dem Merge synchronisiert wird. Das ist eine bekannte Einschränkung, kein Fehler: Die Quelle wird nie gelöscht und bleibt als Fremdschlüsselziel gültig, die App leitet nur die Anzeige auf das Ziel um. Die Verweise verschwinden also nicht, sie hängen nur am falschen Eintrag. + +**Erkennung** (nach einem Merge, bei Verdacht oder als gelegentliche Kontrolle; die Zahlen müssen überall `0` sein): + +```sql +select 'collection_items' as tabelle, count(*) as anzahl from public.collection_items t join public.games g on g.id = t.game_id + where g.id = '7a1b2c3d-0000-4000-8000-000000000002' and g.merged_into_id is not null +union all select 'favorite_games', count(*) from public.favorite_games t join public.games g on g.id = t.game_id + where g.id = '7a1b2c3d-0000-4000-8000-000000000002' and g.merged_into_id is not null +union all select 'plays', count(*) from public.plays t join public.games g on g.id = t.game_id + where g.id = '7a1b2c3d-0000-4000-8000-000000000002' and g.merged_into_id is not null +union all select 'score_sheets', count(*) from public.score_sheets t join public.games g on g.id = t.game_id + where g.id = '7a1b2c3d-0000-4000-8000-000000000002' and g.merged_into_id is not null +union all select 'game_variants', count(*) from public.game_variants t join public.games g on g.id = t.game_id + where g.id = '7a1b2c3d-0000-4000-8000-000000000002' and g.merged_into_id is not null; +``` + +**Behebung:** als `postgres` die interne Umhänge-Funktion erneut aufrufen, in einer Transaktion. `admin_merge_games` geht hier nicht mehr (`merge_source_already_merged`): + +```sql +select public.repoint_game_references('7a1b2c3d-0000-4000-8000-000000000002'::uuid, '7a1b2c3d-0000-4000-8000-000000000003'::uuid); +``` + +Die Funktion arbeitet wie beim Merge (Kollisionsregeln, Favoriten, Editionen). Da die Quelle ein Katalogspiel ist, werden Varianten, die erst nachträglich an der Quelle entstanden sind, am Ziel kopiert beziehungsweise auf gleichnamige abgebildet und danach an der Quelle gelöscht. Die Quelle bleibt ausgeblendet, `merged_into_id` und `hidden_at` ändern sich nicht. Das Ergebnis-JSON hat dieselben Schlüssel wie beim Merge (ohne die Felder von `admin_merge_games`). + +**Nachprüfung:** den Erkennungs-Baustein erneut ausführen; er muss überall `0` liefern. + **Was die Funktion macht** | Was | Regel | diff --git a/docs/specs/2-katalog-daten.md b/docs/specs/2-katalog-daten.md index 6fb2d58..b6dcf76 100644 --- a/docs/specs/2-katalog-daten.md +++ b/docs/specs/2-katalog-daten.md @@ -720,3 +720,5 @@ Umgesetzt: B5 (Migration `20261103193928_catalog_merge.sql`), B6 (pgTAP `07_cata - **Runbook:** Alle SQL-Bausteine aus Abschnitt 5 laufen wörtlich in `07` (einmalige Prüfung per Skript; keine CI-Prüfung). Abschnitt 5 enthält die Duplikatsuche, eine Vorschau (Verweise, Doppelbesitz), den Aufruf, die Nachprüfung, die Regeln, die Schlüssel des Ergebnis-JSON und die Fehlertexte. - **Nachtrag Review 2b:** Tests ergänzt (Spec-F1-Edition am Ziel, `variants_cleared` im Kollisionszweig, gleichnamige Quellvarianten, zweiter Fremdschlüssel-Wächter auf `game_variants`, unbeteiligte Spiele, Ziel `null`); Runbook um Trockenlauf (`begin; …; rollback;` in einem Lauf, nur dieser Baustein läuft nicht wörtlich in pgTAP), Ketten-Nachprüfung und Nachschlagen von `merged_into_id` ergänzt. Der Null-Check in `admin_merge_games` ist ein äquivalenter Mutant (das spätere `not found` liefert denselben Fehler) und bleibt als ausdrückliche Absicherung. Offen für Chris: Verweise, die nach dem Merge noch auf die Quelle entstehen (W3). + +- **Nachtrag W3 (Chris):** Nachträgliche Verweise auf eine bereits zusammengeführte Quelle sind eine bekannte Einschränkung, kein neuer Code. Runbook 5 beschreibt Erkennung, Behebung (`repoint_game_references` erneut als `postgres`) und Nachprüfung; `07` testet das (Quelle bleibt ausgeblendet, Kollisionen, nachträglich entstandene Variante wird am Ziel kopiert). diff --git a/supabase/tests/database/07_catalog_merge.test.sql b/supabase/tests/database/07_catalog_merge.test.sql index c70e81e..3818bdd 100644 --- a/supabase/tests/database/07_catalog_merge.test.sql +++ b/supabase/tests/database/07_catalog_merge.test.sql @@ -5,7 +5,7 @@ -- 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(107); +select plan(118); 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"}'), @@ -490,6 +490,84 @@ select is((select v.name || '/' || v.game_id::text from public.collection_items 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'); +-- Nachträgliche Verweise auf eine bereits zusammengeführte Quelle (Runbook 5, bekannte Einschränkung; Entscheidung Chris, W3). +-- Die Quelle ...0002 ist im Hauptfall schon zusammengeführt. Ein veralteter Client bzw. eine offline erfasste Partie +-- hängt danach noch Daten an sie: Variante (neu am Quell-Eintrag), Partie, Sammlungseintrag mit Kollision, Favoriten. +create temp table rb_late_state as +select hidden_at, merged_into_id from public.games where id = '7a1b2c3d-0000-4000-8000-000000000002'; +insert into public.game_variants (id, game_id, name) values + ('f7000000-0000-0000-0000-0000000006e1', '7a1b2c3d-0000-4000-8000-000000000002', 'Nachzügler'); +insert into public.plays (id, owner_id, game_id, variant_id, played_at, client_id) values + ('f7000000-0000-0000-0000-0000000006b1', '11111111-1111-1111-1111-111111111111', '7a1b2c3d-0000-4000-8000-000000000002', + 'f7000000-0000-0000-0000-0000000006e1', '2026-05-01', 'f7000000-0000-0000-0000-0000000006b1'); +insert into public.collection_items (id, owner_id, game_id, list) values + ('f7000000-0000-0000-0000-0000000006c1', '33333333-3333-3333-3333-333333333333', '7a1b2c3d-0000-4000-8000-000000000002', 'owned'); +insert into public.favorite_games (id, profile_id, game_id, position) values + ('f7000000-0000-0000-0000-0000000006d1', '33333333-3333-3333-3333-333333333333', '7a1b2c3d-0000-4000-8000-000000000003', 4), + ('f7000000-0000-0000-0000-0000000006d2', '33333333-3333-3333-3333-333333333333', '7a1b2c3d-0000-4000-8000-000000000002', 5), + ('f7000000-0000-0000-0000-0000000006d3', '44444444-4444-4444-4444-444444444444', '7a1b2c3d-0000-4000-8000-000000000002', 2); + +-- Runbook 5, nachträgliche Verweise: Erkennung +create temp table rb_late_vorher as +select 'collection_items' as tabelle, count(*) as anzahl from public.collection_items t join public.games g on g.id = t.game_id + where g.id = '7a1b2c3d-0000-4000-8000-000000000002' and g.merged_into_id is not null +union all select 'favorite_games', count(*) from public.favorite_games t join public.games g on g.id = t.game_id + where g.id = '7a1b2c3d-0000-4000-8000-000000000002' and g.merged_into_id is not null +union all select 'plays', count(*) from public.plays t join public.games g on g.id = t.game_id + where g.id = '7a1b2c3d-0000-4000-8000-000000000002' and g.merged_into_id is not null +union all select 'score_sheets', count(*) from public.score_sheets t join public.games g on g.id = t.game_id + where g.id = '7a1b2c3d-0000-4000-8000-000000000002' and g.merged_into_id is not null +union all select 'game_variants', count(*) from public.game_variants t join public.games g on g.id = t.game_id + where g.id = '7a1b2c3d-0000-4000-8000-000000000002' and g.merged_into_id is not null; +select set_eq($$select tabelle, anzahl from rb_late_vorher$$, + $$values ('collection_items'::text, 1::bigint), ('favorite_games', 2), ('plays', 1), ('score_sheets', 0), ('game_variants', 1)$$, + 'KAT-05 Kriterium 8: Runbook 5, Erkennung nachträglicher Verweise auf die zusammengeführte Quelle'); + +select throws_ok($$select public.admin_merge_games('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003')$$, + 'P0001', 'merge_source_already_merged', 'Runbook 5: ein zweiter admin_merge_games scheitert (merge_source_already_merged), deshalb repoint'); + +-- Runbook 5, nachträgliche Verweise: Behebung +create temp table rb_late_fix as +select public.repoint_game_references('7a1b2c3d-0000-4000-8000-000000000002'::uuid, '7a1b2c3d-0000-4000-8000-000000000003'::uuid); +select is((select repoint_game_references from rb_late_fix), + '{"collection_items":0,"collection_conflicts":1,"favorite_games":1,"favorite_conflicts":1,"plays":1,"score_sheets":0,"variants_copied":1,"variants_mapped":0,"variants_cleared":0}'::jsonb, + 'KAT-05 Kriterium 8: Runbook 5, repoint auf eine zusammengeführte Quelle hängt nachträgliche Verweise um (Katalog→Katalog: Variante kopiert)'); + +-- Runbook 5, nachträgliche Verweise: Nachprüfung (Erkennung erneut) +create temp table rb_late_nachher as +select 'collection_items' as tabelle, count(*) as anzahl from public.collection_items t join public.games g on g.id = t.game_id + where g.id = '7a1b2c3d-0000-4000-8000-000000000002' and g.merged_into_id is not null +union all select 'favorite_games', count(*) from public.favorite_games t join public.games g on g.id = t.game_id + where g.id = '7a1b2c3d-0000-4000-8000-000000000002' and g.merged_into_id is not null +union all select 'plays', count(*) from public.plays t join public.games g on g.id = t.game_id + where g.id = '7a1b2c3d-0000-4000-8000-000000000002' and g.merged_into_id is not null +union all select 'score_sheets', count(*) from public.score_sheets t join public.games g on g.id = t.game_id + where g.id = '7a1b2c3d-0000-4000-8000-000000000002' and g.merged_into_id is not null +union all select 'game_variants', count(*) from public.game_variants t join public.games g on g.id = t.game_id + where g.id = '7a1b2c3d-0000-4000-8000-000000000002' and g.merged_into_id is not null; +select set_eq($$select tabelle, anzahl from rb_late_nachher$$, + $$values ('collection_items'::text, 0::bigint), ('favorite_games', 0), ('plays', 0), ('score_sheets', 0), ('game_variants', 0)$$, + 'KAT-05 Kriterium 8: Runbook 5, nach dem erneuten repoint hängt nichts mehr an der Quelle'); +select is((select count(*) from public.games g, rb_late_state s where g.id = '7a1b2c3d-0000-4000-8000-000000000002' + and g.hidden_at is not distinct from s.hidden_at and g.merged_into_id is not distinct from s.merged_into_id + and g.merged_into_id = '7a1b2c3d-0000-4000-8000-000000000003'), 1::bigint, + 'Die Quelle bleibt ausgeblendet mit unverändertem merged_into_id und hidden_at'); +select is((select row(list, note)::text from public.collection_items where id = 'f7000000-0000-0000-0000-0000000000c5'), + '(owned,"Caras Zielnotiz")', 'Kollision: Caras nachträglicher Eintrag fließt in den Zieleintrag (owned vor wishlist), der Quell-Eintrag ist weg'); +select is((select count(*) from public.collection_items where id = 'f7000000-0000-0000-0000-0000000006c1'), 0::bigint, 'Kollision: nachträglicher Quell-Eintrag gelöscht'); +select is((select array_agg(f.id::text order by f.id) from public.favorite_games f where f.id in + ('f7000000-0000-0000-0000-0000000006d1', 'f7000000-0000-0000-0000-0000000006d2', 'f7000000-0000-0000-0000-0000000006d3')), + array['f7000000-0000-0000-0000-0000000006d1', 'f7000000-0000-0000-0000-0000000006d3'], + 'Favoriten: Caras Quell-Favorit entfällt (Ziel-Favorit bleibt), Doras wechselt auf das Ziel'); +select is((select game_id from public.favorite_games where id = 'f7000000-0000-0000-0000-0000000006d3'), '7a1b2c3d-0000-4000-8000-000000000003'::uuid, + 'Favoriten: Doras nachträglicher Favorit hängt am Ziel'); +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-0000000006b1'), + '7a1b2c3d-0000-4000-8000-000000000003:7a1b2c3d-0000-4000-8000-000000000003:Nachzügler', + 'Partie: die nachträgliche Edition ist eine Kopie am Ziel'); +select is((select count(*) from public.game_variants where id = 'f7000000-0000-0000-0000-0000000006e1'), 0::bigint, + 'Die nachträgliche Variante der Quelle ist gelöscht (kopiert)'); + -- Spec-F1 Kernregel (W1): Doppelbesitz, beide Einträge haben eine eigene Edition -> der Zieleintrag behält seine insert into public.games (id, title, visibility) values ('f7000000-0000-0000-0000-000000000401', 'Edition Quelle', 'catalog'), ('f7000000-0000-0000-0000-000000000402', 'Edition Ziel', 'catalog');