test(db): close surviving mutants in merge tests, extend runbook (KAT-05, 2b)
All checks were successful
CI / flutter (pull_request) Successful in 1m20s
CI / web-build (pull_request) Successful in 1m2s
CI / supabase (pull_request) Successful in 8s
CI / supabase-tests (pull_request) Successful in 31s
CI / e2e (pull_request) Successful in 3m1s

Refs #2

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
cschlaefke 2026-11-03 21:04:12 +01:00
parent 2c2546ab47
commit 127f765d85
4 changed files with 120 additions and 12 deletions

View file

@ -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`. 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). - **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. - **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). - **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_*`. - **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_*`.

View file

@ -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'; 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 ```sql
select public.admin_merge_games('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003'); 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: **Schritt 4: Nachprüfen.** Der Vorschau-Baustein aus Schritt 2 (mit der Quelle) muss jetzt überall `0` liefern. Zustand beider Einträge:
```sql ```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'); 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** **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. | | 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. | | 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. | | 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. | | 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. | | 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_same_game` | Quelle und Ziel sind dieselbe ID |
| `merge_game_not_found` | Quelle oder Ziel gibt es nicht (ID vertippt?) | | `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_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 | | `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). 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

@ -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. - **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`. - **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. - **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).

View file

@ -5,7 +5,7 @@
-- Hinweis für 2c: Der Fremdschlüssel-Wächter am Ende muss um game_reports.target_game_id (behandelt) und -- 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. -- game_reports.provisional_game_id (bewusst ausgelassen) erweitert werden, sonst wird er rot.
begin; begin;
select plan(89); select plan(107);
insert into auth.users (id, email, raw_user_meta_data) values 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"}'), ('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 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'); ('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) -- Runbook 5, Schritt 1: Duplikate finden (global; geprüft wird nur die Fixture-Zeile)
create temp table rb_dup as create temp table rb_dup as
select lower(btrim(title)) as titel_normalisiert, year, 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)$$, ('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'); '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 -- 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, 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'); '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'), select is((select row(list, note, acquired_on)::text from public.collection_items where id = 'f7000000-0000-0000-0000-0000000000c7'),
'(owned,"Doras Quellnotiz",)', '(owned,"Doras Quellnotiz",)',
'Spec-F1: Dora: owned (Quelle) vor shopping (Ziel); eine leere Notiz am Ziel wird aus der Quelle gefüllt'); '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'); '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, 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'); '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) -- Löschen eines Ziels mit Verweis (nur der Verweis merged_into_id, ohne andere Verweise)
insert into public.games (id, title, visibility) values insert into public.games (id, title, visibility) values
('f7000000-0000-0000-0000-0000000000b8', 'Löschziel', 'catalog'), ('f7000000-0000-0000-0000-0000000000b9', 'Löschquelle', 'catalog'); ('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', 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)'); 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 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-000000000131', 'Versteckte Quelle', 'catalog', '2020-05-05 00:00:00+00'),
('f7000000-0000-0000-0000-000000000132', 'Ziel zu Versteckt', 'catalog', null); ('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, 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'); '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, 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-000000000141', 'Kette A', 'catalog'),
('f7000000-0000-0000-0000-000000000142', 'Kette B', 'catalog'), ('f7000000-0000-0000-0000-000000000142', 'Kette B', 'catalog'),
('f7000000-0000-0000-0000-000000000143', 'Kette C', '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'); 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; 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, 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 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-0000000001c1', '11111111-1111-1111-1111-111111111111', 'f7000000-0000-0000-0000-000000000151',
'f7000000-0000-0000-0000-0000000001b1', '2026-02-01', 'f7000000-0000-0000-0000-0000000001c1'); '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, 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'); '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, 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 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-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'); ('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 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'), 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'); '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 -- 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')$$, ('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'); '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(); select * from finish();
rollback; rollback;