feat(db): add admin_merge_games and repoint_game_references (KAT-05, 2b)

Refs #2

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
cschlaefke 2026-11-03 20:46:19 +01:00
parent 8c003ddece
commit daeae999dd
3 changed files with 875 additions and 5 deletions

View file

@ -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`. 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 ## 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. 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 ```sql
select id, title, year, hidden_at from public.games select id, title, year, hidden_at from public.games
@ -164,7 +164,7 @@ where id = '7a1b2c3d-0000-4000-8000-000000000001' and visibility = 'catalog';
Regeln: Regeln:
- **Katalogspiele nie löschen, sondern ausblenden** (siehe 4). Zugeordnete Sammlungen, Favoriten und Partien verweisen auf sie. - **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. - 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`. - 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 ## 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 ## 6. Meldungen bearbeiten

View file

@ -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;

View file

@ -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;