feat(db): game reports table, admin_resolve_report and visibility trigger (KAT-05 2c) Refs #2
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2c2546ab47
commit
147dd1870e
5 changed files with 1176 additions and 14 deletions
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
Dieses Dokument beschreibt, wie der Admin (Chris) den Katalog in R1 pflegt: ausschließlich über Supabase Studio bzw. `psql`, es gibt keine Admin-Oberfläche in der App (Klärungen 25/44). Grundlage ist die Spec `docs/specs/2-katalog-daten.md`.
|
Dieses Dokument beschreibt, wie der Admin (Chris) den Katalog in R1 pflegt: ausschließlich über Supabase Studio bzw. `psql`, es gibt keine Admin-Oberfläche in der App (Klärungen 25/44). Grundlage ist die Spec `docs/specs/2-katalog-daten.md`.
|
||||||
|
|
||||||
Stand: PR 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
|
## 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).
|
- **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.
|
- **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.
|
- **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):
|
**Schritt 1: Duplikate finden** (normalisierter Titel + Jahr, nur sichtbare Katalogspiele; die älteste ID steht zuerst):
|
||||||
|
|
@ -265,7 +265,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,
|
{"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,
|
"variants_copied": 2, "variants_mapped": 1, "variants_cleared": 0, "chain_flattened": 0,
|
||||||
"favorite_conflicts": 0, "bgg_id_transferred": true, "source_transferred": false,
|
"favorite_conflicts": 0, "bgg_id_transferred": true, "source_transferred": false,
|
||||||
"collection_conflicts": 1}
|
"collection_conflicts": 1, "reports_repointed": 0}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Schlüssel | Bedeutung |
|
| Schlüssel | Bedeutung |
|
||||||
|
|
@ -275,11 +275,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 |
|
| `favorite_games`, `favorite_conflicts` | Favoriten umgehängt bzw. wegen vorhandenem Favoriten am Ziel entfallen |
|
||||||
| `plays`, `score_sheets` | umgehängte Partien und Wertungsbögen |
|
| `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_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_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 |
|
| `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`) |
|
| `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 |
|
| `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`):
|
**Fehlermeldungen** (alle ohne Änderung, `P0001`):
|
||||||
|
|
||||||
|
|
@ -296,7 +297,145 @@ Ein Aufruf über die App-Schnittstelle (als `anon` oder `authenticated`, auch mi
|
||||||
|
|
||||||
## 6. Meldungen bearbeiten
|
## 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
|
## 7. Admin-Rolle in der App
|
||||||
|
|
||||||
|
|
|
||||||
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;
|
begin;
|
||||||
select plan(5);
|
select plan(5);
|
||||||
|
|
||||||
select is((select count(*) from pg_tables where schemaname = 'public'), 20::bigint,
|
select is((select count(*) from pg_tables where schemaname = 'public'), 21::bigint,
|
||||||
'R1 hat genau 20 Tabellen (17 + catalog_sources, publishers, game_publishers)');
|
'R1 hat genau 21 Tabellen (17 + catalog_sources, publishers, game_publishers, game_reports)');
|
||||||
|
|
||||||
select is_empty($$
|
select is_empty($$
|
||||||
select c.relname from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
select c.relname from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
-- Fremdschlüssel-Wächter und die SQL-Bausteine aus docs/katalog-pflege.md, Abschnitt 5
|
-- Fremdschlüssel-Wächter und die SQL-Bausteine aus docs/katalog-pflege.md, Abschnitt 5
|
||||||
-- (unverändert, ohne begin/commit; Auswahl immer per id).
|
-- (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.
|
-- 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
|
-- 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) erweitert werden, sonst wird er rot.
|
-- game_reports.provisional_game_id (bewusst ausgelassen); admin_merge_games meldet reports_repointed.
|
||||||
begin;
|
begin;
|
||||||
select plan(89);
|
select plan(89);
|
||||||
|
|
||||||
|
|
@ -209,7 +209,7 @@ select is((select admin_merge_games from rb_merge),
|
||||||
'source_id', '7a1b2c3d-0000-4000-8000-000000000002', 'target_id', '7a1b2c3d-0000-4000-8000-000000000003',
|
'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,
|
'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,
|
'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');
|
'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)
|
-- Runbook 5, Schritt 4: Nachprüfen (der Vorschau-Baustein aus Schritt 2 liefert überall 0)
|
||||||
|
|
@ -402,7 +402,7 @@ select is((select count(*) from public.game_variants where game_id = 'f7000000-0
|
||||||
insert into public.games (id, title, visibility) values
|
insert into public.games (id, title, visibility) values
|
||||||
('f7000000-0000-0000-0000-000000000161', 'Leer Quelle', 'catalog'), ('f7000000-0000-0000-0000-000000000162', 'Leer Ziel', 'catalog');
|
('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',
|
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');
|
'Zusammenführen ohne Verweise liefert lauter Nullen');
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------------------------
|
||||||
|
|
@ -475,9 +475,11 @@ select set_eq($$select fk from fk_found$$,
|
||||||
$$values
|
$$values
|
||||||
-- behandelt von repoint_game_references / admin_merge_games
|
-- behandelt von repoint_game_references / admin_merge_games
|
||||||
('collection_items.game_id'), ('favorite_games.game_id'), ('plays.game_id'), ('score_sheets.game_id'),
|
('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
|
-- 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');
|
'KAT-05 Kriterium 4: Fremdschlüssel-Wächter: neue Verweise auf games erzwingen eine Entscheidung im Zusammenführen');
|
||||||
|
|
||||||
select * from finish();
|
select * from finish();
|
||||||
|
|
|
||||||
741
supabase/tests/database/08_game_reports.test.sql
Normal file
741
supabase/tests/database/08_game_reports.test.sql
Normal file
|
|
@ -0,0 +1,741 @@
|
||||||
|
-- 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(152);
|
||||||
|
|
||||||
|
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)');
|
||||||
|
select throws_ok($$delete from public.games where id = '7c1d2c3d-0000-4000-8000-000000000004'$$, '23503', null,
|
||||||
|
'Fremdschlüssel: ein Ziel mit Verweis aus einer Meldung lässt sich nicht löschen (on delete restrict)');
|
||||||
|
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