Compare commits
4 commits
2c2546ab47
...
e665a71f13
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e665a71f13 | ||
|
|
ac5b5f2be2 | ||
|
|
147dd1870e | ||
|
|
127f765d85 |
8 changed files with 1349 additions and 30 deletions
|
|
@ -28,7 +28,7 @@ Die Pfeile zeigen, welche Tabelle auf welche verweist. Jede Partie (`plays`) hä
|
|||
|
||||
## Tabellen
|
||||
|
||||
R1 braucht 20 Tabellen in vier Bereichen (17 aus der Grundplanung, dazu `catalog_sources`, `publishers` und `game_publishers` aus KAT-05; mit `game_reports` in KAT-05 PR 2c werden es 21). Jede Tabelle hat einen eigenen Primärschlüssel `id` (uuid, PK) sowie `created_at` und `updated_at`; diese drei Spalten stehen in den Tabellen unten nicht mehr einzeln. `updated_at` setzt ein Datenbank-Trigger bei jeder Änderung, nie der Client. Ein Fremdschlüssel (FK) steht nur dort, wo zwei Tabellen in Beziehung stehen, und heißt `<Zieltabelle>_id`; er speichert die `id` der Zielzeile. Spaltennamen sind Englisch (Konvention aus CLAUDE.md), die Namen der Statuswerte sind mein Vorschlag.
|
||||
R1 braucht 21 Tabellen in vier Bereichen (17 aus der Grundplanung, dazu `catalog_sources`, `publishers`, `game_publishers` und `game_reports` aus KAT-05). Jede Tabelle hat einen eigenen Primärschlüssel `id` (uuid, PK) sowie `created_at` und `updated_at`; diese drei Spalten stehen in den Tabellen unten nicht mehr einzeln. `updated_at` setzt ein Datenbank-Trigger bei jeder Änderung, nie der Client. Ein Fremdschlüssel (FK) steht nur dort, wo zwei Tabellen in Beziehung stehen, und heißt `<Zieltabelle>_id`; er speichert die `id` der Zielzeile. Spaltennamen sind Englisch (Konvention aus CLAUDE.md), die Namen der Statuswerte sind mein Vorschlag.
|
||||
|
||||
### Konto und Profil
|
||||
|
||||
|
|
@ -43,6 +43,7 @@ R1 braucht 20 Tabellen in vier Bereichen (17 aus der Grundplanung, dazu `catalog
|
|||
| Tabelle | Spalten | Zweck |
|
||||
| --- | --- | --- |
|
||||
| `games` | `owner_id` (FK → `profiles`, nur bei privaten Spielen), `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `bgg_id` (optional, eindeutig), `visibility` (`catalog` oder `private`), `hidden_at`, `merged_into_id` (FK → `games`), `manual_fields`, `catalog_source_id` (FK → `catalog_sources`), `source_ref`, `source_url`, `source_synced_at` | Ein Eintrag je Spiel; private Einträge sind vorläufig und nur für den Ersteller sichtbar (O5). Die letzten sieben Spalten gehören der Katalogpflege (KAT-05), siehe unten |
|
||||
| `game_reports` | `owner_id` (FK → `profiles`), `origin` (`user`/`import`), `title`, `publisher_name`, `year`, `link`, `bgg_id`, `provisional_game_id` (FK → `games`), `status` (`open`/`approved`/`rejected`/`merged`), `target_game_id` (FK → `games`), `resolved_at` | Meldungen fehlender Spiele (KAT-05 PR 2c, Anlegen durch Nutzer kommt mit KAT-04); siehe Katalogpflege |
|
||||
| `catalog_sources` | `key`, `name`, `attribution_text`, `attribution_url`, `images_allowed`, `texts_allowed`, `terms_url`, `terms_checked_at` | Datenquellen des Katalogs mit Anzeige-Erlaubnis für Bilder und Texte je Quelle (KAT-05); die Migration legt `bgg` ohne Erlaubnis an |
|
||||
| `publishers`, `game_publishers` | `name` (eindeutig ohne Beachtung der Schreibweise); `game_id`, `publisher_id` | Verlage und ihre Zuordnung zu Spielen (Klärung 4) |
|
||||
| `game_variants` | `game_id` (FK → `games`), `name`, `year`, `language`, `bgg_id` (optional) | Editionen und Varianten eines Spiels (O4) |
|
||||
|
|
@ -76,6 +77,7 @@ Jede Tabelle hat Row Level Security (RLS) und eine Policy-Test-Suite (pgTAP). De
|
|||
| `profiles` | Eigenes Profil; fremde nur `display_name`, `avatar_path`, `city` | Nur eigenes |
|
||||
| `favorite_games`, `bgg_links` | Nur eigene | Nur eigene |
|
||||
| `games`, `game_variants`, Begriffe | Katalog für alle (O6); private Spiele nur der Ersteller | Katalog nur Admin (in R1 nur über Studio/SQL, E10); private Spiele nur der Ersteller, und nur die Spalten laut Spaltenrechten |
|
||||
| `game_reports` | nur der Meldende liest seine Zeilen (Admin-Policy `is_admin()` für R4); Schreiben kein Nutzerrecht | Anlegen ergänzt KAT-04 (#13); Bearbeiten nur über `admin_resolve_report` (Studio) |
|
||||
| `catalog_sources`, `publishers`, `game_publishers`, View `catalog_games` | für alle, auch ohne Anmeldung (`game_publishers` folgt dem Spiel) | Quellen und Verlage nur Admin; `game_publishers` Admin oder Ersteller des privaten Spiels; die View ist nicht beschreibbar |
|
||||
| `collection_items`, `score_sheets`, `score_categories` | Nur eigene | Nur eigene |
|
||||
| `plays` | Ersteller und eingeladene Mitspieler (offen oder bestätigt) | Nur Ersteller |
|
||||
|
|
@ -233,6 +235,26 @@ Diese Struktur ist aus den fertigen Migrationen in `supabase/migrations/` ausgel
|
|||
| `created_at` | timestamptz | ja | `now()` | |
|
||||
| `updated_at` | timestamptz | ja | `now()` | |
|
||||
|
||||
**`game_reports`** (KAT-05, Migration `game_reports`):
|
||||
|
||||
| Spalte | Typ | Pflicht | Standard | Schlüssel |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `id` | uuid | ja | `gen_random_uuid()` | PK |
|
||||
| `owner_id` | uuid | ja | | FK → profiles (`on delete cascade`) |
|
||||
| `origin` | text | ja | `'user'` | `user` oder `import` |
|
||||
| `title` | text | ja | | 1 bis 200 Zeichen |
|
||||
| `publisher_name` | text | nein | | höchstens 200 |
|
||||
| `year` | smallint | nein | | 1800 bis 2100 |
|
||||
| `link` | text | nein | | `http(s)://`, höchstens 500 |
|
||||
| `bgg_id` | integer | nein | | |
|
||||
| `provisional_game_id` | uuid | nein | | FK → games (`on delete set null`), eindeutig (1:1) |
|
||||
| `status` | text | ja | `'open'` | `open`, `approved`, `rejected`, `merged` |
|
||||
| `target_game_id` | uuid | nein | | FK → games (`on delete restrict`) |
|
||||
| `resolved_at` | timestamptz | nein | | gesetzt genau dann, wenn `status <> 'open'` |
|
||||
| `created_at`, `updated_at` | timestamptz | ja | `now()` | |
|
||||
|
||||
Checks: offen hat kein Ziel; `approved`/`merged` brauchen ein Ziel. Indizes: `(owner_id, created_at)`, eindeutig `(owner_id, bgg_id)` für offene Import-Meldungen (N12a), `(created_at)` für offene Meldungen, eindeutig `provisional_game_id`, `target_game_id`.
|
||||
|
||||
**`genres`**, **`mechanics`** und **`categories`** sind gleich aufgebaut:
|
||||
|
||||
| Spalte | Typ | Pflicht | Standard | Schlüssel |
|
||||
|
|
@ -342,10 +364,11 @@ Drei Ansichten (Views) kommen dazu: `public_profiles` zeigt Mitspielern Name, Av
|
|||
Die Pflege läuft in R1 nur über Supabase Studio bzw. `psql` als Datenbankrolle `postgres` (Klärungen 25/44, Ablauf: `docs/katalog-pflege.md`). Spec: `docs/specs/2-katalog-daten.md`.
|
||||
|
||||
- **Ausblenden:** `games.hidden_at` gesetzt = ausgeblendet. Die View `catalog_games` enthält nur Katalogspiele (`visibility = 'catalog'`) ohne `hidden_at` und ohne `merged_into_id`. **Suche und Entdecken lesen nur diese View.** Ein ausgeblendetes Spiel bleibt per ID lesbar (Sammlungen, Partien und Detailseite verweisen darauf).
|
||||
- **Zusammenführen** (Migration `catalog_merge`, PR 2b): `games.merged_into_id` zeigt auf das Zielspiel; ein Trigger lässt nur Katalogspiele als Ziel zu (Fehler `merged_into_not_catalog`). Gesetzt wird die Spalte nur von der Funktion `public.admin_merge_games(p_source, p_target) returns jsonb` (Ablauf und Ergebnis-JSON: `docs/katalog-pflege.md`, Abschnitt 5). Sie ist `security invoker`, prüft `current_user in ('postgres', 'supabase_admin')` (sonst `admin_only`) und ist für `anon`, `authenticated` und `service_role` nicht ausführbar; einen API-Aufruf gibt es nicht. Beide Spiele müssen Katalogspiele sein, das Ziel sichtbar. Die Funktion hängt über die interne Funktion `public.repoint_game_references(p_from, p_to)` Sammlung, Favoriten, Partien (mit Edition) und Wertungsbögen um, kopiert die Varianten der Quelle ans Ziel (gleichnamige Zielvarianten werden abgebildet: `lower(btrim(name))`, bei mehreren die älteste, dann die kleinste `id`) und löscht danach die alten Varianten. Bei Doppelbesitz bleibt der Zieleintrag, leere Felder füllt die Quelle, die Liste folgt `owned` vor `wishlist` vor `shopping`, der Quell-Eintrag wird gelöscht; ein Favorit der Quelle entfällt, wenn der Nutzer das Ziel schon als Favorit hat. `bgg_id` und `catalog_source_id`/`source_*` gehen aufs Ziel über, wenn es keine hat; sonst werden sie an der Quelle trotzdem geleert und der Konflikt mit den alten Werten im Ergebnis gemeldet. Die Quelle wird nie gelöscht, sondern ausgeblendet und verwiesen (`hidden_at`, `merged_into_id`); Ketten werden geglättet. Zuordnungen (Genre, Mechanik, Kategorie, Verlag) bleiben am Quell-Eintrag. Ein pgTAP-Wächter (`07`) listet alle Fremdschlüssel auf `games` und lässt neue Verweise erst nach einer Entscheidung zu.
|
||||
- **Zusammenführen** (Migration `catalog_merge`, PR 2b): `games.merged_into_id` zeigt auf das Zielspiel; ein Trigger lässt nur Katalogspiele als Ziel zu (Fehler `merged_into_not_catalog`). **Regel: `merged_into_id` wird nur über `public.admin_merge_games(p_source, p_target) returns jsonb` gesetzt, nie von Hand.** (Ablauf und Ergebnis-JSON: `docs/katalog-pflege.md`, Abschnitt 5). Sie ist `security invoker`, prüft `current_user in ('postgres', 'supabase_admin')` (sonst `admin_only`) und ist für `anon`, `authenticated` und `service_role` nicht ausführbar; einen API-Aufruf gibt es nicht. Beide Spiele müssen Katalogspiele sein, das Ziel sichtbar. Die Funktion hängt über die interne Funktion `public.repoint_game_references(p_from, p_to)` Sammlung, Favoriten, Partien (mit Edition) und Wertungsbögen um, kopiert die Varianten der Quelle ans Ziel (gleichnamige Zielvarianten werden abgebildet: `lower(btrim(name))`, bei mehreren die älteste, dann die kleinste `id`) und löscht danach die alten Varianten. Bei Doppelbesitz bleibt der Zieleintrag, leere Felder füllt die Quelle, die Liste folgt `owned` vor `wishlist` vor `shopping`, der Quell-Eintrag wird gelöscht; ein Favorit der Quelle entfällt, wenn der Nutzer das Ziel schon als Favorit hat. `bgg_id` und `catalog_source_id`/`source_*` gehen aufs Ziel über, wenn es keine hat; sonst werden sie an der Quelle trotzdem geleert und der Konflikt mit den alten Werten im Ergebnis gemeldet. Die Quelle wird nie gelöscht, sondern ausgeblendet und verwiesen (`hidden_at`, `merged_into_id`); Ketten werden geglättet. Zuordnungen (Genre, Mechanik, Kategorie, Verlag) bleiben am Quell-Eintrag. Ein pgTAP-Wächter (`07`) listet alle Fremdschlüssel auf `games` und lässt neue Verweise erst nach einer Entscheidung zu.
|
||||
- **Meldungen** (Migration `game_reports`, PR 2c): Tabelle `game_reports` (siehe Tabellenstruktur). Lesen darf nur der Meldende (Policy auf `owner_id`; die Policy für `is_admin()` ist für R4 vorbereitet); Schreibrechte haben `anon` und `authenticated` nicht, #13 ergänzt Insert-Recht und -Policy. Bearbeitet wird in R1 nur über `public.admin_resolve_report(p_report, p_status, p_target) returns jsonb` (Ablauf, Ergebnis und Fehler: `docs/katalog-pflege.md`, Abschnitt 6): `security invoker`, prüft `current_user in ('postgres', 'supabase_admin')` (sonst `admin_only`), für `anon`, `authenticated` und `service_role` nicht ausführbar. `approved` und `merged` hängen den vorläufigen privaten Eintrag über `repoint_game_references` auf das Ziel um (Varianten werden nicht kopiert, gleichnamige abgebildet, sonst geleert, `variants_cleared`) und blenden ihn aus (`hidden_at`, `merged_into_id`); `rejected` speichert höchstens den Verweis. `admin_merge_games` hängt zusätzlich `game_reports.target_game_id` von der Quelle auf das Ziel (`reports_repointed`). Der Trigger `games_visibility_immutable` lehnt jede Änderung von `visibility` und `owner_id` an bestehenden Spielen ab (Fehler `visibility_immutable`, für alle Rollen auch `postgres`; vor der RLS-Prüfung, ein Nutzer bekommt also P0001 statt 42501). Beim Konto-Löschen werden Meldungen und vorläufige Einträge (auch nach der Freigabe) hart gelöscht, Katalogspiele und Daten anderer Nutzer bleiben. Fremdschlüssel-Wächter: `07` und `08`.
|
||||
- **Manuell gepflegte Felder:** Der Trigger `games_mark_manual_fields` trägt jedes Feld eines Katalogspiels, das ein Mensch setzt oder ändert, in `manual_fields` ein (beim Anlegen alle gesetzten Felder). Der Trigger `game_links_mark_manual` ergänzt `genres`, `mechanics`, `categories` und `publishers`, wenn Zuordnungen angelegt, geändert oder gelöscht werden. Ausgenommen ist nur ein späterer Import: Rolle `catalog_importer` **und** Einstellung `shuffle.catalog_import = 'on'` (der Import setzt sie per `set local`; das ist Konvention, die Rolle ist die Sperre). Private Spiele werden nicht gekennzeichnet. Eine bewusst entfernte Markierung entfernt der Trigger nicht wieder.
|
||||
- **Anzeige-Erlaubnis:** `catalog_games.description` und `image_path` sind leer, wenn die Quelle (`catalog_sources`) Texte bzw. Bilder nicht erlaubt und das Feld nicht in `manual_fields` steht. Das ist nur die zweite Sicherung: Daten ohne Erlaubnis dürfen gar nicht erst gespeichert werden (Pflicht für den Import).
|
||||
- **Spaltenrechte auf `games`:** Nutzer dürfen nur `owner_id` (nur beim Anlegen), `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `bgg_id` und `visibility` schreiben, nicht `id`, `hidden_at`, `merged_into_id`, `manual_fields`, `catalog_source_id` und `source_*`.
|
||||
- **Startbestand:** `supabase/catalog/` (CSV, Werkzeug, erzeugte SQL-Dateien); `supabase db reset` spielt Vokabular und Startbestand ein. Der Startbestand enthält nur Fakten und einen eigenen deutschen Kurztext, kein Cover, keine Quelle, keine BGG-Kennung.
|
||||
- **Konto löschen:** Neue Nutzerdaten entstehen nicht; die Zuordnungen privater Spiele (`game_publishers` usw.) werden mit dem Spiel gelöscht.
|
||||
- **API-Änderungen für das Frontend:** neu lesbar für alle: View `catalog_games`, Tabellen `catalog_sources`, `publishers`, `game_publishers`. Beim Anlegen und Ändern eines privaten Spiels dürfen Clients keine `id`, `created_at` und `updated_at` mehr mitsenden (42501; die ID kommt per `returning`); ein `toJson()` eines Modells darf nie ungefiltert an `insert`/`update` gehen (Merkpunkt für #3 und #13). Der `is_admin()`-Zweig der Zuordnungs-Policies (`game_*_write`) greift für Katalogspiele faktisch nicht mehr, weil der Trigger `game_links_mark_manual` `manual_fields` schreiben muss; ein späterer Admin-Pfad (QUE-08) braucht eigene RPCs. Es gibt keine generierten Datenbanktypen im Repo; das Domänenmodell `CatalogGame` legt KAT-01 (#3) auf Basis der Spalten von `catalog_games` an.
|
||||
- **API-Änderungen für das Frontend:** neu lesbar für alle: View `catalog_games`, Tabellen `catalog_sources`, `publishers`, `game_publishers`; neu für den Meldenden lesbar: `game_reports`. Das Ändern von `visibility` an einem Spiel ist nicht mehr möglich (P0001 `visibility_immutable`); wer eine Sichtbarkeit mitsendet, muss den alten Wert senden oder die Spalte weglassen. Beim Anlegen und Ändern eines privaten Spiels dürfen Clients keine `id`, `created_at` und `updated_at` mehr mitsenden (42501; die ID kommt per `returning`); ein `toJson()` eines Modells darf nie ungefiltert an `insert`/`update` gehen (Merkpunkt für #3 und #13). Der `is_admin()`-Zweig der Zuordnungs-Policies (`game_*_write`) greift für Katalogspiele faktisch nicht mehr, weil der Trigger `game_links_mark_manual` `manual_fields` schreiben muss; ein späterer Admin-Pfad (QUE-08) braucht eigene RPCs. Es gibt keine generierten Datenbanktypen im Repo; das Domänenmodell `CatalogGame` legt KAT-01 (#3) auf Basis der Spalten von `catalog_games` an.
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
Dieses Dokument beschreibt, wie der Admin (Chris) den Katalog in R1 pflegt: ausschließlich über Supabase Studio bzw. `psql`, es gibt keine Admin-Oberfläche in der App (Klärungen 25/44). Grundlage ist die Spec `docs/specs/2-katalog-daten.md`.
|
||||
|
||||
Stand: PR 2b (Datenmodell, Startbestand, Ausblenden, Zusammenführen). Der Abschnitt **Meldungen bearbeiten** (PR 2c) kommt mit dem jeweiligen PR dazu.
|
||||
Stand: PR 2c (Datenmodell, Startbestand, Ausblenden, Zusammenführen, Meldungen bearbeiten).
|
||||
|
||||
**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).
|
||||
**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`, Abschnitt 6 in `08_game_reports.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
|
||||
|
||||
|
|
@ -190,7 +190,7 @@ Zwei Katalogspiele, die dasselbe Spiel beschreiben (Duplikat), führt man mit de
|
|||
|
||||
- **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).
|
||||
- Beide müssen **Katalogspiele** sein. Einen vorläufigen privaten Eintrag eines Nutzers führt man nicht hiermit zusammen (dafür gibt es die Meldungs-Funktion, siehe 6).
|
||||
- **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):
|
||||
|
|
@ -233,12 +233,22 @@ join public.collection_items t on t.owner_id = s.owner_id and t.game_id = '7a1b2
|
|||
where s.game_id = '7a1b2c3d-0000-4000-8000-000000000002';
|
||||
```
|
||||
|
||||
**Schritt 3: Zusammenführen.** In einer Transaktion ausführen (`begin;` davor, nach dem Lesen des Ergebnisses `commit;`, bei Zweifel `rollback;`; die Bausteine stehen wie überall ohne `begin`/`commit`). Bei einem Fehler wird nichts geändert:
|
||||
**Schritt 3: Zusammenführen.** Der Baustein steht wie überall ohne `begin`/`commit`; er wird in einer Transaktion ausgeführt. Bei einem Fehler wird nichts geändert:
|
||||
|
||||
```sql
|
||||
select public.admin_merge_games('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003');
|
||||
```
|
||||
|
||||
**Trockenlauf (empfohlen vor dem ersten echten Merge).** Das Ergebnis lesen und alles zurückrollen, **in einem einzigen Lauf**:
|
||||
|
||||
```sql
|
||||
begin;
|
||||
select public.admin_merge_games('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003');
|
||||
rollback;
|
||||
```
|
||||
|
||||
Danach ist nichts verändert; mit `commit;` statt `rollback;` wird der Lauf endgültig. Achtung: `begin;` und `commit;`/`rollback;` in getrennten Läufen funktionieren nur in `psql` (eine Sitzung). Der SQL-Editor in Studio führt jeden Lauf womöglich in einer eigenen Verbindung bzw. Transaktion aus; dort immer alles in einem Lauf ausführen. (Dieser Trockenlauf enthält Transaktionssteuerung und kann deshalb nicht wörtlich in pgTAP laufen; der enthaltene Aufruf ist identisch zu Schritt 3 und dort getestet.)
|
||||
|
||||
**Schritt 4: Nachprüfen.** Der Vorschau-Baustein aus Schritt 2 (mit der Quelle) muss jetzt überall `0` liefern. Zustand beider Einträge:
|
||||
|
||||
```sql
|
||||
|
|
@ -246,7 +256,11 @@ select id, title, hidden_at is not null as ausgeblendet, merged_into_id, bgg_id,
|
|||
from public.games where id in ('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003');
|
||||
```
|
||||
|
||||
Die Quelle ist ausgeblendet und verweist auf das Ziel, das Ziel ist unverändert sichtbar.
|
||||
Die Quelle ist ausgeblendet und verweist auf das Ziel. Das Ziel bleibt sichtbar; Fakten, Kurztext und Zuordnungen sind unverändert. **Ändern können sich `bgg_id` und die Quelle (`catalog_source_id`, `source_*`)**, wenn das Ziel sie von der Quelle übernommen hat (siehe `bgg_id_transferred` und `source_transferred` im Ergebnis). Außerdem darf kein Spiel mehr auf die Quelle verweisen; diese Abfrage muss leer sein (Ketten sind geglättet):
|
||||
|
||||
```sql
|
||||
select id from public.games where merged_into_id = '7a1b2c3d-0000-4000-8000-000000000002';
|
||||
```
|
||||
|
||||
**Was die Funktion macht**
|
||||
|
||||
|
|
@ -255,7 +269,7 @@ Die Quelle ist ausgeblendet und verweist auf das Ziel, das Ziel ist unverändert
|
|||
| Sammlung (`collection_items`) | Hat der Nutzer nur die Quelle, wechselt der Eintrag auf das Ziel. Hat er beide (Doppelbesitz, Spec-F1): Der Zieleintrag bleibt; leere Felder (Notiz, Erwerbsdatum, Edition) füllt der Quell-Eintrag; bei unterschiedlicher Liste gilt Sammlung vor Wunschliste vor Einkaufsliste; der Quell-Eintrag wird gelöscht. Eine nicht leere Notiz am Ziel wird nie überschrieben. |
|
||||
| Favoriten | Hat der Nutzer das Ziel auch als Favorit, entfällt der Favorit der Quelle (die Lücke in der Reihenfolge ist zulässig); sonst wechselt er auf das Ziel. |
|
||||
| Partien, Wertungsbögen | wechseln auf das Ziel; die Edition einer Partie wird über die Variantenzuordnung übertragen. |
|
||||
| Varianten (Editionen) | Für jede Variante der Quelle: Hat das Ziel eine gleichnamige Variante (Vergleich ohne Groß-/Kleinschreibung und ohne Leerzeichen am Rand), wird darauf abgebildet; gibt es mehrere gleichnamige, gilt die älteste (bei Gleichstand die kleinste ID). Sonst wird die Variante am Ziel neu angelegt (Name, Jahr, Sprache, BGG-Kennung). Danach werden die Varianten der Quelle gelöscht. |
|
||||
| Varianten (Editionen) | Für jede Variante der Quelle: Hat das Ziel eine gleichnamige Variante (Vergleich ohne Groß-/Kleinschreibung und ohne **Leerzeichen** am Rand; Tabulatoren und Zeilenumbrüche am Rand zählen als Teil des Namens), wird darauf abgebildet; gibt es mehrere gleichnamige, gilt die älteste (bei Gleichstand die kleinste ID). Sonst wird die Variante am Ziel neu angelegt (Name, Jahr, Sprache, BGG-Kennung). Danach werden die Varianten der Quelle gelöscht. |
|
||||
| BGG-Kennung und Quelle | Hat das Ziel keine, aber die Quelle, übernimmt das Ziel sie (`bgg_id`; ebenso Quelle, `source_ref`, `source_url`, `source_synced_at` als Gruppe). An der Quelle werden sie in jedem Fall geleert, damit der BGG-Abgleich später den ausgeblendeten Eintrag nicht trifft. |
|
||||
| Ketten | Spiele, die bisher auf die Quelle verwiesen, verweisen jetzt auf das Ziel. |
|
||||
|
||||
|
|
@ -265,7 +279,7 @@ Die Quelle ist ausgeblendet und verweist auf das Ziel, das Ziel ist unverändert
|
|||
{"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}
|
||||
"collection_conflicts": 1, "reports_repointed": 0}
|
||||
```
|
||||
|
||||
| Schlüssel | Bedeutung |
|
||||
|
|
@ -275,11 +289,12 @@ Die Quelle ist ausgeblendet und verweist auf das Ziel, das Ziel ist unverändert
|
|||
| `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) |
|
||||
| `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 (Abschnitt 6) |
|
||||
| `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 |
|
||||
| `reports_repointed` | Meldungen (`game_reports`), deren Zieleintrag die Quelle war und die jetzt auf das Ziel zeigen (Abschnitt 6) |
|
||||
|
||||
**Fehlermeldungen** (alle ohne Änderung, `P0001`):
|
||||
|
||||
|
|
@ -289,14 +304,158 @@ Die Quelle ist ausgeblendet und verweist auf das Ziel, das Ziel ist unverändert
|
|||
| `merge_same_game` | Quelle und Ziel sind dieselbe ID |
|
||||
| `merge_game_not_found` | Quelle oder Ziel gibt es nicht (ID vertippt?) |
|
||||
| `merge_not_catalog` | Quelle oder Ziel ist ein privater Eintrag; Zusammenführen gilt nur für Katalogspiele |
|
||||
| `merge_target_not_listed` | Das Ziel ist ausgeblendet oder selbst schon zusammengeführt; das endgültige Ziel wählen oder zuerst wieder einblenden (siehe 4) |
|
||||
| `merge_target_not_listed` | Das Ziel ist ausgeblendet oder selbst schon zusammengeführt. Ist es zusammengeführt, das endgültige Ziel über `merged_into_id` nachschlagen (Baustein unten, hier mit der Quelle aus dem Beispiel; Ketten sind geglättet, ein Schritt genügt) oder ein ausgeblendetes Ziel zuerst wieder einblenden (siehe 4) |
|
||||
| `merge_source_already_merged` | Die Quelle ist bereits zusammengeführt |
|
||||
|
||||
Nachschlagen, wohin ein zusammengeführtes Spiel verweist (`merged_into_id` ist das endgültige Ziel):
|
||||
|
||||
```sql
|
||||
select id, title, hidden_at, merged_into_id from public.games where id = '7a1b2c3d-0000-4000-8000-000000000002';
|
||||
```
|
||||
|
||||
Ein Aufruf über die App-Schnittstelle (als `anon` oder `authenticated`, auch mit Admin-Claims) scheitert mit `42501`; die Funktion ist nur für die Datenbankrolle ausführbar (E10). Bei Zeitüberschreitung oder anderem Fehler `rollback;` und erneut prüfen. Löscht man ein Zielspiel, auf das etwas verweist, schlägt das mit `23503` fehl (gewollt: ausblenden statt löschen).
|
||||
|
||||
## 6. Meldungen bearbeiten
|
||||
|
||||
Kommt mit PR 2c.
|
||||
Nutzer melden fehlende Spiele (KAT-04, #13); Meldungen, die ein Import anlegt, gibt es ab SAM-08 (`origin = 'import'`). In der Tabelle `game_reports` steht, was der Nutzer angegeben hat (Titel, Verlag, Jahr, Link, BGG-Kennung) und, falls vorhanden, sein **vorläufiger privater Eintrag** (`provisional_game_id`, nur für ihn sichtbar). Der Admin entscheidet in drei Wegen, immer mit der Funktion `public.admin_resolve_report(meldung, status, ziel)`:
|
||||
|
||||
| Entscheidung | Aufruf | Wirkung |
|
||||
|---|---|---|
|
||||
| **freigeben** | `'approved'` + Ziel | Der Eintrag ist wegen dieser Meldung **neu** entstanden. Der vorläufige Eintrag des Nutzers wird auf das Ziel umgehängt (Sammlung, Favoriten, Partien, Wertungsbögen) und ausgeblendet. |
|
||||
| **zusammenführen** | `'merged'` + Ziel | Der Eintrag war **schon vorhanden**. Wirkung wie bei `'approved'`. |
|
||||
| **ablehnen** | `'rejected'`, optional mit Ziel | Mit Ziel wird nur der Verweis gespeichert (die App kann dem Nutzer den Zieleintrag anbieten); der vorläufige Eintrag bleibt unverändert. Ohne Ziel ändert sich nichts außer Status und Zeitpunkt. |
|
||||
|
||||
**Regeln**
|
||||
|
||||
- **Nie einen privaten Eintrag öffentlich schalten.** `visibility` und `owner_id` eines Spiels lassen sich nicht ändern (der Trigger `games_visibility_immutable` lehnt es für jede Rolle ab, auch für `postgres`, Fehler `visibility_immutable`). Freigeben heißt immer: **neuen** Katalogeintrag anlegen.
|
||||
- **Beim Anlegen nur Fakten aus den Meldungsfeldern übernehmen:** `title`, `publisher_name`, `year`; `link` dient nur als Recherchehilfe. **Nie** `description` oder `image_path` des vorläufigen Eintrags übernehmen (das sind private Angaben des Nutzers). Den Kurztext schreibt der Admin selbst (SB4, deutsch).
|
||||
- **Datenschutz:** vom vorläufigen Eintrag nur ansehen, was die Entscheidung braucht (Anzahl Verweise, Namen der Editionen). Die Meldung selbst gehört zum Konto des Nutzers und wird mit ihm gelöscht.
|
||||
- **Editionen werden nicht kopiert** (RB1, Klärung N15). Hat der Katalogeintrag eine gleichnamige Edition (Vergleich ohne Groß-/Kleinschreibung und ohne Leerzeichen am Rand), bleibt die Edition des Nutzers erhalten, sonst wird sie an Sammlungseintrag und Partien geleert (`variants_cleared` im Ergebnis). Soll die Edition erhalten bleiben, **vorher** am Katalogeintrag eine Variante anlegen, und zwar nur aus belegbaren Fakten (Name und Jahr der Edition aus Verlags- oder Schachtelangaben), nie durch Übernahme der privaten Variante. Die private Variante bleibt am ausgeblendeten vorläufigen Eintrag und wird mit dem Konto gelöscht.
|
||||
- Das Ziel muss ein sichtbares Katalogspiel sein (nicht ausgeblendet, nicht zusammengeführt). Es gibt kein Rückgängigmachen; in einer Transaktion arbeiten (`begin;` davor, nach dem Lesen des Ergebnisses `commit;`, bei Zweifel `rollback;`; die Bausteine stehen wie überall ohne `begin`/`commit`).
|
||||
- Wird ein Zieleintrag später mit einem anderen Katalogspiel zusammengeführt (Abschnitt 5), zeigen die Meldungen automatisch auf das neue Ziel (`reports_repointed` im Ergebnis).
|
||||
|
||||
In den Bausteinen stehen Beispiel-IDs: `7c1d2c3d-0000-4000-8000-000000000001` ist die Meldung, `…0002` das neu angelegte Katalogspiel, `…0004` ein vorhandenes Katalogspiel. Immer durch die echten IDs ersetzen (neue ID erzeugen: `select gen_random_uuid();`).
|
||||
|
||||
**Schritt 1: Offene Meldungen** (älteste zuerst):
|
||||
|
||||
```sql
|
||||
select r.id, r.created_at, r.origin, r.title, r.publisher_name, r.year, r.link, r.bgg_id, r.provisional_game_id
|
||||
from public.game_reports r
|
||||
where r.status = 'open'
|
||||
order by r.created_at, r.id;
|
||||
```
|
||||
|
||||
**Schritt 2: Ähnliche Spiele im Katalog suchen** (gleicher Titel ohne Groß-/Kleinschreibung oder gleiche BGG-Kennung wie die Meldung). Gibt es schon einen Eintrag, entscheidet man sich für `'merged'`:
|
||||
|
||||
```sql
|
||||
select g.id, g.title, g.year, g.bgg_id
|
||||
from public.game_reports r
|
||||
join public.catalog_games g
|
||||
on lower(btrim(g.title)) = lower(btrim(r.title))
|
||||
or (r.bgg_id is not null and g.bgg_id = r.bgg_id)
|
||||
where r.id = '7c1d2c3d-0000-4000-8000-000000000001'
|
||||
order by g.title, g.id;
|
||||
```
|
||||
|
||||
Was hängt am vorläufigen Eintrag des Nutzers? Nur Anzahlen:
|
||||
|
||||
```sql
|
||||
select g.id, g.title,
|
||||
(select count(*) from public.collection_items c where c.game_id = g.id) as sammlung,
|
||||
(select count(*) from public.favorite_games f where f.game_id = g.id) as favoriten,
|
||||
(select count(*) from public.plays p where p.game_id = g.id) as partien,
|
||||
(select count(*) from public.score_sheets s where s.game_id = g.id) as wertungsboegen
|
||||
from public.game_reports r
|
||||
join public.games g on g.id = r.provisional_game_id
|
||||
where r.id = '7c1d2c3d-0000-4000-8000-000000000001';
|
||||
```
|
||||
|
||||
Und die Namen seiner Editionen (für die Entscheidung, ob am Katalogeintrag eine gleichnamige Variante angelegt wird):
|
||||
|
||||
```sql
|
||||
select v.id, v.name, v.year, v.language
|
||||
from public.game_reports r
|
||||
join public.game_variants v on v.game_id = r.provisional_game_id
|
||||
where r.id = '7c1d2c3d-0000-4000-8000-000000000001'
|
||||
order by v.name, v.id;
|
||||
```
|
||||
|
||||
**Schritt 3a: Freigeben** (neuer Eintrag). Zuerst den Katalogeintrag mit den Fakten aus der Meldung und einem eigenen Kurztext anlegen (jedes gesetzte Feld wird automatisch in `manual_fields` vermerkt):
|
||||
|
||||
```sql
|
||||
insert into public.games (id, title, year, description, description_locale, visibility)
|
||||
values ('7c1d2c3d-0000-4000-8000-000000000002', 'Meldungsspiel', 2024, 'Eigener Kurztext des Admins.', 'de', 'catalog');
|
||||
```
|
||||
|
||||
Verlag zuordnen (ein vorhandener Verlag wird wiederverwendet, die Schreibweise entscheidet nicht):
|
||||
|
||||
```sql
|
||||
insert into public.publishers (name) values ('Beispielverlag') on conflict ((lower(name))) do nothing;
|
||||
insert into public.game_publishers (game_id, publisher_id)
|
||||
select '7c1d2c3d-0000-4000-8000-000000000002', p.id from public.publishers p where lower(p.name) = lower('Beispielverlag');
|
||||
```
|
||||
|
||||
Optional, wenn die Edition des Nutzers erhalten bleiben soll (nur belegbare Fakten, siehe Regeln):
|
||||
|
||||
```sql
|
||||
insert into public.game_variants (game_id, name, year) values ('7c1d2c3d-0000-4000-8000-000000000002', 'Deluxe', 2024);
|
||||
```
|
||||
|
||||
Dann die Meldung abschließen und das Ergebnis lesen:
|
||||
|
||||
```sql
|
||||
select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000001', 'approved', '7c1d2c3d-0000-4000-8000-000000000002');
|
||||
```
|
||||
|
||||
**Schritt 3b: Zusammenführen** (der Eintrag war schon da, Ziel ist das vorhandene Katalogspiel):
|
||||
|
||||
```sql
|
||||
select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000005', 'merged', '7c1d2c3d-0000-4000-8000-000000000004');
|
||||
```
|
||||
|
||||
**Schritt 3c: Ablehnen**, ohne Verweis oder mit Verweis auf ein vorhandenes Katalogspiel:
|
||||
|
||||
```sql
|
||||
select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000006', 'rejected');
|
||||
```
|
||||
|
||||
```sql
|
||||
select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000007', 'rejected', '7c1d2c3d-0000-4000-8000-000000000004');
|
||||
```
|
||||
|
||||
**Schritt 4: Nachprüfen.** Status der Meldung und Zustand des vorläufigen Eintrags (bei `approved`/`merged`: ausgeblendet und verwiesen; bei `rejected`: unverändert):
|
||||
|
||||
```sql
|
||||
select r.id, r.status, r.target_game_id, r.resolved_at is not null as erledigt,
|
||||
g.hidden_at is not null as vorlaeufig_ausgeblendet, g.merged_into_id
|
||||
from public.game_reports r
|
||||
left join public.games g on g.id = r.provisional_game_id
|
||||
where r.id = '7c1d2c3d-0000-4000-8000-000000000001';
|
||||
```
|
||||
|
||||
**Ergebnis lesen** (Beispiel, `approved` mit vorläufigem Eintrag):
|
||||
|
||||
```
|
||||
{"plays": 2, "status": "approved", "repointed": true, "report_id": "…", "target_id": "…", "score_sheets": 1,
|
||||
"favorite_games": 1, "collection_items": 1, "variants_copied": 0, "variants_mapped": 1, "variants_cleared": 1,
|
||||
"favorite_conflicts": 0, "collection_conflicts": 0, "provisional_game_id": "…"}
|
||||
```
|
||||
|
||||
`repointed` ist `false`, wenn nichts umgehängt wurde (`rejected` oder Meldung ohne vorläufigen Eintrag); dann stehen die Zähler nicht im Ergebnis. Die Zähler haben dieselbe Bedeutung wie bei der Zusammenführung (Abschnitt 5). Wichtig hier: **`variants_cleared`** zählt die Sammlungseinträge und Partien, deren Edition geleert wurde, weil der Katalogeintrag keine gleichnamige Variante hatte. Das ist der Hinweis, dass der Nutzer die Edition neu wählen muss. Eine Kollision (`collection_conflicts`) bedeutet: Der Nutzer hatte das Ziel schon in seiner Sammlung; der Zieleintrag bleibt, leere Felder füllt der Eintrag aus dem vorläufigen Spiel.
|
||||
|
||||
**Fehlermeldungen** (alle ohne Änderung, `P0001`):
|
||||
|
||||
| Meldung | Bedeutung |
|
||||
|---|---|
|
||||
| `admin_only` | Die Sitzung läuft nicht als `postgres` bzw. `supabase_admin` (`select current_user;` prüfen) |
|
||||
| `report_not_found` | Die Meldung gibt es nicht (ID vertippt?) |
|
||||
| `report_not_open` | Die Meldung ist schon bearbeitet (`approved`, `merged` oder `rejected`) |
|
||||
| `report_status_invalid` | Der Status ist nicht `approved`, `merged` oder `rejected` |
|
||||
| `report_target_required` | `approved` und `merged` brauchen ein Ziel |
|
||||
| `report_target_not_listed` | Das Ziel ist kein sichtbares Katalogspiel (privat, ausgeblendet, zusammengeführt oder unbekannt); das endgültige Ziel wählen oder zuerst wieder einblenden (siehe 4) |
|
||||
| `report_provisional_not_private` | Der vorläufige Eintrag der Meldung ist kein privates Spiel (Datenfehler); nicht umhängen, sondern die Meldung prüfen |
|
||||
|
||||
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). Meldungen anlegen, ändern oder löschen können Nutzer in dieser Etappe nicht (das Anlegen kommt mit KAT-04); sie lesen nur ihre eigenen. Das Löschen eines Zielspiels, auf das eine Meldung verweist, schlägt mit `23503` fehl (gewollt: ausblenden statt löschen).
|
||||
|
||||
## 7. Admin-Rolle in der App
|
||||
|
||||
|
|
|
|||
|
|
@ -718,3 +718,24 @@ Umgesetzt: B5 (Migration `20261103193928_catalog_merge.sql`), B6 (pgTAP `07_cata
|
|||
- **Schritt 4 aus 3.3** (`game_reports.target_game_id` umhängen) entfällt bis 2c, wie vorgesehen.
|
||||
- **Hinweis für 2c (B9):** Sobald `game_reports` zwei Fremdschlüssel auf `games` bekommt, wird der Wächter in `07` rot. Die erwartete Liste in `07` muss dort um `game_reports.target_game_id` (behandelt) und `game_reports.provisional_game_id` (bewusst ausgelassen) erweitert werden, nicht nur der Wächter in `08`.
|
||||
- **Runbook:** Alle SQL-Bausteine aus Abschnitt 5 laufen wörtlich in `07` (einmalige Prüfung per Skript; keine CI-Prüfung). Abschnitt 5 enthält die Duplikatsuche, eine Vorschau (Verweise, Doppelbesitz), den Aufruf, die Nachprüfung, die Regeln, die Schlüssel des Ergebnis-JSON und die Fehlertexte.
|
||||
|
||||
- **Nachtrag Review 2b:** Tests ergänzt (Spec-F1-Edition am Ziel, `variants_cleared` im Kollisionszweig, gleichnamige Quellvarianten, zweiter Fremdschlüssel-Wächter auf `game_variants`, unbeteiligte Spiele, Ziel `null`); Runbook um Trockenlauf (`begin; …; rollback;` in einem Lauf, nur dieser Baustein läuft nicht wörtlich in pgTAP), Ketten-Nachprüfung und Nachschlagen von `merged_into_id` ergänzt. Der Null-Check in `admin_merge_games` ist ein äquivalenter Mutant (das spätere `not found` liefert denselben Fehler) und bleibt als ausdrückliche Absicherung. Offen für Chris: Verweise, die nach dem Merge noch auf die Quelle entstehen (W3).
|
||||
|
||||
### 2026-11-03 – Umsetzung PR 2c (backend-dev)
|
||||
|
||||
Umgesetzt: B8 (Migration `20261103195151_game_reports.sql`), B9 (pgTAP `08_game_reports.test.sql`, 153 Tests; `07` und `00` angepasst), B10 (Runbook Abschnitt 6). Abweichungen und Ergänzungen, jeweils zur Prüfung im PR:
|
||||
|
||||
- **Branch:** `feature/2c-katalog-meldungen` statt `feature/2-katalog-meldungen` (Vorgabe des Auftrags), gestapelt auf `feature/2b-katalog-zusammenfuehren`.
|
||||
- **Zusätzliche Indizes** auf beide Fremdschlüssel nach `games` (Regel: Index auf jeden Fremdschlüssel). `provisional_game_id` ist **eindeutig** (`where provisional_game_id is not null`, 1:1: ein vorläufiger Eintrag gehört zu höchstens einer Meldung), `target_game_id` ein einfacher Index.
|
||||
- **Zusatzfehler `report_provisional_not_private`** in `admin_resolve_report`: Ist der vorläufige Eintrag kein privates Spiel (Datenfehler per Studio), wird nichts umgehängt; sonst würde `repoint_game_references` ein Katalogspiel wie beim Zusammenführen behandeln (Varianten kopieren und löschen).
|
||||
- **Ergebnis-JSON `admin_resolve_report`:** `report_id`, `status`, `target_id`, `provisional_game_id`, `repointed` (bool). Nur bei `repointed = true` zusätzlich die Zähler aus `repoint_game_references`. `rejected` und Meldungen ohne vorläufigen Eintrag liefern `repointed = false` und keine Zähler.
|
||||
- **Ergebnis-JSON `admin_merge_games`:** neuer Schlüssel `reports_repointed` (Anzahl umgehängter Meldungen, immer vorhanden). Dadurch mussten zwei Assertions in `07` (exaktes JSON) und das Beispiel im Runbook Abschnitt 5 angepasst werden. Rumpf von `admin_merge_games` sonst unverändert (per `diff` gegen 2b geprüft); `revoke execute … from public, anon, authenticated, service_role` nach dem `create or replace` ausdrücklich wiederholt.
|
||||
- **Sperren:** `admin_resolve_report` sperrt die Meldung und danach Ziel und vorläufigen Eintrag in `id`-Reihenfolge (`for update`), wie `admin_merge_games`. Dass der vorläufige Eintrag existiert, sichert der Fremdschlüssel (`on delete set null`); `repoint_game_references` selbst prüft die Existenz von `p_from` weiterhin nicht.
|
||||
- **Ziel wird auch bei `rejected` geprüft** (`report_target_not_listed`), wenn eines angegeben ist. Reihenfolge der Prüfungen wie in 3.4; `p_report = null` ist `report_not_found`, `p_status = null` ist `report_status_invalid`.
|
||||
- **Trigger `games_visibility_immutable`** läuft vor der RLS-Prüfung: Ein Nutzer, der `visibility` ändert, bekommt `P0001 visibility_immutable` statt `42501` (API-Änderung für Clients; bestehende Tests in `02` bleiben grün, weil dort `owner_id` ohne Spaltenrecht vorher mit 42501 scheitert). Es gibt weiterhin keine generierten Datenbanktypen im Repo.
|
||||
- **RLS:** zwei Lese-Policies (eigene Zeilen; `is_admin()` für R4). Bewusst kein Test mit `is_admin()` als Zugang in R1, aber `08` dokumentiert, dass `app_metadata.role = 'admin'` lesen, nicht schreiben kann, und `user_metadata` nichts öffnet.
|
||||
- **Tests stabil gegenüber #13:** `08` prüft Schreibrechte je Spalte (`has_column_privilege` auf `status`, `target_game_id`, `resolved_at`, `origin`, `owner_id`, `bgg_id`, `created_at`), „Delete verboten“ und „fremde Meldung nicht sichtbar/änderbar“, nicht „kein Insert/Update auf der Tabelle“.
|
||||
- **Bewusst nicht gebaut (offene Entscheidung Chris, Spec #13):** `game_reports.result_seen_at` und `editions_cleared`. #13 ergänzt bei Freigabe das Spaltenrecht `update (result_seen_at)` und ggf. `editions_cleared` sowie Insert-Recht und -Policy; die Tests in `08` sind darauf vorbereitet.
|
||||
- **Fremdschlüssel-Wächter:** erweiterte Liste in `07` und in `08` (`game_reports.target_game_id` behandelt, `game_reports.provisional_game_id` bewusst ausgelassen). `00_schema.test.sql`: 21 Tabellen.
|
||||
- **Runbook Abschnitt 6:** Alle SQL-Bausteine laufen wörtlich in `08` (einmalige Prüfung per Skript, keine CI-Prüfung). Der Trockenlauf-Baustein aus 2b gilt entsprechend (`begin; …; rollback;` in einem Lauf).
|
||||
- **Mutationsprüfung:** 25 Mutanten (u. a. Trigger entfernt/ohne `owner_id`, Grants, Policies, RLS aus, Fremdschlüssel-Aktionen, N12a-Index, Funktionsrumpf-Änderungen) gegen `08`; alle werden erkannt. Bei der Prüfung fiel ein Scheintest auf (Löschen eines Ziels scheiterte an anderen Verweisen), jetzt mit eigenem Ziel. Nicht erkennbar: das Weglassen des wiederholten `revoke` allein (Postgres behält die Rechte bei `create or replace`); erkannt wird stattdessen ein ausdrückliches `grant execute`.
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
## Inhalt
|
||||
| Pfad | Zweck |
|
||||
|---|---|
|
||||
| `migrations/` | Schema, Trigger und RLS-Policies für R1 (20 Tabellen, 3 Views). Nur additiv ändern, nie bestehende Dateien editieren. |
|
||||
| `migrations/` | Schema, Trigger und RLS-Policies für R1 (21 Tabellen, 3 Views). Nur additiv ändern, nie bestehende Dateien editieren. |
|
||||
| `tests/database/` | pgTAP-Tests: Struktur-Regeln und Zugriffsrechte je Bereich. |
|
||||
| `templates/` | Auth-Mailvorlagen (HTML, Go-Template) für Bestätigung und Passwort-Reset. |
|
||||
| `seed.sql` | Beispieldaten nur für die lokale Entwicklung. |
|
||||
|
|
@ -32,7 +32,8 @@ supabase test db # alle pgTAP-Tests ausführen
|
|||
## Katalog (KAT-05)
|
||||
- Pflege in R1 nur über Studio bzw. `psql` (Rolle `postgres`), Ablauf und Regeln: [`docs/katalog-pflege.md`](../docs/katalog-pflege.md). Spec: `docs/specs/2-katalog-daten.md`.
|
||||
- Suche und Entdecken lesen nur die View `catalog_games` (ohne ausgeblendete und zusammengeführte Spiele). Geänderte Fakten trägt ein Trigger in `games.manual_fields` ein.
|
||||
- Zusammenführen von Duplikaten nur über die Funktion `public.admin_merge_games(quelle, ziel)` (nur Datenbankrolle `postgres`/`supabase_admin`, nicht über die API aufrufbar); Ablauf in `docs/katalog-pflege.md`, Abschnitt 5. Jede neue Tabelle mit Verweis auf `games` muss dort behandelt werden (Wächter in `tests/database/07_catalog_merge.test.sql`).
|
||||
- Zusammenführen von Duplikaten nur über die Funktion `public.admin_merge_games(quelle, ziel)` (nur Datenbankrolle `postgres`/`supabase_admin`, nicht über die API aufrufbar); Ablauf in `docs/katalog-pflege.md`, Abschnitt 5. Jede neue Tabelle mit Verweis auf `games` muss dort behandelt werden (Wächter in `tests/database/07_catalog_merge.test.sql` und `08_game_reports.test.sql`).
|
||||
- Meldungen fehlender Spiele (`game_reports`) bearbeitet der Admin nur über `public.admin_resolve_report(meldung, status, ziel)` (nur Datenbankrolle `postgres`/`supabase_admin`); Ablauf in `docs/katalog-pflege.md`, Abschnitt 6. `visibility` und `owner_id` eines Spiels sind unveränderlich (Trigger `games_visibility_immutable`).
|
||||
- `db reset` spielt `seed.sql`, `catalog/vocabulary.sql` und `catalog/startbestand.sql` ein (`[db.seed] sql_paths` in `config.toml`). Die SQL-Dateien in `catalog/` sind **erzeugt**; Quellen sind die CSV-Dateien. Neu erzeugen und testen (Deno, `brew install deno`):
|
||||
```bash
|
||||
deno run --allow-read=supabase --allow-write=supabase supabase/catalog/build_startbestand.ts
|
||||
|
|
|
|||
280
supabase/migrations/20261103195151_game_reports.sql
Normal file
280
supabase/migrations/20261103195151_game_reports.sql
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
-- KAT-05 / PR 2c: Meldungen (Spec docs/specs/2-katalog-daten.md, Abschnitt 3.4).
|
||||
-- Nur additiv. Enthält:
|
||||
-- games_visibility_immutable Trigger: Sichtbarkeit und Eigentümer eines Spiels sind unveränderlich (E14)
|
||||
-- game_reports Tabelle mit RLS (Nutzer lesen nur ihre eigenen Meldungen)
|
||||
-- admin_resolve_report Admin-Funktion: Meldung freigeben, zusammenführen oder ablehnen
|
||||
-- admin_merge_games per create or replace erweitert: hängt auch game_reports.target_game_id um
|
||||
-- Anlegen von Meldungen durch Nutzer (Insert-Recht, Insert-Policy, Tageslimit) liefert #13 (KAT-04).
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- E14: visibility und owner_id einer bestehenden games-Zeile ändern sich nie, für keine Rolle
|
||||
-- (auch nicht für postgres). Ein privater Eintrag wird nie öffentlich geschaltet; freigegebene
|
||||
-- Meldungen bekommen immer einen neuen Katalogeintrag. Das Konto-Löschen ist nicht betroffen
|
||||
-- (private Spiele werden per on delete cascade gelöscht, nicht geändert).
|
||||
-- Hinweis: Ein BEFORE-Trigger läuft vor der RLS-Prüfung (with check). Ein Nutzer, der visibility
|
||||
-- ändert, bekommt deshalb P0001 visibility_immutable statt 42501.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
create function public.games_visibility_immutable()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
set search_path = ''
|
||||
as $$
|
||||
begin
|
||||
if new.visibility is distinct from old.visibility or new.owner_id is distinct from old.owner_id then
|
||||
raise exception 'visibility_immutable' using errcode = 'P0001';
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create trigger games_visibility_immutable before update on public.games
|
||||
for each row execute function public.games_visibility_immutable();
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- Tabelle game_reports (Klärungen 6, 10, 26, 27, N12, N12a; E12)
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
create table public.game_reports (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
-- meldender Nutzer; Klärung 10: beim Konto-Löschen hart gelöscht
|
||||
owner_id uuid not null references public.profiles (id) on delete cascade,
|
||||
origin text not null default 'user' check (origin in ('user', 'import')),
|
||||
title text not null check (char_length(title) between 1 and 200),
|
||||
publisher_name text check (char_length(publisher_name) <= 200),
|
||||
year smallint check (year between 1800 and 2100),
|
||||
link text check (char_length(link) <= 500 and link ~ '^https?://'),
|
||||
bgg_id integer,
|
||||
-- vorläufiger privater Eintrag des Meldenden (KAT-04 Krit. 3); verschwindet er, bleibt die Meldung
|
||||
provisional_game_id uuid references public.games (id) on delete set null,
|
||||
status text not null default 'open' check (status in ('open', 'approved', 'rejected', 'merged')),
|
||||
-- Zieleintrag: wie merged_into_id on delete restrict (Katalogspiele werden ausgeblendet, nicht gelöscht)
|
||||
target_game_id uuid references public.games (id) on delete restrict,
|
||||
resolved_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint game_reports_resolved_matches_status check ((status = 'open') = (resolved_at is null)),
|
||||
constraint game_reports_open_has_no_target check (status <> 'open' or target_game_id is null),
|
||||
constraint game_reports_target_required check (status not in ('approved', 'merged') or target_game_id is not null)
|
||||
);
|
||||
|
||||
create trigger set_updated_at before update on public.game_reports
|
||||
for each row execute function public.set_updated_at();
|
||||
|
||||
-- Tageslimit (#13): (owner_id, created_at)
|
||||
create index game_reports_owner_created_idx on public.game_reports (owner_id, created_at);
|
||||
-- N12a: je Nutzer höchstens eine offene Import-Meldung je BGG-Kennung
|
||||
create unique index game_reports_import_open_key on public.game_reports (owner_id, bgg_id)
|
||||
where origin = 'import' and status = 'open' and bgg_id is not null;
|
||||
-- Admin-Liste: offene Meldungen, älteste zuerst
|
||||
create index game_reports_open_idx on public.game_reports (created_at) where status = 'open';
|
||||
-- Fremdschlüssel (Ergänzung zur Spec: Index auf jeden Fremdschlüssel). Ein vorläufiger Eintrag
|
||||
-- gehört zu höchstens einer Meldung (1:1), deshalb eindeutig; mehrere Meldungen können dasselbe
|
||||
-- Ziel haben, daher dort ein einfacher Index.
|
||||
create unique index game_reports_provisional_game_id_key on public.game_reports (provisional_game_id)
|
||||
where provisional_game_id is not null;
|
||||
create index game_reports_target_game_id_idx on public.game_reports (target_game_id);
|
||||
|
||||
-- RLS: Der Meldende liest seine eigenen Meldungen (Ergebnishinweis, KAT-04 Krit. 4-6). Die
|
||||
-- Admin-Policy (is_admin) ist für R4 vorbereitet, in R1 wird die Rolle niemandem vergeben (E10).
|
||||
-- Schreibrechte gibt es für anon und authenticated nicht; #13 ergänzt Insert-Recht und -Policy.
|
||||
alter table public.game_reports enable row level security;
|
||||
revoke all on public.game_reports from anon, authenticated;
|
||||
grant select on public.game_reports to authenticated;
|
||||
|
||||
create policy game_reports_select_own on public.game_reports
|
||||
for select to authenticated
|
||||
using (owner_id = (select auth.uid()));
|
||||
create policy game_reports_select_admin on public.game_reports
|
||||
for select to authenticated
|
||||
using (public.is_admin());
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- Admin-Funktion: Meldung bearbeiten (Krit. 5). security invoker, nur postgres/supabase_admin.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
create function public.admin_resolve_report(p_report uuid, p_status text, p_target uuid default null)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
security invoker
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
rep public.game_reports;
|
||||
prov public.games;
|
||||
result jsonb := '{}'::jsonb;
|
||||
repointed boolean := false;
|
||||
begin
|
||||
if current_user not in ('postgres', 'supabase_admin') then
|
||||
raise exception 'admin_only' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
select * into rep from public.game_reports r where r.id = p_report for update;
|
||||
if not found then
|
||||
raise exception 'report_not_found' using errcode = 'P0001';
|
||||
end if;
|
||||
if rep.status <> 'open' then
|
||||
raise exception 'report_not_open' using errcode = 'P0001';
|
||||
end if;
|
||||
if p_status is null or p_status not in ('approved', 'merged', 'rejected') then
|
||||
raise exception 'report_status_invalid' using errcode = 'P0001';
|
||||
end if;
|
||||
if p_status in ('approved', 'merged') and p_target is null then
|
||||
raise exception 'report_target_required' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
-- Beteiligte Spiele in fester Reihenfolge sperren (wie admin_merge_games).
|
||||
perform 1 from public.games g
|
||||
where g.id in (p_target, rep.provisional_game_id) order by g.id for update;
|
||||
|
||||
if p_target is not null and not exists (
|
||||
select 1 from public.games g
|
||||
where g.id = p_target and g.visibility = 'catalog' and g.hidden_at is null and g.merged_into_id is null
|
||||
) then
|
||||
raise exception 'report_target_not_listed' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
if p_status in ('approved', 'merged') and rep.provisional_game_id is not null then
|
||||
select * into prov from public.games g where g.id = rep.provisional_game_id;
|
||||
-- Zusatzprüfung (nicht in der Spec): Nur ein privater Eintrag darf als vorläufiger Eintrag
|
||||
-- umgehängt werden; ein Katalogspiel würde sonst über den Variantenfall "Katalog" kopiert.
|
||||
if prov.visibility <> 'private' then
|
||||
raise exception 'report_provisional_not_private' using errcode = 'P0001';
|
||||
end if;
|
||||
-- privat -> Katalog: nichts wird kopiert (RB1, Klärung N15)
|
||||
result := public.repoint_game_references(prov.id, p_target);
|
||||
repointed := true;
|
||||
-- merged_into_id verlangt hidden_at (Check), deshalb in einem Statement.
|
||||
update public.games g
|
||||
set merged_into_id = p_target, hidden_at = coalesce(g.hidden_at, now())
|
||||
where g.id = prov.id;
|
||||
end if;
|
||||
|
||||
-- Bei "rejected" mit Ziel wird nur der Verweis gespeichert (Spec-F3); der vorläufige Eintrag
|
||||
-- bleibt unverändert, ob der Nutzer wechselt, entscheidet er in der App.
|
||||
update public.game_reports r
|
||||
set status = p_status, target_game_id = p_target, resolved_at = now()
|
||||
where r.id = rep.id;
|
||||
|
||||
return result || jsonb_build_object(
|
||||
'report_id', rep.id,
|
||||
'status', p_status,
|
||||
'target_id', p_target,
|
||||
'provisional_game_id', rep.provisional_game_id,
|
||||
'repointed', repointed
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- admin_merge_games: Rumpf aus PR 2b unverändert, nur Schritt 4 neu (game_reports.target_game_id
|
||||
-- von der Quelle auf das Ziel umhängen) und der Zähler reports_repointed im Ergebnis.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
create or replace 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;
|
||||
n_reports 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. (neu in 2c) Meldungen, die auf die Quelle als Zieleintrag zeigen, zeigen jetzt auf das Ziel.
|
||||
update public.game_reports r set target_game_id = p_target where r.target_game_id = p_source;
|
||||
get diagnostics n_reports = row_count;
|
||||
|
||||
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,
|
||||
'reports_repointed', n_reports
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- Rechte-Regel: nur der Eigentümer darf ausführen. Nach dem create or replace wird der revoke
|
||||
-- für admin_merge_games ausdrücklich wiederholt (create or replace behält zwar die bisherigen
|
||||
-- Rechte, aber das soll nicht still vorausgesetzt werden).
|
||||
revoke execute on function public.games_visibility_immutable() from public, anon, authenticated, service_role;
|
||||
revoke execute on function public.admin_resolve_report(uuid, text, uuid) from public, anon, authenticated, service_role;
|
||||
revoke execute on function public.admin_merge_games(uuid, uuid) from public, anon, authenticated, service_role;
|
||||
|
|
@ -2,8 +2,8 @@
|
|||
begin;
|
||||
select plan(5);
|
||||
|
||||
select is((select count(*) from pg_tables where schemaname = 'public'), 20::bigint,
|
||||
'R1 hat genau 20 Tabellen (17 + catalog_sources, publishers, game_publishers)');
|
||||
select is((select count(*) from pg_tables where schemaname = 'public'), 21::bigint,
|
||||
'R1 hat genau 21 Tabellen (17 + catalog_sources, publishers, game_publishers, game_reports)');
|
||||
|
||||
select is_empty($$
|
||||
select c.relname from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@
|
|||
-- 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.
|
||||
-- Ab 2c kennt der Fremdschlüssel-Wächter am Ende auch game_reports.target_game_id (behandelt) und
|
||||
-- game_reports.provisional_game_id (bewusst ausgelassen); admin_merge_games meldet reports_repointed.
|
||||
begin;
|
||||
select plan(89);
|
||||
select plan(107);
|
||||
|
||||
insert into auth.users (id, email, raw_user_meta_data) values
|
||||
('11111111-1111-1111-1111-111111111111', 'anna@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Anna"}'),
|
||||
|
|
@ -156,6 +156,16 @@ insert into public.plays (id, owner_id, game_id, variant_id, played_at, client_i
|
|||
insert into public.score_sheets (id, owner_id, game_id, name) values
|
||||
('f7000000-0000-0000-0000-0000000000b5', '11111111-1111-1111-1111-111111111111', '7a1b2c3d-0000-4000-8000-000000000002', 'Bogen');
|
||||
|
||||
-- Unbeteiligtes Spiel mit Verweisen: darf vom Zusammenführen nicht berührt werden
|
||||
insert into public.games (id, title, visibility) values ('f7000000-0000-0000-0000-000000000501', 'Unbeteiligt', 'catalog');
|
||||
insert into public.game_variants (id, game_id, name) values ('f7000000-0000-0000-0000-0000000005e1', 'f7000000-0000-0000-0000-000000000501', 'Deluxe');
|
||||
insert into public.collection_items (id, owner_id, game_id, variant_id, list, note) values
|
||||
('f7000000-0000-0000-0000-0000000005c1', '44444444-4444-4444-4444-444444444444', 'f7000000-0000-0000-0000-000000000501', 'f7000000-0000-0000-0000-0000000005e1', 'wishlist', 'bleibt');
|
||||
insert into public.plays (id, owner_id, game_id, variant_id, played_at, client_id) values
|
||||
('f7000000-0000-0000-0000-0000000005b1', '44444444-4444-4444-4444-444444444444', 'f7000000-0000-0000-0000-000000000501', 'f7000000-0000-0000-0000-0000000005e1', '2026-04-01', 'f7000000-0000-0000-0000-0000000005b1');
|
||||
insert into public.favorite_games (id, profile_id, game_id, position) values
|
||||
('f7000000-0000-0000-0000-0000000005d1', '44444444-4444-4444-4444-444444444444', 'f7000000-0000-0000-0000-000000000501', 1);
|
||||
|
||||
-- Runbook 5, Schritt 1: Duplikate finden (global; geprüft wird nur die Fixture-Zeile)
|
||||
create temp table rb_dup as
|
||||
select lower(btrim(title)) as titel_normalisiert, year,
|
||||
|
|
@ -209,7 +219,7 @@ select is((select admin_merge_games from rb_merge),
|
|||
'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),
|
||||
'bgg_id_transferred', true, 'source_transferred', true, 'chain_flattened', 0, 'reports_repointed', 0),
|
||||
'KAT-05 Kriterium 8: Runbook 5, das Ergebnis-JSON zählt alles richtig');
|
||||
|
||||
-- Runbook 5, Schritt 4: Nachprüfen (der Vorschau-Baustein aus Schritt 2 liefert überall 0)
|
||||
|
|
@ -233,6 +243,17 @@ select set_eq($$select id, ausgeblendet, merged_into_id, bgg_id, catalog_source_
|
|||
('7a1b2c3d-0000-4000-8000-000000000003', false, null, 13, true)$$,
|
||||
'KAT-05 Kriterium 4: Quelle ausgeblendet und verwiesen, Ziel sichtbar und mit BGG-Kennung und Quelle der Quelle');
|
||||
|
||||
-- Runbook 5, Schritt 4: Nachprüfung auf Ketten (muss leer sein)
|
||||
create temp table rb_ketten as
|
||||
select id from public.games where merged_into_id = '7a1b2c3d-0000-4000-8000-000000000002';
|
||||
select is_empty($$select * from rb_ketten$$, 'KAT-05 Kriterium 8: Runbook 5, kein Spiel verweist mehr auf die Quelle');
|
||||
|
||||
-- Runbook 5, Fehlertext merge_target_not_listed: wohin verweist ein zusammengeführtes Spiel?
|
||||
create temp table rb_verweis as
|
||||
select id, title, hidden_at, merged_into_id from public.games where id = '7a1b2c3d-0000-4000-8000-000000000002';
|
||||
select results_eq($$select merged_into_id from rb_verweis$$, $$values ('7a1b2c3d-0000-4000-8000-000000000003'::uuid)$$,
|
||||
'KAT-05 Kriterium 8: Runbook 5, Nachschlagen des endgültigen Ziels über merged_into_id');
|
||||
|
||||
-- Weitere Prüfungen am Ergebnis des Hauptfalls
|
||||
select is((select count(*) from public.catalog_games where id in ('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003')), 1::bigint,
|
||||
'KAT-05 Kriterium 4: catalog_games enthält nur noch das Ziel');
|
||||
|
|
@ -268,8 +289,16 @@ select is((select row(list, note, acquired_on)::text from public.collection_item
|
|||
select is((select row(list, note, acquired_on)::text from public.collection_items where id = 'f7000000-0000-0000-0000-0000000000c7'),
|
||||
'(owned,"Doras Quellnotiz",)',
|
||||
'Spec-F1: Dora: owned (Quelle) vor shopping (Ziel); eine leere Notiz am Ziel wird aus der Quelle gefüllt');
|
||||
select is((select count(*) from public.collection_items where owner_id = '44444444-4444-4444-4444-444444444444'), 1::bigint,
|
||||
select is((select count(*) from public.collection_items where owner_id = '44444444-4444-4444-4444-444444444444'
|
||||
and game_id in ('7a1b2c3d-0000-4000-8000-000000000002', '7a1b2c3d-0000-4000-8000-000000000003')), 1::bigint,
|
||||
'Spec-F1: Doras Quell-Eintrag ist gelöscht');
|
||||
select is((select row(c.game_id, c.variant_id, c.list, c.note)::text || '|' || p.game_id::text || p.variant_id::text || '|' || f.game_id::text || f.position::text
|
||||
from public.collection_items c, public.plays p, public.favorite_games f
|
||||
where c.id = 'f7000000-0000-0000-0000-0000000005c1' and p.id = 'f7000000-0000-0000-0000-0000000005b1' and f.id = 'f7000000-0000-0000-0000-0000000005d1'),
|
||||
'(f7000000-0000-0000-0000-000000000501,f7000000-0000-0000-0000-0000000005e1,wishlist,bleibt)|f7000000-0000-0000-0000-000000000501f7000000-0000-0000-0000-0000000005e1|f7000000-0000-0000-0000-0000000005011',
|
||||
'Daten unbeteiligter Spiele bleiben unverändert (Sammlung, Partie, Favorit, Variante)');
|
||||
select is((select count(*) from public.game_variants where game_id = 'f7000000-0000-0000-0000-000000000501'), 1::bigint,
|
||||
'Varianten unbeteiligter Spiele bleiben erhalten');
|
||||
select is((select count(*) from public.collection_items where game_id = '7a1b2c3d-0000-4000-8000-000000000003'), 4::bigint,
|
||||
'Jeder der vier Nutzer hat genau einen Eintrag am Ziel');
|
||||
|
||||
|
|
@ -308,7 +337,7 @@ select is((select count(distinct variant_id) from (
|
|||
-- Löschen eines Ziels mit Verweis (nur der Verweis merged_into_id, ohne andere Verweise)
|
||||
insert into public.games (id, title, visibility) values
|
||||
('f7000000-0000-0000-0000-0000000000b8', 'Löschziel', 'catalog'), ('f7000000-0000-0000-0000-0000000000b9', 'Löschquelle', 'catalog');
|
||||
select public.admin_merge_games('f7000000-0000-0000-0000-0000000000b9', 'f7000000-0000-0000-0000-0000000000b8');
|
||||
select lives_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-0000000000b9', 'f7000000-0000-0000-0000-0000000000b8')$$, 'Zusammenführen läuft ohne Fehler (0000b8)');
|
||||
select throws_ok($$delete from public.games where id = 'f7000000-0000-0000-0000-0000000000b8'$$, '23503',
|
||||
null, 'KAT-05 Kriterium 4: Ein Ziel mit Verweis lässt sich nicht löschen (on delete restrict)');
|
||||
|
||||
|
|
@ -356,7 +385,7 @@ select is((select r ? 'bgg_id_conflict' from rb_tgt), false, 'Kein Konflikt, wen
|
|||
insert into public.games (id, title, visibility, hidden_at) values
|
||||
('f7000000-0000-0000-0000-000000000131', 'Versteckte Quelle', 'catalog', '2020-05-05 00:00:00+00'),
|
||||
('f7000000-0000-0000-0000-000000000132', 'Ziel zu Versteckt', 'catalog', null);
|
||||
select public.admin_merge_games('f7000000-0000-0000-0000-000000000131', 'f7000000-0000-0000-0000-000000000132');
|
||||
select lives_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-000000000131', 'f7000000-0000-0000-0000-000000000132')$$, 'Zusammenführen läuft ohne Fehler (000132)');
|
||||
select is((select hidden_at from public.games where id = 'f7000000-0000-0000-0000-000000000131'), '2020-05-05 00:00:00+00'::timestamptz,
|
||||
'Eine bereits ausgeblendete Quelle behält ihr hidden_at');
|
||||
select is((select merged_into_id from public.games where id = 'f7000000-0000-0000-0000-000000000131'), 'f7000000-0000-0000-0000-000000000132'::uuid,
|
||||
|
|
@ -369,7 +398,7 @@ insert into public.games (id, title, visibility) values
|
|||
('f7000000-0000-0000-0000-000000000141', 'Kette A', 'catalog'),
|
||||
('f7000000-0000-0000-0000-000000000142', 'Kette B', 'catalog'),
|
||||
('f7000000-0000-0000-0000-000000000143', 'Kette C', 'catalog');
|
||||
select public.admin_merge_games('f7000000-0000-0000-0000-000000000143', 'f7000000-0000-0000-0000-000000000141');
|
||||
select lives_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-000000000143', 'f7000000-0000-0000-0000-000000000141')$$, 'Zusammenführen läuft ohne Fehler (000141)');
|
||||
select is((select merged_into_id from public.games where id = 'f7000000-0000-0000-0000-000000000143'), 'f7000000-0000-0000-0000-000000000141'::uuid, 'Kette: C zeigt zuerst auf A');
|
||||
create temp table rb_chain as select public.admin_merge_games('f7000000-0000-0000-0000-000000000141', 'f7000000-0000-0000-0000-000000000142') as r;
|
||||
select is((select merged_into_id from public.games where id = 'f7000000-0000-0000-0000-000000000143'), 'f7000000-0000-0000-0000-000000000142'::uuid,
|
||||
|
|
@ -392,7 +421,7 @@ insert into public.game_variants (id, game_id, name, created_at) values
|
|||
insert into public.plays (id, owner_id, game_id, variant_id, played_at, client_id) values
|
||||
('f7000000-0000-0000-0000-0000000001c1', '11111111-1111-1111-1111-111111111111', 'f7000000-0000-0000-0000-000000000151',
|
||||
'f7000000-0000-0000-0000-0000000001b1', '2026-02-01', 'f7000000-0000-0000-0000-0000000001c1');
|
||||
select public.admin_merge_games('f7000000-0000-0000-0000-000000000151', 'f7000000-0000-0000-0000-000000000152');
|
||||
select lives_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-000000000151', 'f7000000-0000-0000-0000-000000000152')$$, 'Zusammenführen läuft ohne Fehler (000152)');
|
||||
select is((select variant_id from public.plays where id = 'f7000000-0000-0000-0000-0000000001c1'), 'f7000000-0000-0000-0000-0000000001a1'::uuid,
|
||||
'KAT-05 Kriterium 4: bei Gleichstand von created_at gilt die kleinste id');
|
||||
select is((select count(*) from public.game_variants where game_id = 'f7000000-0000-0000-0000-000000000152'), 3::bigint,
|
||||
|
|
@ -402,7 +431,7 @@ select is((select count(*) from public.game_variants where game_id = 'f7000000-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,
|
||||
'{"collection_items":0,"collection_conflicts":0,"favorite_games":0,"favorite_conflicts":0,"plays":0,"score_sheets":0,"variants_copied":0,"variants_mapped":0,"variants_cleared":0,"bgg_id_transferred":false,"source_transferred":false,"chain_flattened":0,"reports_repointed":0}'::jsonb,
|
||||
'Zusammenführen ohne Verweise liefert lauter Nullen');
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
|
|
@ -456,11 +485,59 @@ insert into public.game_variants (id, game_id, name) values
|
|||
insert into public.collection_items (id, owner_id, game_id, variant_id, list) values
|
||||
('f7000000-0000-0000-0000-0000000003c1', '33333333-3333-3333-3333-333333333333', 'f7000000-0000-0000-0000-0000000003a1', 'f7000000-0000-0000-0000-0000000003e1', 'owned'),
|
||||
('f7000000-0000-0000-0000-0000000003c2', '33333333-3333-3333-3333-333333333333', 'f7000000-0000-0000-0000-0000000003b1', null, 'owned');
|
||||
select public.admin_merge_games('f7000000-0000-0000-0000-0000000003a1', 'f7000000-0000-0000-0000-0000000003b1');
|
||||
select lives_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-0000000003a1', 'f7000000-0000-0000-0000-0000000003b1')$$, 'Zusammenführen läuft ohne Fehler (0003b1)');
|
||||
select is((select v.name || '/' || v.game_id::text from public.collection_items c join public.game_variants v on v.id = c.variant_id
|
||||
where c.id = 'f7000000-0000-0000-0000-0000000003c2'),
|
||||
'Jubiläum/f7000000-0000-0000-0000-0000000003b1', 'Spec-F1: Kollision: die Edition der Quelle kommt über die Zuordnung (Kopie am Ziel) in den Zieleintrag');
|
||||
|
||||
-- Spec-F1 Kernregel (W1): Doppelbesitz, beide Einträge haben eine eigene Edition -> der Zieleintrag behält seine
|
||||
insert into public.games (id, title, visibility) values
|
||||
('f7000000-0000-0000-0000-000000000401', 'Edition Quelle', 'catalog'), ('f7000000-0000-0000-0000-000000000402', 'Edition Ziel', 'catalog');
|
||||
insert into public.game_variants (id, game_id, name) values
|
||||
('f7000000-0000-0000-0000-0000000004e1', 'f7000000-0000-0000-0000-000000000401', 'Y-Edition'),
|
||||
('f7000000-0000-0000-0000-0000000004e2', 'f7000000-0000-0000-0000-000000000402', 'X-Edition');
|
||||
insert into public.collection_items (id, owner_id, game_id, variant_id, list) values
|
||||
('f7000000-0000-0000-0000-0000000004c1', '11111111-1111-1111-1111-111111111111', 'f7000000-0000-0000-0000-000000000401', 'f7000000-0000-0000-0000-0000000004e1', 'owned'),
|
||||
('f7000000-0000-0000-0000-0000000004c2', '11111111-1111-1111-1111-111111111111', 'f7000000-0000-0000-0000-000000000402', 'f7000000-0000-0000-0000-0000000004e2', 'owned');
|
||||
select lives_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-000000000401', 'f7000000-0000-0000-0000-000000000402')$$,
|
||||
'Zusammenführen mit Doppelbesitz und zwei Editionen läuft');
|
||||
select is((select variant_id from public.collection_items where id = 'f7000000-0000-0000-0000-0000000004c2'),
|
||||
'f7000000-0000-0000-0000-0000000004e2'::uuid, 'Spec-F1: Doppelbesitz: der Zieleintrag behält seine eigene Edition (X), die der Quelle (Y) verdrängt sie nicht');
|
||||
|
||||
-- N15 (W2): privater Eintrag -> Katalog, Doppelbesitz, Ziel ohne Edition, Quelle mit Edition ohne Gegenstück -> geleert und gezählt
|
||||
insert into public.games (id, title, visibility, owner_id) values
|
||||
('f7000000-0000-0000-0000-000000000411', 'Vorläufig Cara', 'private', '33333333-3333-3333-3333-333333333333');
|
||||
insert into public.games (id, title, visibility) values ('f7000000-0000-0000-0000-000000000412', 'Ziel Cara', 'catalog');
|
||||
insert into public.game_variants (id, game_id, name) values
|
||||
('f7000000-0000-0000-0000-0000000004e3', 'f7000000-0000-0000-0000-000000000411', 'Nur bei Cara');
|
||||
insert into public.collection_items (id, owner_id, game_id, variant_id, list) values
|
||||
('f7000000-0000-0000-0000-0000000004c3', '33333333-3333-3333-3333-333333333333', 'f7000000-0000-0000-0000-000000000411', 'f7000000-0000-0000-0000-0000000004e3', 'owned'),
|
||||
('f7000000-0000-0000-0000-0000000004c4', '33333333-3333-3333-3333-333333333333', 'f7000000-0000-0000-0000-000000000412', null, 'owned');
|
||||
create temp table rb_n15 as select public.repoint_game_references('f7000000-0000-0000-0000-000000000411', 'f7000000-0000-0000-0000-000000000412') as r;
|
||||
select is((select r from rb_n15),
|
||||
'{"collection_items":0,"collection_conflicts":1,"favorite_games":0,"favorite_conflicts":0,"plays":0,"score_sheets":0,"variants_copied":0,"variants_mapped":0,"variants_cleared":1}'::jsonb,
|
||||
'KAT-05 Kriterium 4 (N15): Doppelbesitz mit Edition ohne Gegenstück: variants_cleared = 1');
|
||||
select is((select variant_id from public.collection_items where id = 'f7000000-0000-0000-0000-0000000004c4'), null::uuid,
|
||||
'KAT-05 Kriterium 4 (N15): die Edition am Zieleintrag bleibt leer (nichts kopiert)');
|
||||
select is((select count(*) from public.game_variants where game_id = 'f7000000-0000-0000-0000-000000000412'), 0::bigint,
|
||||
'KAT-05 Kriterium 4 (N15): das Katalogspiel bekommt keine Variante');
|
||||
|
||||
-- Zwei gleichnamige Quellvarianten werden einzeln kopiert (nur gegen vorher vorhandene Zielvarianten abgeglichen)
|
||||
insert into public.games (id, title, visibility) values
|
||||
('f7000000-0000-0000-0000-000000000421', 'Zwilling Quelle', 'catalog'), ('f7000000-0000-0000-0000-000000000422', 'Zwilling Ziel', 'catalog');
|
||||
insert into public.game_variants (id, game_id, name, created_at) values
|
||||
('f7000000-0000-0000-0000-0000000004f1', 'f7000000-0000-0000-0000-000000000421', 'Twin', '2020-01-01'),
|
||||
('f7000000-0000-0000-0000-0000000004f2', 'f7000000-0000-0000-0000-000000000421', 'twin ', '2021-01-01');
|
||||
create temp table rb_twin as select public.admin_merge_games('f7000000-0000-0000-0000-000000000421', 'f7000000-0000-0000-0000-000000000422') as r;
|
||||
select is((select (r ->> 'variants_copied') || '/' || (r ->> 'variants_mapped') from rb_twin), '2/0',
|
||||
'Zwei gleichnamige Quellvarianten werden beide kopiert, keine auf die eben kopierte abgebildet');
|
||||
select is((select count(*) from public.game_variants where game_id = 'f7000000-0000-0000-0000-000000000422'), 2::bigint,
|
||||
'Das Ziel hat danach beide Kopien');
|
||||
|
||||
-- Ziel null
|
||||
select throws_ok($$select public.admin_merge_games('f7000000-0000-0000-0000-000000000001', null)$$,
|
||||
'P0001', 'merge_game_not_found', 'KAT-05 Kriterium 4: fehlendes Ziel (null) ist merge_game_not_found');
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- Fremdschlüssel-Wächter: jeder Verweis auf games ist entweder behandelt oder bewusst ausgelassen
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
|
|
@ -475,10 +552,21 @@ select set_eq($$select fk from fk_found$$,
|
|||
$$values
|
||||
-- behandelt von repoint_game_references / admin_merge_games
|
||||
('collection_items.game_id'), ('favorite_games.game_id'), ('plays.game_id'), ('score_sheets.game_id'),
|
||||
('game_variants.game_id'), ('games.merged_into_id'),
|
||||
('game_variants.game_id'), ('games.merged_into_id'), ('game_reports.target_game_id'),
|
||||
-- bewusst ausgelassen: bleiben am ausgeblendeten Quell-Eintrag
|
||||
('game_genres.game_id'), ('game_mechanics.game_id'), ('game_categories.game_id'), ('game_publishers.game_id')$$,
|
||||
('game_genres.game_id'), ('game_mechanics.game_id'), ('game_categories.game_id'), ('game_publishers.game_id'),
|
||||
-- bewusst ausgelassen: zeigt auf ein privates Spiel, das Zusammenführen zweier Katalogspiele betrifft ihn nicht
|
||||
('game_reports.provisional_game_id')$$,
|
||||
'KAT-05 Kriterium 4: Fremdschlüssel-Wächter: neue Verweise auf games erzwingen eine Entscheidung im Zusammenführen');
|
||||
|
||||
-- Zweiter Wächter: Verweise auf game_variants. Sie werden über die Variantenzuordnung umgesetzt;
|
||||
-- ein neuer Verweis (z. B. aus SAM-02/SAM-04) erzwingt eine Entscheidung in repoint_game_references.
|
||||
select set_eq($$select (select relname from pg_class where oid = c.conrelid) || '(' ||
|
||||
(select string_agg(a.attname, ', ' order by k.ord) from unnest(c.conkey) with ordinality as k(attnum, ord)
|
||||
join pg_attribute a on a.attrelid = c.conrelid and a.attnum = k.attnum) || ')'
|
||||
from pg_constraint c where c.contype = 'f' and c.confrelid = 'public.game_variants'::regclass$$,
|
||||
$$values ('collection_items(variant_id, game_id)'), ('plays(variant_id, game_id)')$$,
|
||||
'KAT-05 Kriterium 4: Fremdschlüssel-Wächter: Verweise auf game_variants sind genau Sammlung und Partien (beide über die Variantenzuordnung)');
|
||||
|
||||
select * from finish();
|
||||
rollback;
|
||||
|
|
|
|||
747
supabase/tests/database/08_game_reports.test.sql
Normal file
747
supabase/tests/database/08_game_reports.test.sql
Normal file
|
|
@ -0,0 +1,747 @@
|
|||
-- KAT-05 (PR 2c): Meldungen. Tabelle game_reports (RLS, Checks, Indizes, Fremdschlüssel),
|
||||
-- Trigger games_visibility_immutable, admin_resolve_report, admin_merge_games nach dem
|
||||
-- create or replace (Rechte, Schritt 4), Konto-Löschen nach Freigabe, Fremdschlüssel-Wächter und die
|
||||
-- SQL-Bausteine aus docs/katalog-pflege.md, Abschnitt 6 (unverändert, ohne begin/commit; Auswahl per id).
|
||||
-- Läuft auch mit eingespieltem Startbestand: eigene Fixtures mit festen IDs (Präfix 7c1d/f8), gezählt wird nur darüber.
|
||||
-- Regel dieser Datei: Jede Verweigerung steht neben einem Positivfall, sonst würde "alles verweigern" bestehen.
|
||||
begin;
|
||||
select plan(153);
|
||||
|
||||
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"}');
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- Tabellenrechte (KAT-05 Kriterium 9)
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
select ok((select relrowsecurity from pg_class where oid = 'public.game_reports'::regclass),
|
||||
'Sicherheit Kriterium 1: game_reports hat Row Level Security');
|
||||
select ok(has_table_privilege('authenticated', 'public.game_reports', 'select'),
|
||||
'KAT-05 Kriterium 5: authenticated darf game_reports lesen (eigene Zeilen, Policy)');
|
||||
-- Formuliert stabil gegenüber späteren Spaltenrechten (#13 ergänzt Insert-Recht und update (result_seen_at)):
|
||||
-- geprüft wird je Spalte, nicht "kein Insert/Update auf der Tabelle".
|
||||
select is_empty($$select c, p from unnest(array['status', 'target_game_id', 'resolved_at', 'origin', 'owner_id', 'bgg_id', 'created_at']) c,
|
||||
unnest(array['insert', 'update']) p
|
||||
where has_column_privilege('authenticated', 'public.game_reports', c, p)$$,
|
||||
'KAT-05 Kriterium 9: authenticated hat kein Schreibrecht auf status, target_game_id, resolved_at, origin, owner_id, bgg_id, created_at');
|
||||
select is_empty($$select c, p from (select attname as c from pg_attribute where attrelid = 'public.game_reports'::regclass and attnum > 0 and not attisdropped) a,
|
||||
unnest(array['insert', 'update']) p
|
||||
where has_column_privilege('anon', 'public.game_reports', c, p)$$,
|
||||
'KAT-05 Kriterium 9: anon hat auf keine Spalte von game_reports ein Schreibrecht');
|
||||
select is_empty($$select r, p from unnest(array['anon', 'authenticated']) r, unnest(array['delete', 'truncate', 'references', 'trigger']) p
|
||||
where has_table_privilege(r, 'public.game_reports', p)$$,
|
||||
'KAT-05 Kriterium 9: kein Löschen, Leeren oder Trigger-Recht für anon und authenticated');
|
||||
select is_empty($$select 1 from pg_policies where tablename = 'game_reports' and cmd in ('DELETE', 'ALL')$$,
|
||||
'KAT-05 Kriterium 9: game_reports hat keine Delete- oder All-Policy');
|
||||
select ok(not has_table_privilege('anon', 'public.game_reports', 'select'),
|
||||
'Sicherheit Kriterium 3: anon darf game_reports nicht einmal lesen');
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- Fixtures (als postgres)
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
insert into public.games (id, title, visibility) values
|
||||
('7c1d2c3d-0000-4000-8000-000000000004', 'Bestehendes Spiel', 'catalog'),
|
||||
('f8000000-0000-0000-0000-00000000000c', 'MELDUNGSSPIEL', 'catalog'),
|
||||
('f8000000-0000-0000-0000-00000000000e', 'Unbeteiligt', 'catalog');
|
||||
update public.games set year = 2023 where id = 'f8000000-0000-0000-0000-00000000000c';
|
||||
insert into public.games (id, title, year, bgg_id, visibility) values
|
||||
('f8000000-0000-0000-0000-00000000000d', 'Anderer Titel', 2020, 4711, 'catalog');
|
||||
insert into public.game_variants (id, game_id, name) values
|
||||
('f8000000-0000-0000-0000-0000000000a4', '7c1d2c3d-0000-4000-8000-000000000004', 'Standard');
|
||||
|
||||
-- vorläufige private Einträge: P1 (Anna, freigeben), P2 (Anna, zusammenführen), P3 und P4 (Cara, ablehnen)
|
||||
insert into public.games (id, title, year, description, image_path, visibility, owner_id) values
|
||||
('7c1d2c3d-0000-4000-8000-000000000003', 'Meldungsspiel', 2024, 'Annas privater Text', 'private/anna.png', 'private', '11111111-1111-1111-1111-111111111111'),
|
||||
('7c1d2c3d-0000-4000-8000-000000000008', 'Bestehendes Spiel', 2019, null, null, 'private', '11111111-1111-1111-1111-111111111111'),
|
||||
('7c1d2c3d-0000-4000-8000-000000000009', 'Caras Prototyp', 2025, null, null, 'private', '33333333-3333-3333-3333-333333333333'),
|
||||
('7c1d2c3d-0000-4000-8000-00000000000a', 'Caras Wunsch', 2025, null, null, 'private', '33333333-3333-3333-3333-333333333333');
|
||||
|
||||
insert into public.game_reports (id, owner_id, title, publisher_name, year, link, bgg_id, provisional_game_id, created_at) values
|
||||
('7c1d2c3d-0000-4000-8000-000000000001', '11111111-1111-1111-1111-111111111111', 'Meldungsspiel', 'Beispielverlag', 2024, 'https://example.org/spiel', 4711, '7c1d2c3d-0000-4000-8000-000000000003', '2026-01-01'),
|
||||
('7c1d2c3d-0000-4000-8000-000000000005', '11111111-1111-1111-1111-111111111111', 'Bestehendes Spiel', null, null, null, null, '7c1d2c3d-0000-4000-8000-000000000008', '2026-01-03'),
|
||||
('7c1d2c3d-0000-4000-8000-000000000006', '33333333-3333-3333-3333-333333333333', 'Caras Prototyp', null, null, null, null, '7c1d2c3d-0000-4000-8000-000000000009', '2026-01-02'),
|
||||
('7c1d2c3d-0000-4000-8000-000000000007', '33333333-3333-3333-3333-333333333333', 'Caras Wunsch', null, null, null, null, '7c1d2c3d-0000-4000-8000-00000000000a', '2026-01-04'),
|
||||
('7c1d2c3d-0000-4000-8000-00000000000b', '22222222-2222-2222-2222-222222222222', 'Bens Meldung ohne Eintrag', null, null, null, null, null, '2026-01-05');
|
||||
|
||||
-- Verweise an den vorläufigen Einträgen
|
||||
insert into public.game_variants (id, game_id, name) values
|
||||
('f8000000-0000-0000-0000-0000000000e1', '7c1d2c3d-0000-4000-8000-000000000003', 'Deluxe'),
|
||||
('f8000000-0000-0000-0000-0000000000e2', '7c1d2c3d-0000-4000-8000-000000000003', 'Prototyp'),
|
||||
('f8000000-0000-0000-0000-0000000000e3', '7c1d2c3d-0000-4000-8000-000000000008', ' standard');
|
||||
insert into public.collection_items (id, owner_id, game_id, variant_id, list, note) values
|
||||
('f8000000-0000-0000-0000-0000000000c1', '11111111-1111-1111-1111-111111111111', '7c1d2c3d-0000-4000-8000-000000000003', 'f8000000-0000-0000-0000-0000000000e2', 'owned', 'Annas Notiz'),
|
||||
('f8000000-0000-0000-0000-0000000000c2', '11111111-1111-1111-1111-111111111111', '7c1d2c3d-0000-4000-8000-000000000008', 'f8000000-0000-0000-0000-0000000000e3', 'wishlist', 'Annas Wunsch'),
|
||||
('f8000000-0000-0000-0000-0000000000c3', '11111111-1111-1111-1111-111111111111', '7c1d2c3d-0000-4000-8000-000000000004', null, 'owned', null),
|
||||
('f8000000-0000-0000-0000-0000000000c4', '22222222-2222-2222-2222-222222222222', '7c1d2c3d-0000-4000-8000-000000000004', 'f8000000-0000-0000-0000-0000000000a4', 'owned', 'Bens Notiz'),
|
||||
('f8000000-0000-0000-0000-0000000000c5', '33333333-3333-3333-3333-333333333333', '7c1d2c3d-0000-4000-8000-000000000009', null, 'owned', 'Caras Notiz'),
|
||||
('f8000000-0000-0000-0000-0000000000c6', '33333333-3333-3333-3333-333333333333', '7c1d2c3d-0000-4000-8000-00000000000a', null, 'wishlist', 'Caras Wunschnotiz');
|
||||
insert into public.favorite_games (id, profile_id, game_id, position) values
|
||||
('f8000000-0000-0000-0000-0000000000d1', '11111111-1111-1111-1111-111111111111', '7c1d2c3d-0000-4000-8000-000000000003', 1);
|
||||
insert into public.plays (id, owner_id, game_id, variant_id, played_at, client_id) values
|
||||
('f8000000-0000-0000-0000-0000000000b1', '11111111-1111-1111-1111-111111111111', '7c1d2c3d-0000-4000-8000-000000000003', 'f8000000-0000-0000-0000-0000000000e1', '2026-01-01', 'f8000000-0000-0000-0000-0000000000b1'),
|
||||
('f8000000-0000-0000-0000-0000000000b2', '11111111-1111-1111-1111-111111111111', '7c1d2c3d-0000-4000-8000-000000000003', null, '2026-01-02', 'f8000000-0000-0000-0000-0000000000b2'),
|
||||
('f8000000-0000-0000-0000-0000000000b3', '22222222-2222-2222-2222-222222222222', '7c1d2c3d-0000-4000-8000-000000000004', 'f8000000-0000-0000-0000-0000000000a4', '2026-01-03', 'f8000000-0000-0000-0000-0000000000b3'),
|
||||
('f8000000-0000-0000-0000-0000000000b4', '33333333-3333-3333-3333-333333333333', '7c1d2c3d-0000-4000-8000-000000000009', null, '2026-01-04', 'f8000000-0000-0000-0000-0000000000b4');
|
||||
insert into public.score_sheets (id, owner_id, game_id, name) values
|
||||
('f8000000-0000-0000-0000-0000000000b5', '11111111-1111-1111-1111-111111111111', '7c1d2c3d-0000-4000-8000-000000000003', 'Bogen');
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- Lesen, RLS (Sicherheit Kriterium 1 und 3, KAT-05 Kriterium 5 und 9)
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
select cmp_ok((select count(*) from public.game_reports where owner_id <> '11111111-1111-1111-1111-111111111111'), '>', 0::bigint,
|
||||
'Vorbedingung: es gibt Meldungen anderer Nutzer');
|
||||
|
||||
select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true);
|
||||
set local role authenticated;
|
||||
select is((select count(*) from public.game_reports where owner_id = '11111111-1111-1111-1111-111111111111'), 2::bigint,
|
||||
'Sicherheit Kriterium 3: Anna liest ihre beiden eigenen Meldungen');
|
||||
select is((select count(*) from public.game_reports where owner_id <> '11111111-1111-1111-1111-111111111111'), 0::bigint,
|
||||
'Sicherheit Kriterium 3: Anna liest keine fremden Meldungen');
|
||||
select is_empty($$select 1 from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000006'$$,
|
||||
'Sicherheit Kriterium 3: Anna findet Caras Meldung auch per ID nicht');
|
||||
select throws_ok($$insert into public.game_reports (owner_id, title) values ('22222222-2222-2222-2222-222222222222', 'Im Namen von Ben')$$, '42501', null,
|
||||
'KAT-05 Kriterium 9: Anna kann keine Meldung für einen anderen Nutzer anlegen');
|
||||
select throws_ok($$insert into public.game_reports (owner_id, title, status, resolved_at, target_game_id)
|
||||
values ('11111111-1111-1111-1111-111111111111', 'Selbst freigegeben', 'approved', now(), '7c1d2c3d-0000-4000-8000-000000000004')$$, '42501', null,
|
||||
'KAT-05 Kriterium 9: Anna kann keine bereits freigegebene Meldung anlegen');
|
||||
select throws_ok($$update public.game_reports set target_game_id = '7c1d2c3d-0000-4000-8000-000000000004' where id = '7c1d2c3d-0000-4000-8000-000000000001'$$, '42501', null,
|
||||
'KAT-05 Kriterium 9: Anna kann das Ziel ihrer Meldung nicht setzen');
|
||||
select throws_ok($$update public.game_reports set status = 'approved' where id = '7c1d2c3d-0000-4000-8000-000000000001'$$, '42501', null,
|
||||
'KAT-05 Kriterium 9: Anna kann ihre Meldung nicht selbst freigeben');
|
||||
select throws_ok($$delete from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000001'$$, '42501', null,
|
||||
'KAT-05 Kriterium 9: Anna kann ihre Meldung nicht löschen');
|
||||
reset role;
|
||||
|
||||
select set_config('request.jwt.claims', '{"sub":"22222222-2222-2222-2222-222222222222","role":"authenticated"}', true);
|
||||
set local role authenticated;
|
||||
select is((select count(*) from public.game_reports where owner_id = '22222222-2222-2222-2222-222222222222'), 1::bigint,
|
||||
'Sicherheit Kriterium 3: Ben liest seine eigene Meldung');
|
||||
select is((select count(*) from public.game_reports where owner_id <> '22222222-2222-2222-2222-222222222222'), 0::bigint,
|
||||
'Sicherheit Kriterium 3: Ben liest Annas und Caras Meldungen nicht');
|
||||
reset role;
|
||||
|
||||
-- Gefälschte Rolle im user_metadata (vom Client änderbar) öffnet nichts
|
||||
select set_config('request.jwt.claims',
|
||||
'{"sub":"22222222-2222-2222-2222-222222222222","role":"authenticated","user_metadata":{"role":"admin"}}', true);
|
||||
set local role authenticated;
|
||||
select is((select count(*) from public.game_reports where owner_id <> '22222222-2222-2222-2222-222222222222'), 0::bigint,
|
||||
'Sicherheit Kriterium 3: user_metadata.role = admin öffnet keine fremden Meldungen');
|
||||
reset role;
|
||||
|
||||
-- Die Admin-Policy (R4) ist vorbereitet: nur app_metadata (serverseitig gesetzt) öffnet das Lesen, schreiben kann auch der Admin nicht
|
||||
select set_config('request.jwt.claims',
|
||||
'{"sub":"22222222-2222-2222-2222-222222222222","role":"authenticated","app_metadata":{"role":"admin"}}', true);
|
||||
set local role authenticated;
|
||||
select cmp_ok((select count(*) from public.game_reports where owner_id <> '22222222-2222-2222-2222-222222222222'), '>=', 4::bigint,
|
||||
'Admin-Policy für R4: app_metadata.role = admin liest alle Meldungen');
|
||||
select throws_ok($$update public.game_reports set status = 'rejected', resolved_at = now() where id = '7c1d2c3d-0000-4000-8000-000000000001'$$, '42501', null,
|
||||
'KAT-05 Kriterium 9: auch mit app_metadata.role = admin kein Ändern des Status über die Schnittstelle');
|
||||
select throws_ok($$delete from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000001'$$, '42501', null,
|
||||
'KAT-05 Kriterium 9: auch mit app_metadata.role = admin kein Löschen über die Schnittstelle');
|
||||
reset role;
|
||||
|
||||
set local role anon;
|
||||
select throws_ok($$select count(*) from public.game_reports$$, '42501', null,
|
||||
'Sicherheit Kriterium 3: anon liest keine Meldungen');
|
||||
select throws_ok($$insert into public.game_reports (owner_id, title) values ('11111111-1111-1111-1111-111111111111', 'Neu')$$, '42501', null,
|
||||
'KAT-05 Kriterium 9: anon kann keine Meldung anlegen');
|
||||
select throws_ok($$update public.game_reports set status = 'rejected', resolved_at = now() where id = '7c1d2c3d-0000-4000-8000-000000000001'$$, '42501', null,
|
||||
'KAT-05 Kriterium 9: anon kann keine Meldung ändern');
|
||||
reset role;
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- Checks (jeweils mit dem Namen der verletzten Regel, damit nicht ein anderer Fehler besteht)
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
select lives_ok($$insert into public.game_reports (id, owner_id, title, link, year, publisher_name) values
|
||||
('f8000000-0000-0000-0000-000000000a01', '22222222-2222-2222-2222-222222222222', 'Gültig https', 'https://example.org/x', 1800, repeat('v', 200)),
|
||||
('f8000000-0000-0000-0000-000000000a02', '22222222-2222-2222-2222-222222222222', repeat('t', 200), 'http://example.org/x', 2100, null)$$,
|
||||
'Gültige Meldungen (https/http, Jahresgrenzen, Längengrenzen) lassen sich anlegen');
|
||||
select throws_ok($$insert into public.game_reports (owner_id, title, origin) values ('22222222-2222-2222-2222-222222222222', 'X', 'bot')$$,
|
||||
'23514', 'new row for relation "game_reports" violates check constraint "game_reports_origin_check"', 'Check: origin nur user/import');
|
||||
select throws_ok($$insert into public.game_reports (owner_id, title) values ('22222222-2222-2222-2222-222222222222', '')$$,
|
||||
'23514', 'new row for relation "game_reports" violates check constraint "game_reports_title_check"', 'Check: Titel nicht leer');
|
||||
select throws_ok($$insert into public.game_reports (owner_id, title) values ('22222222-2222-2222-2222-222222222222', repeat('t', 201))$$,
|
||||
'23514', 'new row for relation "game_reports" violates check constraint "game_reports_title_check"', 'Check: Titel höchstens 200 Zeichen');
|
||||
select throws_ok($$insert into public.game_reports (owner_id, title, publisher_name) values ('22222222-2222-2222-2222-222222222222', 'X', repeat('v', 201))$$,
|
||||
'23514', 'new row for relation "game_reports" violates check constraint "game_reports_publisher_name_check"', 'Check: Verlag höchstens 200 Zeichen');
|
||||
select throws_ok($$insert into public.game_reports (owner_id, title, year) values ('22222222-2222-2222-2222-222222222222', 'X', 1799)$$,
|
||||
'23514', 'new row for relation "game_reports" violates check constraint "game_reports_year_check"', 'Check: Jahr mindestens 1800');
|
||||
select throws_ok($$insert into public.game_reports (owner_id, title, year) values ('22222222-2222-2222-2222-222222222222', 'X', 2101)$$,
|
||||
'23514', 'new row for relation "game_reports" violates check constraint "game_reports_year_check"', 'Check: Jahr höchstens 2100');
|
||||
select throws_ok($$insert into public.game_reports (owner_id, title, link) values ('22222222-2222-2222-2222-222222222222', 'X', 'ftp://example.org')$$,
|
||||
'23514', 'new row for relation "game_reports" violates check constraint "game_reports_link_check"', 'Check: Link nur http(s)');
|
||||
select throws_ok($$insert into public.game_reports (owner_id, title, link) values ('22222222-2222-2222-2222-222222222222', 'X', 'https://' || repeat('a', 493))$$,
|
||||
'23514', 'new row for relation "game_reports" violates check constraint "game_reports_link_check"', 'Check: Link höchstens 500 Zeichen');
|
||||
select throws_ok($$insert into public.game_reports (owner_id, title, status, resolved_at) values ('22222222-2222-2222-2222-222222222222', 'X', 'done', now())$$,
|
||||
'23514', 'new row for relation "game_reports" violates check constraint "game_reports_status_check"', 'Check: Status nur open/approved/rejected/merged');
|
||||
select throws_ok($$insert into public.game_reports (owner_id, title, status, resolved_at) values ('22222222-2222-2222-2222-222222222222', 'X', 'open', now())$$,
|
||||
'23514', 'new row for relation "game_reports" violates check constraint "game_reports_resolved_matches_status"', 'Check: offene Meldung hat kein resolved_at');
|
||||
select throws_ok($$insert into public.game_reports (owner_id, title, status) values ('22222222-2222-2222-2222-222222222222', 'X', 'rejected')$$,
|
||||
'23514', 'new row for relation "game_reports" violates check constraint "game_reports_resolved_matches_status"', 'Check: erledigte Meldung braucht resolved_at');
|
||||
select throws_ok($$insert into public.game_reports (owner_id, title, target_game_id) values ('22222222-2222-2222-2222-222222222222', 'X', '7c1d2c3d-0000-4000-8000-000000000004')$$,
|
||||
'23514', 'new row for relation "game_reports" violates check constraint "game_reports_open_has_no_target"', 'Check: offene Meldung hat kein Ziel');
|
||||
select throws_ok($$insert into public.game_reports (owner_id, title, status, resolved_at) values ('22222222-2222-2222-2222-222222222222', 'X', 'approved', now())$$,
|
||||
'23514', 'new row for relation "game_reports" violates check constraint "game_reports_target_required"', 'Check: approved braucht ein Ziel');
|
||||
select throws_ok($$insert into public.game_reports (owner_id, title, status, resolved_at) values ('22222222-2222-2222-2222-222222222222', 'X', 'merged', now())$$,
|
||||
'23514', 'new row for relation "game_reports" violates check constraint "game_reports_target_required"', 'Check: merged braucht ein Ziel');
|
||||
select lives_ok($$insert into public.game_reports (id, owner_id, title, status, resolved_at, target_game_id) values
|
||||
('f8000000-0000-0000-0000-000000000a03', '22222222-2222-2222-2222-222222222222', 'rejected mit Ziel', 'rejected', now(), '7c1d2c3d-0000-4000-8000-000000000004'),
|
||||
('f8000000-0000-0000-0000-000000000a04', '22222222-2222-2222-2222-222222222222', 'rejected ohne Ziel', 'rejected', now(), null)$$,
|
||||
'Positiv: rejected ist mit und ohne Ziel zulässig');
|
||||
select is((select origin || ':' || status from public.game_reports where id = 'f8000000-0000-0000-0000-000000000a01'), 'user:open',
|
||||
'Standardwerte: origin = user, status = open');
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- N12a: eine offene Import-Meldung je Nutzer und BGG-Kennung
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
insert into public.game_reports (id, owner_id, title, origin, bgg_id) values
|
||||
('f8000000-0000-0000-0000-000000000b01', '22222222-2222-2222-2222-222222222222', 'Import 1', 'import', 9001);
|
||||
select throws_ok($$insert into public.game_reports (owner_id, title, origin, bgg_id)
|
||||
values ('22222222-2222-2222-2222-222222222222', 'Import 1 doppelt', 'import', 9001)$$, '23505', null,
|
||||
'N12a: zweite offene Import-Meldung desselben Nutzers mit derselben BGG-Kennung wird abgelehnt');
|
||||
select lives_ok($$insert into public.game_reports (owner_id, title, origin, bgg_id) values
|
||||
('33333333-3333-3333-3333-333333333333', 'Import anderer Nutzer', 'import', 9001),
|
||||
('22222222-2222-2222-2222-222222222222', 'Import andere Kennung', 'import', 9002),
|
||||
('22222222-2222-2222-2222-222222222222', 'Von Hand dieselbe Kennung', 'user', 9001),
|
||||
('22222222-2222-2222-2222-222222222222', 'Von Hand dieselbe Kennung 2', 'user', 9001),
|
||||
('22222222-2222-2222-2222-222222222222', 'Import ohne Kennung', 'import', null),
|
||||
('22222222-2222-2222-2222-222222222222', 'Import ohne Kennung 2', 'import', null)$$,
|
||||
'N12a: anderer Nutzer, andere Kennung, origin = user und fehlende Kennung bleiben zulässig');
|
||||
insert into public.game_reports (id, owner_id, title, origin, bgg_id, status, resolved_at) values
|
||||
('f8000000-0000-0000-0000-000000000b02', '22222222-2222-2222-2222-222222222222', 'Import 1 erledigt', 'import', 9001, 'rejected', now());
|
||||
select lives_ok($$insert into public.game_reports (owner_id, title, origin, bgg_id)
|
||||
values ('22222222-2222-2222-2222-222222222222', 'Import 1 neu', 'import', 9003)$$,
|
||||
'N12a: Vorbedingung für die nächste Prüfung');
|
||||
update public.game_reports set status = 'rejected', resolved_at = now() where id = 'f8000000-0000-0000-0000-000000000b01';
|
||||
select lives_ok($$insert into public.game_reports (owner_id, title, origin, bgg_id)
|
||||
values ('22222222-2222-2222-2222-222222222222', 'Import 1 nach Erledigung', 'import', 9001)$$,
|
||||
'N12a: ist die frühere Meldung erledigt, darf eine neue offene für dieselbe Kennung angelegt werden');
|
||||
select is((select indexdef from pg_indexes where indexname = 'game_reports_open_idx'),
|
||||
'CREATE INDEX game_reports_open_idx ON public.game_reports USING btree (created_at) WHERE (status = ''open''::text)',
|
||||
'Index für die Admin-Liste (offene Meldungen nach Alter)');
|
||||
select is((select count(*) from pg_indexes where tablename = 'game_reports' and indexname in
|
||||
('game_reports_owner_created_idx', 'game_reports_provisional_game_id_key', 'game_reports_target_game_id_idx')), 3::bigint,
|
||||
'Indizes für Tageslimit und beide Fremdschlüssel auf games vorhanden');
|
||||
select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id)
|
||||
values ('11111111-1111-1111-1111-111111111111', 'Zweite Meldung zum selben Eintrag', '7c1d2c3d-0000-4000-8000-000000000003')$$, '23505', null,
|
||||
'Ein vorläufiger Eintrag gehört zu höchstens einer Meldung (1:1)');
|
||||
select lives_ok($$insert into public.game_reports (owner_id, title, target_game_id, status, resolved_at) values
|
||||
('22222222-2222-2222-2222-222222222222', 'Gleiches Ziel 1', '7c1d2c3d-0000-4000-8000-000000000004', 'merged', now()),
|
||||
('22222222-2222-2222-2222-222222222222', 'Gleiches Ziel 2', '7c1d2c3d-0000-4000-8000-000000000004', 'merged', now())$$,
|
||||
'Mehrere Meldungen dürfen dasselbe Ziel haben, und mehrere dürfen ohne vorläufigen Eintrag sein');
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- Fremdschlüssel-Regeln
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
insert into public.games (id, title, visibility, owner_id) values
|
||||
('f8000000-0000-0000-0000-000000000c01', 'Wird gelöscht', 'private', '22222222-2222-2222-2222-222222222222');
|
||||
insert into public.game_reports (id, owner_id, title, provisional_game_id) values
|
||||
('f8000000-0000-0000-0000-000000000c02', '22222222-2222-2222-2222-222222222222', 'Vorläufiger Eintrag verschwindet', 'f8000000-0000-0000-0000-000000000c01');
|
||||
delete from public.games where id = 'f8000000-0000-0000-0000-000000000c01';
|
||||
select is((select count(*) || ':' || count(provisional_game_id) from public.game_reports where id = 'f8000000-0000-0000-0000-000000000c02'), '1:0',
|
||||
'Fremdschlüssel: wird der vorläufige Eintrag gelöscht, bleibt die Meldung und der Verweis wird leer (on delete set null)');
|
||||
-- eigenes Ziel, auf das nur eine Meldung verweist (sonst würde ein anderer Verweis die Prüfung vortäuschen)
|
||||
insert into public.games (id, title, visibility) values ('f8000000-0000-0000-0000-000000000c03', 'Ziel nur für eine Meldung', 'catalog');
|
||||
insert into public.game_reports (id, owner_id, title, status, resolved_at, target_game_id) values
|
||||
('f8000000-0000-0000-0000-000000000c04', '22222222-2222-2222-2222-222222222222', 'Verweist auf Ziel', 'rejected', now(), 'f8000000-0000-0000-0000-000000000c03');
|
||||
select throws_ok($$delete from public.games where id = 'f8000000-0000-0000-0000-000000000c03'$$, '23503', null,
|
||||
'Fremdschlüssel: ein Ziel mit Verweis aus einer Meldung lässt sich nicht löschen (on delete restrict)');
|
||||
select is((select target_game_id from public.game_reports where id = 'f8000000-0000-0000-0000-000000000c04'), 'f8000000-0000-0000-0000-000000000c03'::uuid,
|
||||
'Der Verweis der Meldung auf das Ziel bleibt bestehen');
|
||||
select throws_ok($$insert into public.game_reports (owner_id, title) values (gen_random_uuid(), 'Ohne Konto')$$, '23503', null,
|
||||
'Fremdschlüssel: Meldung braucht ein vorhandenes Profil');
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- Trigger games_visibility_immutable (E14), 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 ('games_visibility_immutable', 'admin_resolve_report')
|
||||
and has_function_privilege(r, p.oid, 'execute')$$,
|
||||
'KAT-05 Kriterium 9: games_visibility_immutable und admin_resolve_report 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 ('games_visibility_immutable', 'admin_resolve_report', 'admin_merge_games')
|
||||
and not p.prosecdef and p.proconfig = array['search_path=""']), 3::bigint,
|
||||
'Alle drei Funktionen sind security invoker mit leerem search_path');
|
||||
|
||||
-- als postgres: private -> Katalog (mit allem, was der Check games_owner_matches_visibility verlangt)
|
||||
select throws_ok($$update public.games set visibility = 'catalog', owner_id = null where id = '7c1d2c3d-0000-4000-8000-000000000003'$$,
|
||||
'P0001', 'visibility_immutable', 'KAT-05 Kriterium 9: auch postgres kann einen privaten Eintrag nicht öffentlich schalten');
|
||||
select throws_ok($$update public.games set visibility = 'catalog' where id = '7c1d2c3d-0000-4000-8000-000000000003'$$,
|
||||
'P0001', 'visibility_immutable', 'KAT-05 Kriterium 9: auch nur visibility allein wird abgelehnt');
|
||||
select throws_ok($$update public.games set visibility = 'private', owner_id = '11111111-1111-1111-1111-111111111111' where id = '7c1d2c3d-0000-4000-8000-000000000004'$$,
|
||||
'P0001', 'visibility_immutable', 'KAT-05 Kriterium 9: auch postgres kann ein Katalogspiel nicht privat schalten');
|
||||
select throws_ok($$update public.games set owner_id = '22222222-2222-2222-2222-222222222222' where id = '7c1d2c3d-0000-4000-8000-000000000003'$$,
|
||||
'P0001', 'visibility_immutable', 'KAT-05 Kriterium 9: der Eigentümer eines privaten Spiels ist unveränderlich');
|
||||
select throws_ok($$update public.games set owner_id = null where id = '7c1d2c3d-0000-4000-8000-000000000003'$$,
|
||||
'P0001', 'visibility_immutable', 'KAT-05 Kriterium 9: auch ein leerer Eigentümer wird abgelehnt');
|
||||
select is((select visibility || ':' || owner_id::text from public.games where id = '7c1d2c3d-0000-4000-8000-000000000003'),
|
||||
'private:11111111-1111-1111-1111-111111111111', 'Nach den Fehlern ist das Spiel unverändert');
|
||||
select lives_ok($$update public.games set title = 'Meldungsspiel', visibility = 'private' where id = '7c1d2c3d-0000-4000-8000-000000000003'$$,
|
||||
'Positiv: dieselbe Sichtbarkeit nochmals zu setzen ist keine Änderung');
|
||||
select lives_ok($$update public.games set year = 2022 where id = '7c1d2c3d-0000-4000-8000-000000000004'$$,
|
||||
'Positiv: andere Felder eines Katalogspiels bleiben als postgres änderbar');
|
||||
update public.games set year = null where id = '7c1d2c3d-0000-4000-8000-000000000004';
|
||||
|
||||
-- als Nutzer: der Trigger läuft vor der RLS-Prüfung, deshalb P0001 statt 42501
|
||||
select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true);
|
||||
set local role authenticated;
|
||||
select throws_ok($$update public.games set visibility = 'catalog' where id = '7c1d2c3d-0000-4000-8000-000000000003'$$,
|
||||
'P0001', 'visibility_immutable', 'KAT-05 Kriterium 9: Anna kann ihren privaten Eintrag nicht öffentlich schalten (Trigger vor RLS)');
|
||||
select lives_ok($$update public.games set title = 'Meldungsspiel (neu)' where id = '7c1d2c3d-0000-4000-8000-000000000003'$$,
|
||||
'Positiv: Anna ändert den Titel ihres privaten Eintrags weiterhin');
|
||||
update public.games set title = 'Meldungsspiel' where id = '7c1d2c3d-0000-4000-8000-000000000003';
|
||||
reset role;
|
||||
select is((select visibility from public.games where id = '7c1d2c3d-0000-4000-8000-000000000003'), 'private',
|
||||
'Annas Eintrag ist weiterhin privat');
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- admin_resolve_report: Rechte (KAT-05 Kriterium 9)
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
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_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'rejected')$$, '42501', null,
|
||||
'KAT-05 Kriterium 9: authenticated (auch mit app_metadata.role = admin) kann admin_resolve_report nicht aufrufen');
|
||||
reset role;
|
||||
set local role anon;
|
||||
select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'rejected')$$, '42501', null,
|
||||
'KAT-05 Kriterium 9: anon kann admin_resolve_report nicht aufrufen');
|
||||
reset role;
|
||||
set local role service_role;
|
||||
select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'rejected')$$, '42501', null,
|
||||
'KAT-05 Kriterium 9: service_role kann admin_resolve_report nicht aufrufen');
|
||||
reset role;
|
||||
select is((select status from public.game_reports where id = '7c1d2c3d-0000-4000-8000-00000000000b'), 'open',
|
||||
'Die verweigerten Aufrufe haben nichts verändert');
|
||||
|
||||
-- Die Rollenprüfung im Funktionskörper: eine Rolle MIT execute-Recht, aber ohne postgres/supabase_admin
|
||||
create role report_tester nologin;
|
||||
grant report_tester to postgres with set true;
|
||||
grant usage on schema public to report_tester;
|
||||
grant execute on function public.admin_resolve_report(uuid, text, uuid) to report_tester;
|
||||
set local role report_tester;
|
||||
do $$
|
||||
begin
|
||||
perform public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'rejected');
|
||||
perform set_config('t.resolve_err', 'kein Fehler', true);
|
||||
exception when others then
|
||||
perform set_config('t.resolve_err', sqlstate || ':' || sqlerrm, true);
|
||||
end
|
||||
$$;
|
||||
reset role;
|
||||
select is(current_setting('t.resolve_err'), 'P0001:admin_only',
|
||||
'KAT-05 Kriterium 9: admin_resolve_report prüft die Datenbankrolle (admin_only)');
|
||||
select is((select status from public.game_reports where id = '7c1d2c3d-0000-4000-8000-00000000000b'), 'open',
|
||||
'admin_only ändert nichts');
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- admin_resolve_report: Fehlerfälle (alle ohne Änderung)
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
insert into public.games (id, title, visibility, hidden_at) values
|
||||
('f8000000-0000-0000-0000-000000000d01', 'Ziel versteckt', 'catalog', '2020-01-01');
|
||||
insert into public.games (id, title, visibility, hidden_at, merged_into_id) values
|
||||
('f8000000-0000-0000-0000-000000000d02', 'Ziel zusammengeführt', 'catalog', '2020-01-01', '7c1d2c3d-0000-4000-8000-000000000004');
|
||||
insert into public.games (id, title, visibility, owner_id) values
|
||||
('f8000000-0000-0000-0000-000000000d03', 'Ziel privat', 'private', '22222222-2222-2222-2222-222222222222');
|
||||
create temp table rb_before as
|
||||
select (select count(*) from public.game_reports where status <> 'open') as erledigt,
|
||||
(select count(*) from public.games where hidden_at is not null) as ausgeblendet,
|
||||
(select string_agg(id::text || target_game_id::text || status, ',' order by id) from public.game_reports where id::text like '7c1d2c3d%') as meldungen;
|
||||
|
||||
select throws_ok($$select public.admin_resolve_report(gen_random_uuid(), 'rejected')$$, 'P0001', 'report_not_found',
|
||||
'KAT-05 Kriterium 5: unbekannte Meldung (report_not_found)');
|
||||
select throws_ok($$select public.admin_resolve_report(null, 'rejected')$$, 'P0001', 'report_not_found',
|
||||
'KAT-05 Kriterium 5: fehlende Meldung (null) ist report_not_found');
|
||||
select throws_ok($$select public.admin_resolve_report('f8000000-0000-0000-0000-000000000a04', 'rejected')$$, 'P0001', 'report_not_open',
|
||||
'KAT-05 Kriterium 5: bereits abgelehnte Meldung (report_not_open)');
|
||||
select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'open')$$, 'P0001', 'report_status_invalid',
|
||||
'KAT-05 Kriterium 5: Status open ist kein Ergebnis (report_status_invalid)');
|
||||
select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'fertig')$$, 'P0001', 'report_status_invalid',
|
||||
'KAT-05 Kriterium 5: unbekannter Status (report_status_invalid)');
|
||||
select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', null)$$, 'P0001', 'report_status_invalid',
|
||||
'KAT-05 Kriterium 5: fehlender Status (null) ist report_status_invalid');
|
||||
select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'fertig', null)$$, 'P0001', 'report_status_invalid',
|
||||
'KAT-05 Kriterium 5: ungültiger Status geht vor fehlendem Ziel');
|
||||
select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'approved')$$, 'P0001', 'report_target_required',
|
||||
'KAT-05 Kriterium 5: approved ohne Ziel (report_target_required)');
|
||||
select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'merged', null)$$, 'P0001', 'report_target_required',
|
||||
'KAT-05 Kriterium 5: merged ohne Ziel (report_target_required)');
|
||||
select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'approved', 'f8000000-0000-0000-0000-000000000d01')$$,
|
||||
'P0001', 'report_target_not_listed', 'KAT-05 Kriterium 5: ausgeblendetes Ziel (report_target_not_listed)');
|
||||
select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'merged', 'f8000000-0000-0000-0000-000000000d02')$$,
|
||||
'P0001', 'report_target_not_listed', 'KAT-05 Kriterium 5: zusammengeführtes Ziel (report_target_not_listed)');
|
||||
select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'approved', 'f8000000-0000-0000-0000-000000000d03')$$,
|
||||
'P0001', 'report_target_not_listed', 'KAT-05 Kriterium 5: privates Ziel (report_target_not_listed)');
|
||||
select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'approved', 'f8000000-0000-0000-0000-0000000000ff')$$,
|
||||
'P0001', 'report_target_not_listed', 'KAT-05 Kriterium 5: unbekanntes Ziel (report_target_not_listed)');
|
||||
select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'rejected', 'f8000000-0000-0000-0000-000000000d01')$$,
|
||||
'P0001', 'report_target_not_listed', 'KAT-05 Kriterium 5: auch bei rejected wird das Ziel geprüft');
|
||||
|
||||
-- Datenfehler: vorläufiger Eintrag ist ein Katalogspiel (Zusatzprüfung gegenüber der Spec)
|
||||
insert into public.game_reports (id, owner_id, title, provisional_game_id) values
|
||||
('f8000000-0000-0000-0000-000000000e01', '22222222-2222-2222-2222-222222222222', 'Vorläufig ist Katalog', 'f8000000-0000-0000-0000-00000000000e');
|
||||
select throws_ok($$select public.admin_resolve_report('f8000000-0000-0000-0000-000000000e01', 'merged', '7c1d2c3d-0000-4000-8000-000000000004')$$,
|
||||
'P0001', 'report_provisional_not_private', 'Zusatzprüfung: ein Katalogspiel als vorläufiger Eintrag wird nicht umgehängt');
|
||||
select is((select status from public.game_reports where id = 'f8000000-0000-0000-0000-000000000e01'), 'open',
|
||||
'Nach report_provisional_not_private ist die Meldung weiterhin offen');
|
||||
select is((select hidden_at is null and merged_into_id is null from public.games where id = 'f8000000-0000-0000-0000-00000000000e'), true,
|
||||
'Das Katalogspiel wurde nicht verändert');
|
||||
select is((select row(erledigt, ausgeblendet, meldungen)::text from rb_before),
|
||||
(select row((select count(*) from public.game_reports where status <> 'open'),
|
||||
(select count(*) from public.games where hidden_at is not null),
|
||||
(select string_agg(id::text || target_game_id::text || status, ',' order by id) from public.game_reports where id::text like '7c1d2c3d%'))::text),
|
||||
'Nach allen Fehlerfällen ist nichts verändert (Meldungen, Ausblendungen)');
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- Runbook Abschnitt 6, Schritt 1 und 2 (SQL-Bausteine unverändert)
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- Vorbereitung für Schritt 2 (ähnliche Spiele): Titel gleich bzw. BGG-Kennung 4711 am Katalogspiel
|
||||
-- (Fixtures oben: MELDUNGSSPIEL, Anderer Titel)
|
||||
|
||||
-- Schritt 1: Offene Meldungen
|
||||
create temp table rb_open as
|
||||
select r.id, r.created_at, r.origin, r.title, r.publisher_name, r.year, r.link, r.bgg_id, r.provisional_game_id
|
||||
from public.game_reports r
|
||||
where r.status = 'open'
|
||||
order by r.created_at, r.id;
|
||||
select results_eq($$select id from rb_open where id::text like '7c1d2c3d%' order by created_at, id$$,
|
||||
$$values ('7c1d2c3d-0000-4000-8000-000000000001'::uuid), ('7c1d2c3d-0000-4000-8000-000000000006'), ('7c1d2c3d-0000-4000-8000-000000000005'),
|
||||
('7c1d2c3d-0000-4000-8000-000000000007'), ('7c1d2c3d-0000-4000-8000-00000000000b')$$,
|
||||
'KAT-05 Kriterium 8: Runbook 6, offene Meldungen (älteste zuerst)');
|
||||
select is((select row(origin, title, publisher_name, year, link, bgg_id, provisional_game_id)::text from rb_open where id = '7c1d2c3d-0000-4000-8000-000000000001'),
|
||||
'(user,Meldungsspiel,Beispielverlag,2024,https://example.org/spiel,4711,7c1d2c3d-0000-4000-8000-000000000003)',
|
||||
'KAT-05 Kriterium 8: Runbook 6, die Spalten der Meldung stehen in der Liste');
|
||||
|
||||
-- Schritt 2: Ähnliche Spiele im Katalog
|
||||
create temp table rb_similar as
|
||||
select g.id, g.title, g.year, g.bgg_id
|
||||
from public.game_reports r
|
||||
join public.catalog_games g
|
||||
on lower(btrim(g.title)) = lower(btrim(r.title))
|
||||
or (r.bgg_id is not null and g.bgg_id = r.bgg_id)
|
||||
where r.id = '7c1d2c3d-0000-4000-8000-000000000001'
|
||||
order by g.title, g.id;
|
||||
select results_eq($$select id, title, year, bgg_id from rb_similar$$,
|
||||
$$values ('f8000000-0000-0000-0000-00000000000d'::uuid, 'Anderer Titel'::text, 2020::smallint, 4711::integer),
|
||||
('f8000000-0000-0000-0000-00000000000c', 'MELDUNGSSPIEL', 2023::smallint, null::integer)$$,
|
||||
'KAT-05 Kriterium 8: Runbook 6, ähnliche Spiele (gleicher Titel ohne Groß-/Kleinschreibung oder gleiche BGG-Kennung)');
|
||||
|
||||
-- Schritt 2: Was hängt am vorläufigen Eintrag?
|
||||
create temp table rb_counts as
|
||||
select g.id, g.title,
|
||||
(select count(*) from public.collection_items c where c.game_id = g.id) as sammlung,
|
||||
(select count(*) from public.favorite_games f where f.game_id = g.id) as favoriten,
|
||||
(select count(*) from public.plays p where p.game_id = g.id) as partien,
|
||||
(select count(*) from public.score_sheets s where s.game_id = g.id) as wertungsboegen
|
||||
from public.game_reports r
|
||||
join public.games g on g.id = r.provisional_game_id
|
||||
where r.id = '7c1d2c3d-0000-4000-8000-000000000001';
|
||||
select results_eq($$select id, title, sammlung, favoriten, partien, wertungsboegen from rb_counts$$,
|
||||
$$values ('7c1d2c3d-0000-4000-8000-000000000003'::uuid, 'Meldungsspiel'::text, 1::bigint, 1::bigint, 2::bigint, 1::bigint)$$,
|
||||
'KAT-05 Kriterium 8: Runbook 6, Anzahlen am vorläufigen Eintrag');
|
||||
|
||||
-- Schritt 2: Editionen des vorläufigen Eintrags
|
||||
create temp table rb_editions as
|
||||
select v.id, v.name, v.year, v.language
|
||||
from public.game_reports r
|
||||
join public.game_variants v on v.game_id = r.provisional_game_id
|
||||
where r.id = '7c1d2c3d-0000-4000-8000-000000000001'
|
||||
order by v.name, v.id;
|
||||
select results_eq($$select id, name from rb_editions$$,
|
||||
$$values ('f8000000-0000-0000-0000-0000000000e1'::uuid, 'Deluxe'::text), ('f8000000-0000-0000-0000-0000000000e2', 'Prototyp')$$,
|
||||
'KAT-05 Kriterium 8: Runbook 6, Namen der Editionen des vorläufigen Eintrags');
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- Runbook Abschnitt 6, Schritt 3a: Freigeben (neuer Katalogeintrag, nur Fakten aus der Meldung)
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
insert into public.games (id, title, year, description, description_locale, visibility)
|
||||
values ('7c1d2c3d-0000-4000-8000-000000000002', 'Meldungsspiel', 2024, 'Eigener Kurztext des Admins.', 'de', 'catalog');
|
||||
|
||||
insert into public.publishers (name) values ('Beispielverlag') on conflict ((lower(name))) do nothing;
|
||||
insert into public.game_publishers (game_id, publisher_id)
|
||||
select '7c1d2c3d-0000-4000-8000-000000000002', p.id from public.publishers p where lower(p.name) = lower('Beispielverlag');
|
||||
|
||||
insert into public.game_variants (game_id, name, year) values ('7c1d2c3d-0000-4000-8000-000000000002', 'Deluxe', 2024);
|
||||
|
||||
select is((select manual_fields from public.games where id = '7c1d2c3d-0000-4000-8000-000000000002'),
|
||||
array['description', 'description_locale', 'publishers', 'title', 'year'],
|
||||
'KAT-05 Kriterium 8: Runbook 6, der neue Katalogeintrag vermerkt seine Felder in manual_fields');
|
||||
|
||||
-- Ben hat das neue Spiel nicht; Bens Daten am vorhandenen Ziel (…04) merken, sie dürfen sich nicht ändern
|
||||
create temp table ben_before as
|
||||
select c.id, c.game_id, c.variant_id, c.list, c.note from public.collection_items c where c.owner_id = '22222222-2222-2222-2222-222222222222'
|
||||
union all
|
||||
select p.id, p.game_id, p.variant_id, 'play', null from public.plays p where p.owner_id = '22222222-2222-2222-2222-222222222222';
|
||||
|
||||
create temp table rb_approve as
|
||||
select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000001', 'approved', '7c1d2c3d-0000-4000-8000-000000000002') as r;
|
||||
select is((select r - 'report_id' from rb_approve),
|
||||
jsonb_build_object(
|
||||
'status', 'approved', 'target_id', '7c1d2c3d-0000-4000-8000-000000000002',
|
||||
'provisional_game_id', '7c1d2c3d-0000-4000-8000-000000000003', 'repointed', true,
|
||||
'collection_items', 1, 'collection_conflicts', 0, 'favorite_games', 1, 'favorite_conflicts', 0,
|
||||
'plays', 2, 'score_sheets', 1, 'variants_copied', 0, 'variants_mapped', 1, 'variants_cleared', 1),
|
||||
'KAT-05 Kriterium 5: Runbook 6, approved: Ergebnis-JSON zählt alles richtig (nichts kopiert, eine Edition abgebildet, eine geleert)');
|
||||
select is((select r ->> 'report_id' from rb_approve), '7c1d2c3d-0000-4000-8000-000000000001', 'Das Ergebnis nennt die Meldung');
|
||||
|
||||
-- Schritt 4: Nachprüfen
|
||||
create temp table rb_check as
|
||||
select r.id, r.status, r.target_game_id, r.resolved_at is not null as erledigt,
|
||||
g.hidden_at is not null as vorlaeufig_ausgeblendet, g.merged_into_id
|
||||
from public.game_reports r
|
||||
left join public.games g on g.id = r.provisional_game_id
|
||||
where r.id = '7c1d2c3d-0000-4000-8000-000000000001';
|
||||
select results_eq($$select id, status, target_game_id, erledigt, vorlaeufig_ausgeblendet, merged_into_id from rb_check$$,
|
||||
$$values ('7c1d2c3d-0000-4000-8000-000000000001'::uuid, 'approved'::text, '7c1d2c3d-0000-4000-8000-000000000002'::uuid, true, true, '7c1d2c3d-0000-4000-8000-000000000002'::uuid)$$,
|
||||
'KAT-05 Kriterium 8: Runbook 6, Nachprüfen: Meldung freigegeben, vorläufiger Eintrag ausgeblendet und verwiesen');
|
||||
|
||||
select is((select row(visibility, owner_id)::text from public.games where id = '7c1d2c3d-0000-4000-8000-000000000003'),
|
||||
'(private,11111111-1111-1111-1111-111111111111)', 'KAT-05 Kriterium 5: der vorläufige Eintrag bleibt privat und gehört weiter Anna (E14)');
|
||||
select is((select row(description, image_path)::text from public.games where id = '7c1d2c3d-0000-4000-8000-000000000002'),
|
||||
'("Eigener Kurztext des Admins.",)', 'Beschreibung und Bild des vorläufigen Eintrags werden nie übernommen');
|
||||
select is((select row(visibility, owner_id, hidden_at is null, merged_into_id is null)::text from public.games where id = '7c1d2c3d-0000-4000-8000-000000000002'),
|
||||
'(catalog,,t,t)', 'Das neue Katalogspiel ist sichtbar und gehört niemandem');
|
||||
select is((select count(*) from public.catalog_games where id = '7c1d2c3d-0000-4000-8000-000000000002'), 1::bigint,
|
||||
'Das neue Katalogspiel erscheint in catalog_games');
|
||||
select is((select count(*) from public.catalog_games where id = '7c1d2c3d-0000-4000-8000-000000000003'), 0::bigint,
|
||||
'Der vorläufige Eintrag erscheint nie in catalog_games');
|
||||
select is((select resolved_at is not null and updated_at >= created_at from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000001'), true,
|
||||
'resolved_at ist gesetzt');
|
||||
|
||||
-- Verweise
|
||||
select is((select row(game_id, variant_id, list, note)::text from public.collection_items where id = 'f8000000-0000-0000-0000-0000000000c1'),
|
||||
'(7c1d2c3d-0000-4000-8000-000000000002,,owned,"Annas Notiz")',
|
||||
'RB1: Sammlungseintrag am neuen Spiel, Edition "Prototyp" ohne Gegenstück ist geleert');
|
||||
select is((select row(p.game_id, v.name, v.game_id)::text from public.plays p join public.game_variants v on v.id = p.variant_id
|
||||
where p.id = 'f8000000-0000-0000-0000-0000000000b1'),
|
||||
'(7c1d2c3d-0000-4000-8000-000000000002,Deluxe,7c1d2c3d-0000-4000-8000-000000000002)',
|
||||
'RB1: die Edition "Deluxe" der Partie zeigt auf die vom Admin angelegte gleichnamige Variante am Katalogeintrag');
|
||||
select is((select row(game_id, variant_id)::text from public.plays where id = 'f8000000-0000-0000-0000-0000000000b2'),
|
||||
'(7c1d2c3d-0000-4000-8000-000000000002,)', 'Partie ohne Edition wechselt auf das neue Spiel');
|
||||
select is((select game_id from public.favorite_games where id = 'f8000000-0000-0000-0000-0000000000d1'), '7c1d2c3d-0000-4000-8000-000000000002'::uuid,
|
||||
'Favorit wechselt auf das neue Spiel');
|
||||
select is((select game_id from public.score_sheets where id = 'f8000000-0000-0000-0000-0000000000b5'), '7c1d2c3d-0000-4000-8000-000000000002'::uuid,
|
||||
'Wertungsbogen wechselt auf das neue Spiel');
|
||||
select is((select count(*) from public.game_variants where game_id = '7c1d2c3d-0000-4000-8000-000000000002'), 1::bigint,
|
||||
'RB1: am Katalogeintrag gibt es nur die vom Admin angelegte Variante, die private wurde nicht kopiert');
|
||||
select is((select count(*) from public.game_variants where game_id = '7c1d2c3d-0000-4000-8000-000000000003'), 2::bigint,
|
||||
'RB1: die privaten Varianten bleiben am vorläufigen Eintrag');
|
||||
select is((select count(*) from public.collection_items where game_id = '7c1d2c3d-0000-4000-8000-000000000003'), 0::bigint,
|
||||
'Nichts hängt mehr am vorläufigen Eintrag');
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- Runbook Abschnitt 6, Schritt 3b: Zusammenführen mit einem vorhandenen Eintrag (Kollision)
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
create temp table rb_merged as
|
||||
select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000005', 'merged', '7c1d2c3d-0000-4000-8000-000000000004') as r;
|
||||
select is((select r - 'report_id' from rb_merged),
|
||||
jsonb_build_object(
|
||||
'status', 'merged', 'target_id', '7c1d2c3d-0000-4000-8000-000000000004',
|
||||
'provisional_game_id', '7c1d2c3d-0000-4000-8000-000000000008', 'repointed', true,
|
||||
'collection_items', 0, 'collection_conflicts', 1, 'favorite_games', 0, 'favorite_conflicts', 0,
|
||||
'plays', 0, 'score_sheets', 0, 'variants_copied', 0, 'variants_mapped', 1, 'variants_cleared', 0),
|
||||
'KAT-05 Kriterium 5: Runbook 6, merged: Kollision (Anna hatte das Ziel schon), gleichnamige Variante abgebildet');
|
||||
select is((select row(status, target_game_id)::text from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000005'),
|
||||
'(merged,7c1d2c3d-0000-4000-8000-000000000004)', 'merged: Status und Ziel gespeichert');
|
||||
select is((select row(hidden_at is not null, merged_into_id)::text from public.games where id = '7c1d2c3d-0000-4000-8000-000000000008'),
|
||||
'(t,7c1d2c3d-0000-4000-8000-000000000004)', 'merged: der vorläufige Eintrag ist ausgeblendet und verwiesen');
|
||||
select is((select row(list, note, variant_id)::text from public.collection_items where id = 'f8000000-0000-0000-0000-0000000000c3'),
|
||||
'(owned,"Annas Wunsch",f8000000-0000-0000-0000-0000000000a4)',
|
||||
'Spec-F1: Anna: der Zieleintrag gewinnt (owned vor wishlist), die leere Notiz und Edition füllt der vorläufige Eintrag (Edition über die gleichnamige Variante)');
|
||||
select is((select count(*) from public.collection_items where id = 'f8000000-0000-0000-0000-0000000000c2'), 0::bigint,
|
||||
'Spec-F1: der Quell-Eintrag der Kollision ist gelöscht');
|
||||
select is((select count(*) from public.game_variants where game_id = '7c1d2c3d-0000-4000-8000-000000000004'), 1::bigint,
|
||||
'RB1: das vorhandene Katalogspiel bekommt keine Variante');
|
||||
select is((select count(*) from public.game_variants where game_id = '7c1d2c3d-0000-4000-8000-000000000008'), 1::bigint,
|
||||
'RB1: die private Variante bleibt am vorläufigen Eintrag');
|
||||
select results_eq($$select c.id, c.game_id, c.variant_id, c.list, c.note from public.collection_items c
|
||||
where c.owner_id = '22222222-2222-2222-2222-222222222222'
|
||||
union all select p.id, p.game_id, p.variant_id, 'play', null from public.plays p where p.owner_id = '22222222-2222-2222-2222-222222222222'$$,
|
||||
$$select id, game_id, variant_id, list, note from ben_before$$,
|
||||
'Daten anderer Nutzer am Ziel (Bens Sammlung und Partie) sind unverändert');
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- Runbook Abschnitt 6, Schritt 3c: Ablehnen
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
create temp table rb_rej_snapshot as
|
||||
select 'c5' as k, game_id::text || coalesce(variant_id::text, '') || list as v from public.collection_items where id = 'f8000000-0000-0000-0000-0000000000c5'
|
||||
union all select 'c6', game_id::text || coalesce(variant_id::text, '') || list from public.collection_items where id = 'f8000000-0000-0000-0000-0000000000c6'
|
||||
union all select 'b4', game_id::text from public.plays where id = 'f8000000-0000-0000-0000-0000000000b4'
|
||||
union all select 'g9', visibility || coalesce(hidden_at::text, '') || coalesce(merged_into_id::text, '') from public.games where id = '7c1d2c3d-0000-4000-8000-000000000009'
|
||||
union all select 'ga', visibility || coalesce(hidden_at::text, '') || coalesce(merged_into_id::text, '') from public.games where id = '7c1d2c3d-0000-4000-8000-00000000000a';
|
||||
|
||||
create temp table rb_rej1 as
|
||||
select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000006', 'rejected') as r;
|
||||
create temp table rb_rej2 as
|
||||
select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000007', 'rejected', '7c1d2c3d-0000-4000-8000-000000000004') as r;
|
||||
select is((select r - 'report_id' from rb_rej1),
|
||||
'{"status":"rejected","target_id":null,"provisional_game_id":"7c1d2c3d-0000-4000-8000-000000000009","repointed":false}'::jsonb,
|
||||
'KAT-05 Kriterium 5: Runbook 6, rejected ohne Ziel: nichts umgehängt, keine Zähler');
|
||||
select is((select r - 'report_id' from rb_rej2),
|
||||
'{"status":"rejected","target_id":"7c1d2c3d-0000-4000-8000-000000000004","provisional_game_id":"7c1d2c3d-0000-4000-8000-00000000000a","repointed":false}'::jsonb,
|
||||
'KAT-05 Kriterium 5: Runbook 6, rejected mit Ziel: nur der Verweis, nichts umgehängt');
|
||||
select is((select row(status, target_game_id, resolved_at is not null)::text from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000006'),
|
||||
'(rejected,,t)', 'rejected ohne Ziel: Status und Zeitpunkt gespeichert, kein Ziel');
|
||||
select is((select row(status, target_game_id, resolved_at is not null)::text from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000007'),
|
||||
'(rejected,7c1d2c3d-0000-4000-8000-000000000004,t)', 'rejected mit Ziel: der Verweis auf den Zieleintrag ist gespeichert (Spec-F3)');
|
||||
select results_eq($$select 'c5', game_id::text || coalesce(variant_id::text, '') || list from public.collection_items where id = 'f8000000-0000-0000-0000-0000000000c5'
|
||||
union all select 'c6', game_id::text || coalesce(variant_id::text, '') || list from public.collection_items where id = 'f8000000-0000-0000-0000-0000000000c6'
|
||||
union all select 'b4', game_id::text from public.plays where id = 'f8000000-0000-0000-0000-0000000000b4'
|
||||
union all select 'g9', visibility || coalesce(hidden_at::text, '') || coalesce(merged_into_id::text, '') from public.games where id = '7c1d2c3d-0000-4000-8000-000000000009'
|
||||
union all select 'ga', visibility || coalesce(hidden_at::text, '') || coalesce(merged_into_id::text, '') from public.games where id = '7c1d2c3d-0000-4000-8000-00000000000a'$$,
|
||||
$$select k, v from rb_rej_snapshot$$,
|
||||
'KAT-05 Kriterium 5: bei rejected (mit und ohne Ziel) bleiben vorläufige Einträge, Sammlung und Partien unverändert');
|
||||
|
||||
-- approved ohne vorläufigen Eintrag: nur Status und Ziel
|
||||
create temp table rb_noprov as
|
||||
select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'approved', '7c1d2c3d-0000-4000-8000-000000000004') as r;
|
||||
select is((select r - 'report_id' from rb_noprov),
|
||||
'{"status":"approved","target_id":"7c1d2c3d-0000-4000-8000-000000000004","provisional_game_id":null,"repointed":false}'::jsonb,
|
||||
'approved ohne vorläufigen Eintrag: nichts umzuhängen (repointed = false)');
|
||||
select is((select row(status, target_game_id)::text from public.game_reports where id = '7c1d2c3d-0000-4000-8000-00000000000b'),
|
||||
'(approved,7c1d2c3d-0000-4000-8000-000000000004)', 'approved ohne vorläufigen Eintrag: Status und Ziel gespeichert');
|
||||
select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000001', 'rejected')$$, 'P0001', 'report_not_open',
|
||||
'KAT-05 Kriterium 5: eine freigegebene Meldung lässt sich nicht erneut bearbeiten');
|
||||
|
||||
-- Der Nutzer sieht das Ergebnis seiner Meldung (KAT-04 Krit. 4-6), aber keine fremden
|
||||
select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true);
|
||||
set local role authenticated;
|
||||
select is((select status || ':' || target_game_id::text from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000001'),
|
||||
'approved:7c1d2c3d-0000-4000-8000-000000000002', 'Anna sieht das Ergebnis ihrer Meldung');
|
||||
select is((select count(*) from public.game_reports where owner_id <> '11111111-1111-1111-1111-111111111111'), 0::bigint,
|
||||
'Anna sieht weiterhin keine fremden Meldungen');
|
||||
select cmp_ok((select count(*) from public.games where id = '7c1d2c3d-0000-4000-8000-000000000003'), '=', 1::bigint,
|
||||
'Anna sieht ihren ausgeblendeten vorläufigen Eintrag weiterhin');
|
||||
reset role;
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- admin_merge_games nach dem create or replace (Schritt 4: Meldungen umhängen) und Rechte
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
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 ('admin_merge_games', 'repoint_game_references')
|
||||
and has_function_privilege(r, p.oid, 'execute')$$,
|
||||
'KAT-05 Kriterium 9: nach dem create or replace sind admin_merge_games und repoint_game_references für anon, authenticated und service_role nicht ausführbar');
|
||||
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 weiterhin 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 weiterhin 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 weiterhin nicht aufrufen');
|
||||
reset role;
|
||||
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('f8000000-0000-0000-0000-000000000f01', 'f8000000-0000-0000-0000-000000000f02');
|
||||
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 nach dem create or replace weiterhin die Datenbankrolle');
|
||||
|
||||
-- Schritt 4: Quelle (…f01) -> Ziel (…f02); Meldungen mit Ziel = Quelle ziehen mit
|
||||
insert into public.games (id, title, visibility) values
|
||||
('f8000000-0000-0000-0000-000000000f01', 'Merge Quelle', 'catalog'),
|
||||
('f8000000-0000-0000-0000-000000000f02', 'Merge Ziel', 'catalog'),
|
||||
('f8000000-0000-0000-0000-000000000f03', 'Anderes Ziel', 'catalog');
|
||||
insert into public.game_reports (id, owner_id, title, status, resolved_at, target_game_id) values
|
||||
('f8000000-0000-0000-0000-000000000f11', '22222222-2222-2222-2222-222222222222', 'Freigegeben auf Quelle', 'approved', now(), 'f8000000-0000-0000-0000-000000000f01'),
|
||||
('f8000000-0000-0000-0000-000000000f12', '22222222-2222-2222-2222-222222222222', 'Abgelehnt mit Verweis auf Quelle', 'rejected', now(), 'f8000000-0000-0000-0000-000000000f01'),
|
||||
('f8000000-0000-0000-0000-000000000f13', '22222222-2222-2222-2222-222222222222', 'Zusammengeführt mit anderem', 'merged', now(), 'f8000000-0000-0000-0000-000000000f03');
|
||||
insert into public.game_reports (id, owner_id, title) values
|
||||
('f8000000-0000-0000-0000-000000000f14', '22222222-2222-2222-2222-222222222222', 'Offen');
|
||||
create temp table rb_mg as select public.admin_merge_games('f8000000-0000-0000-0000-000000000f01', 'f8000000-0000-0000-0000-000000000f02') as r;
|
||||
select is((select r -> 'reports_repointed' from rb_mg), '2'::jsonb, 'KAT-05 Kriterium 4: admin_merge_games meldet 2 umgehängte Meldungen');
|
||||
select results_eq($$select id, target_game_id from public.game_reports where id::text like 'f8000000-0000-0000-0000-000000000f1%' order by id$$,
|
||||
$$values ('f8000000-0000-0000-0000-000000000f11'::uuid, 'f8000000-0000-0000-0000-000000000f02'::uuid),
|
||||
('f8000000-0000-0000-0000-000000000f12', 'f8000000-0000-0000-0000-000000000f02'),
|
||||
('f8000000-0000-0000-0000-000000000f13', 'f8000000-0000-0000-0000-000000000f03'),
|
||||
('f8000000-0000-0000-0000-000000000f14', null)$$,
|
||||
'KAT-05 Kriterium 4: Meldungen mit Ziel = Quelle zeigen auf das Ziel, andere Meldungen bleiben unverändert');
|
||||
select is((select row(status, resolved_at is not null)::text from public.game_reports where id = 'f8000000-0000-0000-0000-000000000f11'), '(approved,t)',
|
||||
'Das Umhängen ändert Status und Zeitpunkt der Meldung nicht');
|
||||
select is(public.admin_merge_games('f8000000-0000-0000-0000-000000000f03', 'f8000000-0000-0000-0000-000000000f02') -> 'reports_repointed', '1'::jsonb,
|
||||
'Ein weiteres Zusammenführen hängt die nächste Meldung um');
|
||||
select lives_ok($$delete from public.games where id = 'f8000000-0000-0000-0000-000000000f01'$$,
|
||||
'Positiv: Die Quelle ist nach dem Umhängen nicht mehr Ziel einer Meldung und lässt sich löschen (keine verwaiste Restrict-Sperre)');
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- Fremdschlüssel-Wächter (wie in 07): jeder Verweis auf games ist behandelt oder bewusst ausgelassen
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
create temp table fk_found as
|
||||
select (select relname from pg_class where oid = c.conrelid) || '.' ||
|
||||
(select attname from pg_attribute where attrelid = c.conrelid and attnum = c.conkey[1]) as fk
|
||||
from pg_constraint c
|
||||
where c.contype = 'f' and c.confrelid = 'public.games'::regclass;
|
||||
select is((select count(*) from pg_constraint c where c.contype = 'f' and c.confrelid = 'public.games'::regclass and cardinality(c.conkey) > 1), 0::bigint,
|
||||
'Fremdschlüssel-Wächter: alle Verweise auf games sind einspaltig');
|
||||
select set_eq($$select fk from fk_found$$,
|
||||
$$values
|
||||
-- behandelt von repoint_game_references / admin_merge_games
|
||||
('collection_items.game_id'), ('favorite_games.game_id'), ('plays.game_id'), ('score_sheets.game_id'),
|
||||
('game_variants.game_id'), ('games.merged_into_id'), ('game_reports.target_game_id'),
|
||||
-- bewusst ausgelassen: bleiben am ausgeblendeten Quell-Eintrag
|
||||
('game_genres.game_id'), ('game_mechanics.game_id'), ('game_categories.game_id'), ('game_publishers.game_id'),
|
||||
-- bewusst ausgelassen: zeigt auf ein privates Spiel, das Zusammenführen zweier Katalogspiele betrifft ihn nicht
|
||||
('game_reports.provisional_game_id')$$,
|
||||
'KAT-05 Kriterium 4: Fremdschlüssel-Wächter: neue Verweise auf games erzwingen eine Entscheidung im Zusammenführen');
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- Konto löschen nach Freigabe (QUE-04, Klärung 10; Spec 9.5 KTO-05)
|
||||
-- Anna: freigegebene Meldung (neu angelegter Katalogeintrag), zusammengeführte Meldung, ausgeblendete
|
||||
-- vorläufige Einträge mit merged_into_id auf Katalogspiele.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
insert into public.collection_items (id, owner_id, game_id, list, note) values
|
||||
('f8000000-0000-0000-0000-0000000000c7', '22222222-2222-2222-2222-222222222222', '7c1d2c3d-0000-4000-8000-000000000002', 'owned', 'Bens Notiz am neuen Spiel');
|
||||
create temp table other_before as
|
||||
select 'game2' as k, row(g.title, g.year, g.description, g.hidden_at, g.merged_into_id, g.visibility, g.manual_fields)::text as v
|
||||
from public.games g where g.id = '7c1d2c3d-0000-4000-8000-000000000002'
|
||||
union all select 'game4', row(g.title, g.year, g.hidden_at, g.merged_into_id, g.visibility)::text from public.games g where g.id = '7c1d2c3d-0000-4000-8000-000000000004'
|
||||
union all select 'ben_c7', row(c.game_id, c.list, c.note)::text from public.collection_items c where c.id = 'f8000000-0000-0000-0000-0000000000c7'
|
||||
union all select 'ben_c4', row(c.game_id, c.variant_id, c.list, c.note)::text from public.collection_items c where c.id = 'f8000000-0000-0000-0000-0000000000c4'
|
||||
union all select 'cara_report', row(r.status, r.target_game_id)::text from public.game_reports r where r.id = '7c1d2c3d-0000-4000-8000-000000000007'
|
||||
union all select 'cara_c5', row(c.game_id, c.list, c.note)::text from public.collection_items c where c.id = 'f8000000-0000-0000-0000-0000000000c5'
|
||||
union all select 'variants', (select count(*) from public.game_variants where game_id in ('7c1d2c3d-0000-4000-8000-000000000002', '7c1d2c3d-0000-4000-8000-000000000004'))::text;
|
||||
|
||||
select cmp_ok((select count(*) from public.game_reports where owner_id = '11111111-1111-1111-1111-111111111111'), '=', 2::bigint,
|
||||
'Vorbedingung: Anna hat zwei bearbeitete Meldungen');
|
||||
select cmp_ok((select count(*) from public.games where owner_id = '11111111-1111-1111-1111-111111111111' and merged_into_id is not null), '=', 2::bigint,
|
||||
'Vorbedingung: Anna hat zwei ausgeblendete vorläufige Einträge mit merged_into_id');
|
||||
select lives_ok($$delete from auth.users where id = '11111111-1111-1111-1111-111111111111'$$,
|
||||
'KAT-05 Kriterium 5 / QUE-04: das Konto der Meldenden lässt sich nach der Freigabe löschen');
|
||||
select is((select count(*) from public.game_reports where owner_id = '11111111-1111-1111-1111-111111111111'), 0::bigint,
|
||||
'QUE-04: die Meldungen sind mit dem Konto gelöscht');
|
||||
select is((select count(*) from public.games where owner_id = '11111111-1111-1111-1111-111111111111'), 0::bigint,
|
||||
'QUE-04: die vorläufigen Einträge sind mit dem Konto gelöscht');
|
||||
select is((select count(*) from public.collection_items where owner_id = '11111111-1111-1111-1111-111111111111'), 0::bigint,
|
||||
'QUE-04: Annas Sammlung (auch die umgehängten Einträge) ist gelöscht');
|
||||
select results_eq($$select 'game2', row(g.title, g.year, g.description, g.hidden_at, g.merged_into_id, g.visibility, g.manual_fields)::text from public.games g where g.id = '7c1d2c3d-0000-4000-8000-000000000002'
|
||||
union all select 'game4', row(g.title, g.year, g.hidden_at, g.merged_into_id, g.visibility)::text from public.games g where g.id = '7c1d2c3d-0000-4000-8000-000000000004'
|
||||
union all select 'ben_c7', row(c.game_id, c.list, c.note)::text from public.collection_items c where c.id = 'f8000000-0000-0000-0000-0000000000c7'
|
||||
union all select 'ben_c4', row(c.game_id, c.variant_id, c.list, c.note)::text from public.collection_items c where c.id = 'f8000000-0000-0000-0000-0000000000c4'
|
||||
union all select 'cara_report', row(r.status, r.target_game_id)::text from public.game_reports r where r.id = '7c1d2c3d-0000-4000-8000-000000000007'
|
||||
union all select 'cara_c5', row(c.game_id, c.list, c.note)::text from public.collection_items c where c.id = 'f8000000-0000-0000-0000-0000000000c5'
|
||||
union all select 'variants', (select count(*) from public.game_variants where game_id in ('7c1d2c3d-0000-4000-8000-000000000002', '7c1d2c3d-0000-4000-8000-000000000004'))::text$$,
|
||||
$$select k, v from other_before$$,
|
||||
'QUE-04: Katalogspiele (neu angelegt und vorhanden), ihre Varianten und die Daten und Meldungen anderer Nutzer bleiben unverändert');
|
||||
select is((select count(*) from public.game_reports where owner_id = '33333333-3333-3333-3333-333333333333'), 3::bigint,
|
||||
'Caras Meldungen (zwei aus dem Runbook, eine Import-Meldung) sind nach Annas Löschung vorhanden');
|
||||
|
||||
select * from finish();
|
||||
rollback;
|
||||
Loading…
Reference in a new issue