Compare commits

...

3 commits

Author SHA1 Message Date
cschlaefke
3135ed6381 docs(db): Meldungen (KAT-04) in Datenmodell, Katalogpflege, README und Umsetzungs-Nachtrag
Refs #13

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 19:16:31 +01:00
cschlaefke
0826c97471 test(db): Policy- und Fehlerpfade für KAT-04 nachgeschärft (Refs #13)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 19:14:15 +01:00
cschlaefke
7186cddac5 feat(db): Fehlendes Spiel melden – Migration, RPCs, RLS und pgTAP-Tests (KAT-04, 13a)
Refs #13

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 19:11:20 +01:00
7 changed files with 1193 additions and 6 deletions

View file

@ -43,7 +43,7 @@ R1 braucht 22 Tabellen in vier Bereichen (17 aus der Grundplanung, dazu `catalog
| Tabelle | Spalten | Zweck | | Tabelle | Spalten | Zweck |
| --- | --- | --- | | --- | --- | --- |
| `games` | `owner_id` (FK → `profiles`, nur bei privaten Spielen), `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `bgg_id` (optional, eindeutig), `visibility` (`catalog` oder `private`), `hidden_at`, `merged_into_id` (FK → `games`), `manual_fields`, `catalog_source_id` (FK → `catalog_sources`), `source_ref`, `source_url`, `source_synced_at`, `title_search` (generiert) | Ein Eintrag je Spiel; private Einträge sind vorläufig und nur für den Ersteller sichtbar (O5). Die sieben Spalten vor `title_search` gehören der Katalogpflege (KAT-05), `title_search` der Suche (KAT-01), siehe unten | | `games` | `owner_id` (FK → `profiles`, nur bei privaten Spielen), `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `bgg_id` (optional, eindeutig), `visibility` (`catalog` oder `private`), `hidden_at`, `merged_into_id` (FK → `games`), `manual_fields`, `catalog_source_id` (FK → `catalog_sources`), `source_ref`, `source_url`, `source_synced_at`, `title_search` (generiert) | Ein Eintrag je Spiel; private Einträge sind vorläufig und nur für den Ersteller sichtbar (O5). Die sieben Spalten vor `title_search` gehören der Katalogpflege (KAT-05), `title_search` der Suche (KAT-01), siehe unten |
| `game_reports` | `owner_id` (FK → `profiles`), `origin` (`user`/`import`), `title`, `publisher_name`, `year`, `link`, `bgg_id`, `provisional_game_id` (FK → `games`), `status` (`open`/`approved`/`rejected`/`merged`), `target_game_id` (FK → `games`), `resolved_at`, `editions_cleared` | Meldungen fehlender Spiele (KAT-05 PR 2c, Anlegen durch Nutzer kommt mit KAT-04); siehe Katalogpflege | | `game_reports` | `owner_id` (FK → `profiles`), `origin` (`user`/`import`), `title`, `publisher_name`, `year`, `link`, `bgg_id`, `provisional_game_id` (FK → `games`), `status` (`open`/`approved`/`rejected`/`merged`), `target_game_id` (FK → `games`), `resolved_at`, `editions_cleared`, `result_seen_at` | Meldungen fehlender Spiele (KAT-05 PR 2c; Anlegen durch Nutzer und Ergebnishinweis KAT-04 PR 13a); siehe Katalogpflege und Abschnitt „Meldungen (KAT-04)“ |
| `catalog_sources` | `key`, `name`, `attribution_text`, `attribution_url`, `images_allowed`, `texts_allowed`, `terms_url`, `terms_checked_at` | Datenquellen des Katalogs mit Anzeige-Erlaubnis für Bilder und Texte je Quelle (KAT-05); die Migration legt `bgg` ohne Erlaubnis an | | `catalog_sources` | `key`, `name`, `attribution_text`, `attribution_url`, `images_allowed`, `texts_allowed`, `terms_url`, `terms_checked_at` | Datenquellen des Katalogs mit Anzeige-Erlaubnis für Bilder und Texte je Quelle (KAT-05); die Migration legt `bgg` ohne Erlaubnis an |
| `publishers`, `game_publishers` | `name` (eindeutig ohne Beachtung der Schreibweise), `name_search` (generiert); `game_id`, `publisher_id` | Verlage und ihre Zuordnung zu Spielen (Klärung 4) | | `publishers`, `game_publishers` | `name` (eindeutig ohne Beachtung der Schreibweise), `name_search` (generiert); `game_id`, `publisher_id` | Verlage und ihre Zuordnung zu Spielen (Klärung 4) |
| `game_alt_titles` | `game_id` (FK → `games`), `title`, `locale` (`de`/`en`), `title_search` (generiert) | Alternative Titel eines Spiels mit Sprache (Klärung 3, KAT-01); die Suche findet das Spiel auch darüber | | `game_alt_titles` | `game_id` (FK → `games`), `title`, `locale` (`de`/`en`), `title_search` (generiert) | Alternative Titel eines Spiels mit Sprache (Klärung 3, KAT-01); die Suche findet das Spiel auch darüber |
@ -78,7 +78,7 @@ Jede Tabelle hat Row Level Security (RLS) und eine Policy-Test-Suite (pgTAP). De
| `profiles` | Eigenes Profil; fremde nur `display_name`, `avatar_path`, `city` | Nur eigenes | | `profiles` | Eigenes Profil; fremde nur `display_name`, `avatar_path`, `city` | Nur eigenes |
| `favorite_games`, `bgg_links` | Nur eigene | Nur eigene | | `favorite_games`, `bgg_links` | Nur eigene | Nur eigene |
| `games`, `game_variants`, Begriffe | Katalog für alle (O6); private Spiele nur der Ersteller | Katalog nur Admin (in R1 nur über Studio/SQL, E10); private Spiele nur der Ersteller, und nur die Spalten laut Spaltenrechten | | `games`, `game_variants`, Begriffe | Katalog für alle (O6); private Spiele nur der Ersteller | Katalog nur Admin (in R1 nur über Studio/SQL, E10); private Spiele nur der Ersteller, und nur die Spalten laut Spaltenrechten |
| `game_reports` | nur der Meldende liest seine Zeilen (Admin-Policy `is_admin()` für R4); Schreiben kein Nutzerrecht | Anlegen ergänzt KAT-04 (#13); Bearbeiten nur über `admin_resolve_report` (Studio) | | `game_reports` | nur der Meldende liest seine Zeilen (Admin-Policy `is_admin()` für R4) | Anlegen: eigene Zeile mit eigenem vorläufigen Eintrag (Spaltenrechte, Policy `game_reports_insert_own`, Tageslimit); Ändern: nur `result_seen_at` an eigenen, bereits bearbeiteten Meldungen; kein Löschen. Bearbeiten (Status, Ziel) nur über `admin_resolve_report` (Studio) |
| `game_alt_titles` | für alle, auch ohne Anmeldung; folgt dem Spiel (alternative Titel eines fremden privaten Spiels sind unsichtbar) | **niemand über die API** (kein Recht, keine Policy); Pflege nur über Studio und das Startbestand-Werkzeug | | `game_alt_titles` | für alle, auch ohne Anmeldung; folgt dem Spiel (alternative Titel eines fremden privaten Spiels sind unsichtbar) | **niemand über die API** (kein Recht, keine Policy); Pflege nur über Studio und das Startbestand-Werkzeug |
| `catalog_sources`, `publishers`, `game_publishers`, View `catalog_games` | für alle, auch ohne Anmeldung (`game_publishers` folgt dem Spiel) | Quellen und Verlage nur Admin; `game_publishers` Admin oder Ersteller des privaten Spiels; die View ist nicht beschreibbar | | `catalog_sources`, `publishers`, `game_publishers`, View `catalog_games` | für alle, auch ohne Anmeldung (`game_publishers` folgt dem Spiel) | Quellen und Verlage nur Admin; `game_publishers` Admin oder Ersteller des privaten Spiels; die View ist nicht beschreibbar |
| `collection_items`, `score_sheets`, `score_categories` | Nur eigene | Nur eigene | | `collection_items`, `score_sheets`, `score_categories` | Nur eigene | Nur eigene |
@ -267,7 +267,8 @@ Eindeutig ist `(game_id, locale, title_search)`: derselbe Titel in derselben Spr
| `status` | text | ja | `'open'` | `open`, `approved`, `rejected`, `merged` | | `status` | text | ja | `'open'` | `open`, `approved`, `rejected`, `merged` |
| `target_game_id` | uuid | nein | | FK → games (`on delete restrict`) | | `target_game_id` | uuid | nein | | FK → games (`on delete restrict`) |
| `resolved_at` | timestamptz | nein | | gesetzt genau dann, wenn `status <> 'open'` | | `resolved_at` | timestamptz | nein | | gesetzt genau dann, wenn `status <> 'open'` |
| `editions_cleared` | integer | ja | `0` | Anzahl der Verweise (Sammlung, Partien), deren Edition beim Umhängen geleert wurde (`variants_cleared`); Check `>= 0`; setzt nur `admin_resolve_report` (Spec-F6) | | `editions_cleared` | integer | ja | `0` | Anzahl der Verweise (Sammlung, Partien), deren Edition beim Umhängen geleert wurde (`variants_cleared`); Check `>= 0`; setzen nur `admin_resolve_report` und `adopt_report_target` (Spec-F6) |
| `result_seen_at` | timestamptz | nein | | Der Nutzer hat den Ergebnishinweis weggeklickt (KAT-04, Spec-F5); Check: nur bei `status <> 'open'` |
| `created_at`, `updated_at` | timestamptz | ja | `now()` | | | `created_at`, `updated_at` | timestamptz | ja | `now()` | |
Checks: offen hat kein Ziel; `approved`/`merged` brauchen ein Ziel. Indizes: `(owner_id, created_at)`, eindeutig `(owner_id, bgg_id)` für offene Import-Meldungen (N12a), `(created_at)` für offene Meldungen, eindeutig `provisional_game_id`, `target_game_id`. Checks: offen hat kein Ziel; `approved`/`merged` brauchen ein Ziel. Indizes: `(owner_id, created_at)`, eindeutig `(owner_id, bgg_id)` für offene Import-Meldungen (N12a), `(created_at)` für offene Meldungen, eindeutig `provisional_game_id`, `target_game_id`.
@ -382,7 +383,7 @@ Die Pflege läuft in R1 nur über Supabase Studio bzw. `psql` als Datenbankrolle
- **Ausblenden:** `games.hidden_at` gesetzt = ausgeblendet. Die View `catalog_games` enthält nur Katalogspiele (`visibility = 'catalog'`) ohne `hidden_at` und ohne `merged_into_id`. **Suche und Entdecken lesen nur diese View.** Ein ausgeblendetes Spiel bleibt per ID lesbar (Sammlungen, Partien und Detailseite verweisen darauf). - **Ausblenden:** `games.hidden_at` gesetzt = ausgeblendet. Die View `catalog_games` enthält nur Katalogspiele (`visibility = 'catalog'`) ohne `hidden_at` und ohne `merged_into_id`. **Suche und Entdecken lesen nur diese View.** Ein ausgeblendetes Spiel bleibt per ID lesbar (Sammlungen, Partien und Detailseite verweisen darauf).
- **Zusammenführen** (Migration `catalog_merge`, PR 2b): `games.merged_into_id` zeigt auf das Zielspiel; ein Trigger lässt nur Katalogspiele als Ziel zu (Fehler `merged_into_not_catalog`). **Regel: `merged_into_id` wird nur über `public.admin_merge_games(p_source, p_target) returns jsonb` gesetzt, nie von Hand.** (Ablauf und Ergebnis-JSON: `docs/katalog-pflege.md`, Abschnitt 5). Sie ist `security invoker`, prüft `current_user in ('postgres', 'supabase_admin')` (sonst `admin_only`) und ist für `anon`, `authenticated` und `service_role` nicht ausführbar; einen API-Aufruf gibt es nicht. Beide Spiele müssen Katalogspiele sein, das Ziel sichtbar. Die Funktion hängt über die interne Funktion `public.repoint_game_references(p_from, p_to)` Sammlung, Favoriten, Partien (mit Edition) und Wertungsbögen um, kopiert die Varianten der Quelle ans Ziel (gleichnamige Zielvarianten werden abgebildet: `lower(btrim(name))`, bei mehreren die älteste, dann die kleinste `id`) und löscht danach die alten Varianten. Bei Doppelbesitz bleibt der Zieleintrag, leere Felder füllt die Quelle, die Liste folgt `owned` vor `wishlist` vor `shopping`, der Quell-Eintrag wird gelöscht; ein Favorit der Quelle entfällt, wenn der Nutzer das Ziel schon als Favorit hat. `bgg_id` und `catalog_source_id`/`source_*` gehen aufs Ziel über, wenn es keine hat; sonst werden sie an der Quelle trotzdem geleert und der Konflikt mit den alten Werten im Ergebnis gemeldet. Die Quelle wird nie gelöscht, sondern ausgeblendet und verwiesen (`hidden_at`, `merged_into_id`); Ketten werden geglättet. Zuordnungen (Genre, Mechanik, Kategorie, Verlag) bleiben am Quell-Eintrag. Ein pgTAP-Wächter (`07`) listet alle Fremdschlüssel auf `games` und lässt neue Verweise erst nach einer Entscheidung zu. - **Zusammenführen** (Migration `catalog_merge`, PR 2b): `games.merged_into_id` zeigt auf das Zielspiel; ein Trigger lässt nur Katalogspiele als Ziel zu (Fehler `merged_into_not_catalog`). **Regel: `merged_into_id` wird nur über `public.admin_merge_games(p_source, p_target) returns jsonb` gesetzt, nie von Hand.** (Ablauf und Ergebnis-JSON: `docs/katalog-pflege.md`, Abschnitt 5). Sie ist `security invoker`, prüft `current_user in ('postgres', 'supabase_admin')` (sonst `admin_only`) und ist für `anon`, `authenticated` und `service_role` nicht ausführbar; einen API-Aufruf gibt es nicht. Beide Spiele müssen Katalogspiele sein, das Ziel sichtbar. Die Funktion hängt über die interne Funktion `public.repoint_game_references(p_from, p_to)` Sammlung, Favoriten, Partien (mit Edition) und Wertungsbögen um, kopiert die Varianten der Quelle ans Ziel (gleichnamige Zielvarianten werden abgebildet: `lower(btrim(name))`, bei mehreren die älteste, dann die kleinste `id`) und löscht danach die alten Varianten. Bei Doppelbesitz bleibt der Zieleintrag, leere Felder füllt die Quelle, die Liste folgt `owned` vor `wishlist` vor `shopping`, der Quell-Eintrag wird gelöscht; ein Favorit der Quelle entfällt, wenn der Nutzer das Ziel schon als Favorit hat. `bgg_id` und `catalog_source_id`/`source_*` gehen aufs Ziel über, wenn es keine hat; sonst werden sie an der Quelle trotzdem geleert und der Konflikt mit den alten Werten im Ergebnis gemeldet. Die Quelle wird nie gelöscht, sondern ausgeblendet und verwiesen (`hidden_at`, `merged_into_id`); Ketten werden geglättet. Zuordnungen (Genre, Mechanik, Kategorie, Verlag) bleiben am Quell-Eintrag. Ein pgTAP-Wächter (`07`) listet alle Fremdschlüssel auf `games` und lässt neue Verweise erst nach einer Entscheidung zu.
- **Meldungen** (Migration `game_reports`, PR 2c): Tabelle `game_reports` (siehe Tabellenstruktur). Lesen darf nur der Meldende (Policy auf `owner_id`; die Policy für `is_admin()` ist für R4 vorbereitet); Schreibrechte haben `anon` und `authenticated` nicht, #13 ergänzt Insert-Recht und -Policy. Bearbeitet wird in R1 nur über `public.admin_resolve_report(p_report, p_status, p_target) returns jsonb` (Ablauf, Ergebnis und Fehler: `docs/katalog-pflege.md`, Abschnitt 6): `security invoker`, prüft `current_user in ('postgres', 'supabase_admin')` (sonst `admin_only`), für `anon`, `authenticated` und `service_role` nicht ausführbar. `approved` und `merged` hängen den vorläufigen privaten Eintrag über `repoint_game_references` auf das Ziel um (Varianten werden nicht kopiert, gleichnamige abgebildet, sonst geleert, `variants_cleared`) und blenden ihn aus (`hidden_at`, `merged_into_id`); `rejected` speichert höchstens den Verweis. `admin_merge_games` hängt zusätzlich `game_reports.target_game_id` von der Quelle auf das Ziel (`reports_repointed`). Der Trigger `games_visibility_immutable` lehnt jede Änderung von `visibility` und `owner_id` an bestehenden Spielen ab (Fehler `visibility_immutable`, für alle Rollen auch `postgres`; vor der RLS-Prüfung, ein Nutzer bekommt also P0001 statt 42501). `admin_resolve_report` schreibt außerdem `editions_cleared` (Anzahl geleerter Editionen, 0 bei `rejected` und ohne Umhängen), damit die App den Nutzer zum Neuwählen auffordern kann (Spec-F6). Beim Konto-Löschen werden Meldungen und vorläufige Einträge (auch nach der Freigabe) hart gelöscht, Katalogspiele und Daten anderer Nutzer bleiben. Fremdschlüssel-Wächter: `07` und `08`. - **Meldungen** (Migration `game_reports`, PR 2c): Tabelle `game_reports` (siehe Tabellenstruktur). Lesen darf nur der Meldende (Policy auf `owner_id`; die Policy für `is_admin()` ist für R4 vorbereitet); `anon` hat keine Rechte; `authenticated` bekommt ab KAT-04 (Migration `game_reports_user`) Insert- und Update-Rechte nur spaltenweise (siehe Abschnitt „Meldungen (KAT-04)“). Bearbeitet wird in R1 nur über `public.admin_resolve_report(p_report, p_status, p_target) returns jsonb` (Ablauf, Ergebnis und Fehler: `docs/katalog-pflege.md`, Abschnitt 6): `security invoker`, prüft `current_user in ('postgres', 'supabase_admin')` (sonst `admin_only`), für `anon`, `authenticated` und `service_role` nicht ausführbar. `approved` und `merged` hängen den vorläufigen privaten Eintrag über `repoint_game_references` auf das Ziel um (Varianten werden nicht kopiert, gleichnamige abgebildet, sonst geleert, `variants_cleared`) und blenden ihn aus (`hidden_at`, `merged_into_id`); `rejected` speichert höchstens den Verweis. `admin_merge_games` hängt zusätzlich `game_reports.target_game_id` von der Quelle auf das Ziel (`reports_repointed`). Der Trigger `games_visibility_immutable` lehnt jede Änderung von `visibility` und `owner_id` an bestehenden Spielen ab (Fehler `visibility_immutable`, für alle Rollen auch `postgres`; vor der RLS-Prüfung, ein Nutzer bekommt also P0001 statt 42501). `admin_resolve_report` schreibt außerdem `editions_cleared` (Anzahl geleerter Editionen, 0 bei `rejected` und ohne Umhängen), damit die App den Nutzer zum Neuwählen auffordern kann (Spec-F6). Beim Konto-Löschen werden Meldungen und vorläufige Einträge (auch nach der Freigabe) hart gelöscht, Katalogspiele und Daten anderer Nutzer bleiben. Fremdschlüssel-Wächter: `07` und `08`.
- **Manuell gepflegte Felder:** Der Trigger `games_mark_manual_fields` trägt jedes Feld eines Katalogspiels, das ein Mensch setzt oder ändert, in `manual_fields` ein (beim Anlegen alle gesetzten Felder). Der Trigger `game_links_mark_manual` ergänzt `genres`, `mechanics`, `categories` und `publishers`, wenn Zuordnungen angelegt, geändert oder gelöscht werden. Ausgenommen ist nur ein späterer Import: Rolle `catalog_importer` **und** Einstellung `shuffle.catalog_import = 'on'` (der Import setzt sie per `set local`; das ist Konvention, die Rolle ist die Sperre). Private Spiele werden nicht gekennzeichnet. Eine bewusst entfernte Markierung entfernt der Trigger nicht wieder. - **Manuell gepflegte Felder:** Der Trigger `games_mark_manual_fields` trägt jedes Feld eines Katalogspiels, das ein Mensch setzt oder ändert, in `manual_fields` ein (beim Anlegen alle gesetzten Felder). Der Trigger `game_links_mark_manual` ergänzt `genres`, `mechanics`, `categories` und `publishers`, wenn Zuordnungen angelegt, geändert oder gelöscht werden. Ausgenommen ist nur ein späterer Import: Rolle `catalog_importer` **und** Einstellung `shuffle.catalog_import = 'on'` (der Import setzt sie per `set local`; das ist Konvention, die Rolle ist die Sperre). Private Spiele werden nicht gekennzeichnet. Eine bewusst entfernte Markierung entfernt der Trigger nicht wieder.
- **Anzeige-Erlaubnis:** `catalog_games.description` und `image_path` sind leer, wenn die Quelle (`catalog_sources`) Texte bzw. Bilder nicht erlaubt und das Feld nicht in `manual_fields` steht. Das ist nur die zweite Sicherung: Daten ohne Erlaubnis dürfen gar nicht erst gespeichert werden (Pflicht für den Import). - **Anzeige-Erlaubnis:** `catalog_games.description` und `image_path` sind leer, wenn die Quelle (`catalog_sources`) Texte bzw. Bilder nicht erlaubt und das Feld nicht in `manual_fields` steht. Das ist nur die zweite Sicherung: Daten ohne Erlaubnis dürfen gar nicht erst gespeichert werden (Pflicht für den Import).
- **Spaltenrechte auf `games`:** Nutzer dürfen nur `owner_id` (nur beim Anlegen), `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `bgg_id` und `visibility` schreiben, nicht `id`, `hidden_at`, `merged_into_id`, `manual_fields`, `catalog_source_id` und `source_*`. - **Spaltenrechte auf `games`:** Nutzer dürfen nur `owner_id` (nur beim Anlegen), `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `bgg_id` und `visibility` schreiben, nicht `id`, `hidden_at`, `merged_into_id`, `manual_fields`, `catalog_source_id` und `source_*`.
@ -390,6 +391,38 @@ Die Pflege läuft in R1 nur über Supabase Studio bzw. `psql` als Datenbankrolle
- **Konto löschen:** Neue Nutzerdaten entstehen nicht; die Zuordnungen privater Spiele (`game_publishers` usw.) werden mit dem Spiel gelöscht. - **Konto löschen:** Neue Nutzerdaten entstehen nicht; die Zuordnungen privater Spiele (`game_publishers` usw.) werden mit dem Spiel gelöscht.
- **API-Änderungen für das Frontend:** neu lesbar für alle: View `catalog_games`, Tabellen `catalog_sources`, `publishers`, `game_publishers`; neu für den Meldenden lesbar: `game_reports`. Das Ändern von `visibility` an einem Spiel ist nicht mehr möglich (P0001 `visibility_immutable`); wer eine Sichtbarkeit mitsendet, muss den alten Wert senden oder die Spalte weglassen. Beim Anlegen und Ändern eines privaten Spiels dürfen Clients keine `id`, `created_at` und `updated_at` mehr mitsenden (42501; die ID kommt per `returning`); ein `toJson()` eines Modells darf nie ungefiltert an `insert`/`update` gehen (Merkpunkt für #3 und #13). Der `is_admin()`-Zweig der Zuordnungs-Policies (`game_*_write`) greift für Katalogspiele faktisch nicht mehr, weil der Trigger `game_links_mark_manual` `manual_fields` schreiben muss; ein späterer Admin-Pfad (QUE-08) braucht eigene RPCs. Es gibt keine generierten Datenbanktypen im Repo; das Domänenmodell `CatalogGame` legt KAT-01 (#3) auf Basis der Spalten von `catalog_games` an. - **API-Änderungen für das Frontend:** neu lesbar für alle: View `catalog_games`, Tabellen `catalog_sources`, `publishers`, `game_publishers`; neu für den Meldenden lesbar: `game_reports`. Das Ändern von `visibility` an einem Spiel ist nicht mehr möglich (P0001 `visibility_immutable`); wer eine Sichtbarkeit mitsendet, muss den alten Wert senden oder die Spalte weglassen. Beim Anlegen und Ändern eines privaten Spiels dürfen Clients keine `id`, `created_at` und `updated_at` mehr mitsenden (42501; die ID kommt per `returning`); ein `toJson()` eines Modells darf nie ungefiltert an `insert`/`update` gehen (Merkpunkt für #3 und #13). Der `is_admin()`-Zweig der Zuordnungs-Policies (`game_*_write`) greift für Katalogspiele faktisch nicht mehr, weil der Trigger `game_links_mark_manual` `manual_fields` schreiben muss; ein späterer Admin-Pfad (QUE-08) braucht eigene RPCs. Es gibt keine generierten Datenbanktypen im Repo; das Domänenmodell `CatalogGame` legt KAT-01 (#3) auf Basis der Spalten von `catalog_games` an.
## Meldungen (KAT-04, Migration `game_reports_user`)
Spec: `docs/specs/13-fehlendes-spiel-melden.md` (Umsetzungsnachtrag: `docs/specs/13-umsetzung.md`). Nur additiv zu `game_reports` aus KAT-05; keine neue Tabelle, kein neuer Fremdschlüssel.
**Ablauf (Nutzer):** Die App ruft zuerst `find_similar_catalog_games(titel, jahr)`; gibt es Treffer, bestätigt der Nutzer ausdrücklich, dass sein Spiel ein anderes ist. Danach `report_missing_game(...)` mit `p_confirmed_distinct = true`. Die RPC legt in einer Transaktion den vorläufigen privaten Eintrag (nur Titel und Jahr), die Meldung (`status = 'open'`, `origin = 'user'`) und optional den Listeneintrag an. Der Admin bearbeitet die Meldung manuell (`admin_resolve_report`, `docs/katalog-pflege.md` Abschnitt 6). Bei „abgelehnt mit Verweis“ entscheidet der Nutzer selbst über den Wechsel auf den Zieleintrag (`adopt_report_target`). Den Ergebnishinweis klickt die App mit einem Update von `result_seen_at` weg.
**Funktionen** (alle mit `search_path = ''`; jede zuerst für `public`, `anon`, `authenticated`, `service_role` entzogen, danach nur der genannte Grant):
| Funktion | Art | `execute` | Zweck |
| --- | --- | --- | --- |
| `strip_leading_article(text)`, `normalize_game_title(text)` | sql, immutable | `authenticated` | Titel normalisieren; `normalize_game_title` = `search_normalize` plus ein führender Artikel (de/en), Rückfall `lower(btrim)` |
| `report_day(timestamptz)` | sql, immutable | `authenticated` | Berliner Kalendertag (Tageslimit); `authenticated` braucht das Recht, weil der Trigger als `security invoker` läuft |
| `find_similar_catalog_games(p_title, p_year)` | RPC, `security invoker`, stable | `authenticated` | höchstens 5 sichtbare Katalogspiele mit gleichem normalisierten Titel (Regel A, jedes Jahr) oder Titel-Ähnlichkeit >= 0,8 und Jahr ± 1 (Regel B; fehlt ein Jahr, ohne Jahresbedingung); Kandidatentitel sind der Haupttitel und alle alternativen Titel (`title_search`); Rückgabe `game_id, title, matched_title, year, publishers, match_kind ('same_title'/'similar_title'), similarity`; Titel leer oder über 200 Zeichen ergibt eine leere Menge |
| `report_missing_game(p_title, p_publisher_name, p_year, p_link, p_list, p_confirmed_distinct)` | RPC, `security invoker` | `authenticated` | Meldung + vorläufiger Eintrag + optional Listeneintrag; Rückgabe `{"report_id", "provisional_game_id", "collection_item_id"}` |
| `adopt_report_target(p_report)` | RPC, **`security definer`**, Eigentümer `postgres` | `authenticated` | Wechsel auf den Zieleintrag einer mit Verweis abgelehnten Meldung; Rückgabe: Zähler aus `repoint_game_references` |
| `game_reports_daily_limit()` | Trigger-Funktion, `security invoker` | niemand | Tageslimit |
**Fehlercodes** (`raise exception '<code>' using errcode = 'P0001'`; die App bildet sie auf Domänenfehler ab):
`report_missing_game`: `not_authenticated`, `report_title_required`, `report_title_too_long`, `report_publisher_too_long`, `report_year_invalid`, `report_link_invalid`, `report_list_invalid`, `report_daily_limit`, `report_similar_exists`, `report_provisional_in_use` (nur bei gleichzeitigem Konflikt auf den Teilindex; über die RPC praktisch nicht erreichbar, ein direkter Insert liefert 23505).
`adopt_report_target`: `not_authenticated`, `report_not_found` (unbekannt, fremd oder vorläufiger Eintrag gehört nicht dem Aufrufer), `report_not_adoptable`, `report_no_provisional`, `report_already_adopted`, `report_target_not_listed`, `report_foreign_references`.
Tageslimit im Trigger: `report_daily_limit`.
**Rechte und Policies auf `game_reports`:** Insert-Recht nur auf `owner_id, title, publisher_name, year, link, provisional_game_id`; Update-Recht nur auf `result_seen_at`; kein Löschrecht. Nicht schreibbar (42501): `id`, `origin`, `status`, `bgg_id`, `target_game_id`, `resolved_at`, `editions_cleared`, `created_at`, `updated_at`. Policy `game_reports_insert_own`: `owner_id = auth.uid()`, `origin = 'user'`, `status = 'open'`, kein Ziel, `provisional_game_id` verweist auf ein eigenes privates, nicht ausgeblendetes und nicht zusammengeführtes Spiel. Policy `game_reports_update_seen`: nur eigene Meldungen mit `status <> 'open'`. Lesen unverändert (nur eigene).
**Tageslimit (Krit. 7):** Trigger `game_reports_daily_limit` (`before insert ... when (new.origin = 'user')`) lässt höchstens 10 Nutzer-Meldungen je Nutzer und Berliner Kalendertag zu (Advisory-Lock je Nutzer gegen gleichzeitige Inserts, setzt READ COMMITTED voraus, den Standard bei PostgREST). Import-Meldungen (`origin = 'import'`) werden weder gezählt noch begrenzt (N12). Der Trigger gilt für jede Rolle: Wer als `postgres` viele Nutzer-Meldungen „von heute“ anlegt (z. B. in Tests), stößt ebenfalls auf das Limit.
**`adopt_report_target` (security definer, Begründung):** Nutzer dürfen `merged_into_id` und `hidden_at` nicht schreiben und `repoint_game_references` nicht ausführen. Die Funktion bestimmt den Aufrufer nur über `auth.uid()`, prüft Eigentum an Meldung und vorläufigem Eintrag, dass das Ziel ein sichtbares Katalogspiel ist und dass keine Verweise mit fremdem oder fehlendem Eigentümer (`collection_items`, `favorite_games`, `plays`, `score_sheets`) am vorläufigen Eintrag hängen; sie ruft nie eine `admin_*`-Funktion auf. Sperrreihenfolge: erst die Meldung, dann beide Spiele nach `id`. `result_seen_at` bleibt unverändert, damit der Hinweis am Zieleintrag erscheint, bis der Nutzer ihn wegklickt. `editions_cleared` wird wie in `admin_resolve_report` gesetzt.
**Normalisierung (gemeinsam mit KAT-01):** `search_normalize(text)` (Migration `catalog_search`) ist die einzige Basis und entfernt nie Artikel. `normalize_game_title` und der Kandidatenschlüssel in `find_similar_catalog_games` entfernen zusätzlich einen führenden Artikel. Ein Titel, der nach `search_normalize` leer ist (nur nicht-lateinische Zeichen oder Sonderzeichen), fällt auf `lower(btrim(titel))` zurück.
**API-Änderungen für das Frontend (13b):** neue RPCs `find_similar_catalog_games`, `report_missing_game`, `adopt_report_target`; `game_reports` ist für `authenticated` jetzt beschreibbar, aber nur wie oben (nie mit `id`, `created_at`, `status`, `origin`, `target_game_id`, `resolved_at`, `bgg_id`, `editions_cleared` senden; die App schreibt über die RPCs und das Update von `result_seen_at` mit `{'result_seen_at': <ISO-8601 UTC>}`). Es gibt keine generierten Datenbanktypen im Repo.
## Katalogsuche (KAT-01, Migration `catalog_search`) ## Katalogsuche (KAT-01, Migration `catalog_search`)
Spec: `docs/specs/3-katalog-suche.md`. Die Suche liest ausschließlich über die RPC `search_catalog_games`, die ihrerseits die View `catalog_games` nutzt. Spec: `docs/specs/3-katalog-suche.md`. Die Suche liest ausschließlich über die RPC `search_catalog_games`, die ihrerseits die View `catalog_games` nutzt.

View file

@ -2,7 +2,7 @@
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 3a (Datenmodell, Startbestand, Ausblenden, Zusammenführen, Meldungen bearbeiten, alternative Titel und Suche). Stand: PR 13a (Datenmodell, Startbestand, Ausblenden, Zusammenführen, Meldungen bearbeiten inkl. Nutzer-Meldungen, alternative Titel und Suche).
**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`, Abschnitte 3.1, 5 (Schritt 2b) und 8 in `09_catalog_search.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`, Abschnitte 3.1, 5 (Schritt 2b) und 8 in `09_catalog_search.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).
@ -401,6 +401,12 @@ Nutzer melden fehlende Spiele (KAT-04, #13); Meldungen, die ein Import anlegt, g
| **zusammenführen** | `'merged'` + Ziel | Der Eintrag war **schon vorhanden**. Wirkung wie bei `'approved'`. | | **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. | | **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. |
**Nutzer-Meldungen (KAT-04, ab PR 13a):** Jede Meldung, die ein Nutzer in der App abschickt, hat einen **vorläufigen Eintrag** (`provisional_game_id`), sofern der Nutzer ihn nicht inzwischen gelöscht hat (dann ist die Spalte leer; `approved`/`merged` ändern dann nur Status und Ziel). Die Meldung entsteht über die RPC `report_missing_game` (höchstens 10 Meldungen je Nutzer und Berliner Kalendertag; Import-Meldungen zählen nicht). Daraus folgt für die Bearbeitung:
- **Bei „abgelehnt mit Verweis“ (`'rejected'` + Ziel) hängt der Admin nichts um.** Der Nutzer entscheidet selbst, ob er auf den Zieleintrag wechselt (Funktion `adopt_report_target`, die dieselbe Umhängelogik `repoint_game_references` nutzt). Status und Ziel nur über `admin_resolve_report` setzen, nie von Hand: Die App leitet den Hinweis an den Nutzer aus Status, Ziel, `merged_into_id` und `result_seen_at` ab.
- **Vor `approved` die Edition berücksichtigen** (Regel „Editionen werden nicht kopiert“ unten): Die App zeigt dem Nutzer nach einer Freigabe den Hinweis „Edition neu wählen“, wenn `editions_cleared > 0`.
- Der Nutzer kann nur `result_seen_at` ändern (Hinweis „Verstanden“); das Ergebnis der Bearbeitung kann er nicht beeinflussen.
**Regeln** **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. - **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.
@ -533,7 +539,7 @@ where r.id = '7c1d2c3d-0000-4000-8000-000000000001';
**Falsch gesetzte `visibility` oder `owner_id`** lassen sich nicht mit `update` korrigieren (Trigger `games_visibility_immutable`, Fehler `visibility_immutable`). Korrigieren geht nur durch Löschen und Neuanlegen des Spiels oder, bewusst und nur als Tabelleneigentümer in einer Transaktion, mit `alter table public.games disable trigger games_visibility_immutable;` … `enable trigger …`. Für Clients: Der Fehler `P0001` kommt über PostgREST als HTTP 400 an (Hinweis für die Fehlerabbildung in #3 und #13). **Falsch gesetzte `visibility` oder `owner_id`** lassen sich nicht mit `update` korrigieren (Trigger `games_visibility_immutable`, Fehler `visibility_immutable`). Korrigieren geht nur durch Löschen und Neuanlegen des Spiels oder, bewusst und nur als Tabelleneigentümer in einer Transaktion, mit `alter table public.games disable trigger games_visibility_immutable;` … `enable trigger …`. Für Clients: Der Fehler `P0001` kommt über PostgREST als HTTP 400 an (Hinweis für die Fehlerabbildung in #3 und #13).
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). Hinweis: Die Rolle `service_role` hat (Supabase-Standard) volle Tabellenrechte und umgeht RLS; die Zugriffsgrenzen in Krit. 9 gelten für die API-Rollen `anon` und `authenticated`, den Funktionen ist `service_role` ausdrücklich entzogen. 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). 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). Hinweis: Die Rolle `service_role` hat (Supabase-Standard) volle Tabellenrechte und umgeht RLS; die Zugriffsgrenzen in Krit. 9 gelten für die API-Rollen `anon` und `authenticated`, den Funktionen ist `service_role` ausdrücklich entzogen. Nutzer können ab KAT-04 (PR 13a) eigene Meldungen nur über die RPC `report_missing_game` bzw. einen Insert mit eng begrenzten Spaltenrechten anlegen und an eigenen, bearbeiteten Meldungen nur `result_seen_at` ändern; Status, Ziel und Löschen bleiben ihnen verwehrt, 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

View file

@ -0,0 +1,24 @@
# Spec #13 – KAT-04 Fehlendes Spiel melden: Umsetzungs-Nachtrag PR 13a (Backend)
Ergänzt `docs/specs/13-fehlendes-spiel-melden.md` (die Spec selbst wird hier nicht geändert). Stand: 2026-11-04, Branch `feature/13a-fehlendes-spiel-backend` (die Spec nennt `feature/13a-meldungen-backend`), Migration `20261104100000_game_reports_user.sql`, Tests `supabase/tests/database/10_game_reports_user.test.sql`.
## 1. Abweichungen und Präzisierungen zum Vertrag
- **Spec-F6 ist entschieden und umgesetzt (2c):** `game_reports.editions_cleared` und die Zuweisung in `admin_resolve_report` stehen schon in der Migration `game_reports` (PR 2c). 13a legt die Spalte nicht an und ändert `admin_resolve_report` nicht; `adopt_report_target` setzt `editions_cleared` genauso.
- **Tageslimit gilt auch für `postgres`:** Der Trigger `game_reports_daily_limit` unterscheidet nicht nach Rolle (Spec 3.2). Fixtures, die als `postgres` mehr als 10 Nutzer-Meldungen „von heute“ für einen Nutzer anlegen, scheitern deshalb. Folge für die Tests: `08_game_reports.test.sql` schaltet den Trigger am Anfang ab (`alter table … disable trigger`, mit dem Rollback am Ende zurückgenommen); in `10` bekommen Fixtures für gestern/älter feste Zeitstempel. Außerdem ist `created_at` nach dem Anlegen unveränderlich (Trigger `set_updated_at`), alte Zeitstempel müssen also beim Einfügen gesetzt werden.
- **Limit-Zählung über `report_day(created_at) = report_day(now())`** (nicht die optionale Bereichsform aus Spec 3.2); bei höchstens 10 Zeilen je Nutzer und Tag spielt der Index `(owner_id, created_at)` keine Rolle.
- **Trimmen:** `report_missing_game` trimmt Leerzeichen, Tab, CR und LF (nicht nur Leerzeichen). Titel, Verlag und Link werden vor allen Prüfungen getrimmt, Längen gelten für den getrimmten Wert.
- **`find_similar_catalog_games`:** Der Titel wird vor der Normalisierung getrimmt und die Länge (1 bis 200) am getrimmten Wert geprüft. Ist der normalisierte Titel leer (kann nach `normalize_game_title` nur bei leerem Rest vorkommen), gibt es keine Treffer. Je Spiel wird der beste Kandidatentitel gewählt: gleicher Schlüssel vor Ähnlichkeit, dann Haupttitel vor alternativen Titeln, dann Schreibweise. Verlage sind nach `publishers.name_search`, dann `name` sortiert (wie in `search_catalog_games`). Bei gleicher Ähnlichkeit sortiert das Ergebnis nach `title`, dann `game_id`. Die Ähnlichkeit bei `same_title` ist immer `1.0`.
- **`report_provisional_in_use`:** Der Handler um den Insert in `game_reports` wertet den Namen des verletzten Index (`game_reports_provisional_game_id_key`) aus; andere Unique-Verletzungen laufen unverändert weiter. Über die RPC ist der Fall nicht erreichbar (jeder Aufruf legt ein frisches Spiel an); der Test lenkt ihn mit einem temporären Trigger herbei.
- **`adopt_report_target`:** Alle Prüfungen laufen nach dem Sperren von Meldung und Spielen, in der Reihenfolge der Spec-Tabelle. Eine Meldung, die nicht dem Aufrufer gehört, wird schon beim Sperren nicht gefunden (`report_not_found`, `p_report = null` ebenso). `current_user` wird nirgends ausgewertet.
- **Eigentümer von `adopt_report_target`:** Die Migration ändert den Eigentümer nicht ausdrücklich (`alter function … owner to`); sie läuft als `postgres` (lokal und per Supabase CLI). Der Test prüft `proowner = postgres`.
- **Testplan:** `00_schema` unverändert (22 Tabellen), `07`/`08`-Fremdschlüssel-Wächter unberührt (kein neuer Fremdschlüssel), `08` nur um das Abschalten des Limit-Triggers ergänzt (Erwartungen und Beschreibungen unverändert gültig: die Spaltenrechte aus 08 waren schon auf `result_seen_at` und die Insert-Spalten ausgelegt).
## 2. Mutationsprüfung
Die Mutanten sind veränderte Kopien der Migration, jeweils in einer Transaktion zusammen mit dem Testlauf angewendet und mit dem Rollback des Tests zurückgenommen (am echten Schema bleibt nichts). Die Skripte liegen nicht im Repo. Ergebnis siehe Bericht an Chris; die überlebenden Mutanten sind entweder Doppelabsicherungen (zum Beispiel `origin = 'user'` in der Insert-Policy, die schon durch die Spaltenrechte gesichert ist) oder nur mit Parallelität testbar (Advisory-Lock).
## 3. Offene Punkte
- Zeitpunkt des Hinweises: Klickt der Nutzer den Hinweis „abgelehnt mit Verweis“ weg (`result_seen_at` gesetzt) und wechselt danach auf den Zieleintrag, bleibt `result_seen_at` gesetzt (Spec 4.4: unverändert). Der Hinweis „Dein Eintrag zeigt jetzt auf …“ erscheint dann am Zieleintrag nicht mehr. Siehe Bericht, Frage an Chris.
- Paralleltest des Limits (zwei `psql`-Sitzungen unter READ COMMITTED) ist nicht automatisiert (Spec 7.1) und gehört zu T1.

View file

@ -45,6 +45,7 @@ supabase test db # alle pgTAP-Tests ausführen
## Katalogsuche (KAT-01) ## Katalogsuche (KAT-01)
- `public.search_normalize(text)` (Normalisierung für Suche und Titelvergleich) und die RPC `public.search_catalog_games(…)` sind **für API-Rollen ausführbar** (`search_normalize`: `anon`, `authenticated`, `service_role`, weil generierte Spalten mit den Rechten des Schreibenden rechnen; `search_catalog_games`: `anon`, `authenticated`). Beides ist die Ausnahme von der Regel „Funktionen sind für niemanden aufrufbar“; die Rechte prüft `tests/database/09_catalog_search.test.sql`. - `public.search_normalize(text)` (Normalisierung für Suche und Titelvergleich) und die RPC `public.search_catalog_games(…)` sind **für API-Rollen ausführbar** (`search_normalize`: `anon`, `authenticated`, `service_role`, weil generierte Spalten mit den Rechten des Schreibenden rechnen; `search_catalog_games`: `anon`, `authenticated`). Beides ist die Ausnahme von der Regel „Funktionen sind für niemanden aufrufbar“; die Rechte prüft `tests/database/09_catalog_search.test.sql`.
- **Fehlendes Spiel melden (KAT-04, `tests/database/10_game_reports_user.test.sql`):** `public.find_similar_catalog_games(titel, jahr)`, `public.report_missing_game(...)` und `public.adopt_report_target(meldung)` sind nur für `authenticated` ausführbar (`anon` und `service_role` nicht); ebenso die Hilfsfunktionen `strip_leading_article`, `normalize_game_title` und `report_day`. `adopt_report_target` ist die einzige `security definer`-Funktion dieser Gruppe (Eigentümer `postgres`, `search_path` leer, Aufrufer nur über `auth.uid()`, ruft keine `admin_*`-Funktion auf). `authenticated` darf in `game_reports` nur `owner_id, title, publisher_name, year, link, provisional_game_id` einfügen und nur `result_seen_at` ändern. Das Tageslimit (10 Nutzer-Meldungen je Berliner Tag) setzt ein Trigger durch, der unter READ COMMITTED (Standard bei PostgREST) wirkt; er gilt für jede Rolle, auch `postgres`. Vertrag und Fehlercodes: `docs/datenmodell-r1.md`, Abschnitt „Meldungen (KAT-04)“.
- Alternative Titel (`game_alt_titles`) sind für API-Rollen nur lesbar; Pflege über Studio (`docs/katalog-pflege.md`, Abschnitt 3.1) und die CSV-Spalte `alt_titles` (`de:Titel;en:Title`, `;` im Titel nicht erlaubt). - Alternative Titel (`game_alt_titles`) sind für API-Rollen nur lesbar; Pflege über Studio (`docs/katalog-pflege.md`, Abschnitt 3.1) und die CSV-Spalte `alt_titles` (`de:Titel;en:Title`, `;` im Titel nicht erlaubt).
- Nach einem Image-Upgrade der Datenbank die Normalisierung prüfen (`docs/katalog-pflege.md`, Abschnitt 8). - Nach einem Image-Upgrade der Datenbank die Normalisierung prüfen (`docs/katalog-pflege.md`, Abschnitt 8).
- Messskript für das Zeitbudget der Suche (nur lokal, nie gegen Staging/Produktion, bewusst nicht unter `tests/`): `perf/catalog_search_perf.sql`, Aufruf im Kopfkommentar. - Messskript für das Zeitbudget der Suche (nur lokal, nie gegen Staging/Produktion, bewusst nicht unter `tests/`): `perf/catalog_search_perf.sql`, Aufruf im Kopfkommentar.

View file

@ -0,0 +1,394 @@
-- KAT-04 / PR 13a: Fehlendes Spiel melden (Spec docs/specs/13-fehlendes-spiel-melden.md, Abschnitte 3 und 4).
-- Nur additiv. Enthält:
-- strip_leading_article, normalize_game_title Titel-Normalisierung über search_normalize (Spec 3.3)
-- report_day Berliner Kalendertag (Tageslimit, Krit. 7)
-- game_reports.result_seen_at Ergebnishinweis weggeklickt (Spec-F5)
-- Spaltenrechte und Policies auf game_reports Anlegen eigener Meldungen, Wegklicken des Hinweises
-- game_reports_daily_limit Trigger: höchstens 10 Nutzer-Meldungen je Berliner Kalendertag
-- find_similar_catalog_games RPC: sehr ähnliche Katalogspiele (Krit. 2)
-- report_missing_game RPC: Meldung + vorläufiger Eintrag + optional Listeneintrag
-- adopt_report_target RPC (security definer): Wechsel auf den Zieleintrag (Krit. 5)
-- Rechte-Regel (Spec #2 3.2): jede Funktion zuerst allen entziehen, danach nur die Grants aus Spec 4.1.
-- editions_cleared und admin_resolve_report stammen aus 2c (kein create or replace hier).
-- ---------------------------------------------------------------------------------------------
-- 1. Normalisierung (Spec 3.3). search_normalize (3a) entfernt nie Artikel; das tun nur diese beiden.
-- ---------------------------------------------------------------------------------------------
-- Erwartet bereits normalisierten Text (Kleinbuchstaben, ein Leerzeichen als Trenner). Entfernt
-- einmal ein führendes Artikelwort samt Leerzeichen; ein Titel, der nur aus dem Artikel besteht
-- ("das"), hat kein folgendes Leerzeichen und bleibt unverändert.
create function public.strip_leading_article(t text)
returns text
language sql
immutable
parallel safe
set search_path = ''
as $$
select regexp_replace(t,
'^(?:der|die|das|den|dem|des|ein|eine|einen|einem|einer|eines|the|a|an) ', '')
$$;
create function public.normalize_game_title(t text)
returns text
language sql
immutable
parallel safe
set search_path = ''
as $$
select coalesce(
nullif(public.strip_leading_article(public.search_normalize(t)), ''),
nullif(public.search_normalize(t), ''),
lower(btrim(t)))
$$;
-- Berliner Kalendertag eines Zeitpunkts. Eigene Funktion, damit pgTAP die Tagesgrenze mit festen
-- Zeitstempeln prüfen kann. Rein rechnend; authenticated braucht das Recht, weil der Trigger
-- game_reports_daily_limit als security invoker mit der Rolle des Einfügenden läuft.
create function public.report_day(p_at timestamptz)
returns date
language sql
immutable
set search_path = ''
as $$
select (p_at at time zone 'Europe/Berlin')::date
$$;
revoke execute on function public.strip_leading_article(text) from public, anon, authenticated, service_role;
revoke execute on function public.normalize_game_title(text) from public, anon, authenticated, service_role;
revoke execute on function public.report_day(timestamptz) from public, anon, authenticated, service_role;
grant execute on function public.strip_leading_article(text) to authenticated;
grant execute on function public.normalize_game_title(text) to authenticated;
grant execute on function public.report_day(timestamptz) to authenticated;
-- ---------------------------------------------------------------------------------------------
-- 2. game_reports: neue Spalte, Spaltenrechte, Policies (Spec 3.2)
-- ---------------------------------------------------------------------------------------------
alter table public.game_reports
add column result_seen_at timestamptz,
add constraint game_reports_result_seen_check check (result_seen_at is null or status <> 'open');
-- Bewusst gegenüber 2c erweitert: Nutzer legen eigene Meldungen an und ändern an eigenen, bereits
-- bearbeiteten Meldungen genau eine Spalte. Nicht schreibbar: id, origin, status, bgg_id,
-- target_game_id, resolved_at, editions_cleared, created_at, updated_at (42501); die Datenbank
-- setzt origin = 'user', status = 'open' und created_at = now() selbst. Kein delete-Recht.
grant insert (owner_id, title, publisher_name, year, link, provisional_game_id)
on public.game_reports to authenticated;
grant update (result_seen_at) on public.game_reports to authenticated;
create policy game_reports_insert_own on public.game_reports
for insert to authenticated
with check (
owner_id = (select auth.uid())
and origin = 'user'
and status = 'open'
and target_game_id is null
and provisional_game_id is not null
and exists (
select 1 from public.games g
where g.id = provisional_game_id
and g.visibility = 'private'
and g.owner_id = (select auth.uid())
and g.hidden_at is null
and g.merged_into_id is null
)
);
create policy game_reports_update_seen on public.game_reports
for update to authenticated
using (owner_id = (select auth.uid()) and status <> 'open')
with check (owner_id = (select auth.uid()) and status <> 'open');
-- ---------------------------------------------------------------------------------------------
-- 3. Tageslimit (Krit. 7, Klärung 27, N12)
-- Setzt READ COMMITTED voraus (Standard bei PostgREST): Die Zählung nach der Sperre sieht die
-- inzwischen bestätigten Meldungen der anderen Transaktion. Die Zahl 10 steht nur hier und in
-- report_missing_game (Vorprüfung); eine Änderung braucht eine neue Migration.
-- security invoker: Der Nutzer sieht per RLS alle eigenen Meldungen; Studio (postgres) umgeht RLS.
-- ---------------------------------------------------------------------------------------------
create function public.game_reports_daily_limit()
returns trigger
language plpgsql
set search_path = ''
as $$
begin
perform pg_advisory_xact_lock(hashtextextended('game_reports_limit:' || new.owner_id::text, 0));
if (select count(*) from public.game_reports r
where r.owner_id = new.owner_id and r.origin = 'user'
and public.report_day(r.created_at) = public.report_day(now())) >= 10 then
raise exception 'report_daily_limit' using errcode = 'P0001';
end if;
return new;
end;
$$;
-- Import-Meldungen (SAM-08) werden weder gezählt noch begrenzt (N12).
create trigger game_reports_daily_limit before insert on public.game_reports
for each row when (new.origin = 'user') execute function public.game_reports_daily_limit();
revoke execute on function public.game_reports_daily_limit() from public, anon, authenticated, service_role;
-- ---------------------------------------------------------------------------------------------
-- 4. RPC find_similar_catalog_games (Krit. 2, Klärung 15; Spec 3.3 und 4.3)
-- security invoker: es gelten die Policies des Aufrufers; gelesen wird über catalog_games (nur
-- sichtbare Katalogspiele). Kandidatentitel: Haupttitel (games.title_search) und alle alternativen
-- Titel (game_alt_titles.title_search). Regel A: gleicher Schlüssel, jedes Jahr. Regel B:
-- Trigramm-Ähnlichkeit >= 0.8 und Jahr +-1 (fehlt ein Jahr, ohne Jahresbedingung, Spec-F2).
-- extensions.similarity ausdrücklich, nicht der Operator % (der nutzt pg_trgm.similarity_threshold).
-- ---------------------------------------------------------------------------------------------
create function public.find_similar_catalog_games(p_title text, p_year integer default null)
returns table (
game_id uuid,
title text,
matched_title text,
year smallint,
publishers text[],
match_kind text,
similarity real
)
language sql
stable
set search_path = ''
as $$
with q as (
select public.normalize_game_title(btrim(p_title)) as nk
where p_title is not null
and char_length(btrim(p_title)) between 1 and 200
),
cand as (
select c.id as game_id, c.title as game_title, c.year as game_year,
k.cand_title, k.cand_key, k.ord
from public.catalog_games c
join public.games g on g.id = c.id
cross join lateral (
select g.title as cand_title,
coalesce(nullif(public.strip_leading_article(g.title_search), ''), g.title_search, lower(btrim(g.title))) as cand_key,
0 as ord
union all
select a.title,
coalesce(nullif(public.strip_leading_article(a.title_search), ''), a.title_search),
1
from public.game_alt_titles a
where a.game_id = g.id
) k
),
scored as (
select cd.*, (cd.cand_key = q.nk) as is_same,
extensions.similarity(cd.cand_key, q.nk) as sim
from cand cd cross join q
where q.nk <> ''
),
hit as (
select distinct on (s.game_id)
s.game_id, s.game_title, s.game_year, s.cand_title, s.is_same, s.sim
from scored s
where s.is_same
or (s.sim >= 0.8
and (p_year is null or s.game_year is null or abs(s.game_year - p_year) <= 1))
order by s.game_id, s.is_same desc, s.sim desc, s.ord, s.cand_title
)
select h.game_id,
h.game_title,
h.cand_title,
h.game_year,
coalesce((select array_agg(p.name order by p.name_search, p.name)
from public.game_publishers gp
join public.publishers p on p.id = gp.publisher_id
where gp.game_id = h.game_id), '{}'::text[]),
case when h.is_same then 'same_title' else 'similar_title' end,
case when h.is_same then 1.0::real else h.sim end
from hit h
order by h.is_same desc, h.sim desc, h.game_title, h.game_id
limit 5
$$;
revoke execute on function public.find_similar_catalog_games(text, integer) from public, anon, authenticated, service_role;
grant execute on function public.find_similar_catalog_games(text, integer) to authenticated;
-- ---------------------------------------------------------------------------------------------
-- 5. RPC report_missing_game (Krit. 1, 2, 3, 7, 8; Spec 4.2)
-- security invoker: RLS, Spaltenrechte und Trigger gelten weiter. Eine Transaktion: jeder Fehler
-- rollt alles zurück (kein verwaistes vorläufiges Spiel). p_year ist integer und wird selbst
-- geprüft (smallint würde bei großen Werten schon beim Aufruf mit 22003 scheitern).
-- ---------------------------------------------------------------------------------------------
create function public.report_missing_game(
p_title text,
p_publisher_name text default null,
p_year integer default null,
p_link text default null,
p_list text default null,
p_confirmed_distinct boolean default false
)
returns jsonb
language plpgsql
set search_path = ''
as $$
declare
v_uid uuid := auth.uid();
v_title text;
v_publisher text;
v_link text;
v_game_id uuid;
v_report_id uuid;
v_item_id uuid;
v_constraint text;
begin
if v_uid is null then
raise exception 'not_authenticated' using errcode = 'P0001';
end if;
v_title := nullif(btrim(p_title, E' \t\r\n'), '');
v_publisher := nullif(btrim(p_publisher_name, E' \t\r\n'), '');
v_link := nullif(btrim(p_link, E' \t\r\n'), '');
if v_title is null then
raise exception 'report_title_required' using errcode = 'P0001';
end if;
if char_length(v_title) > 200 then
raise exception 'report_title_too_long' using errcode = 'P0001';
end if;
if char_length(v_publisher) > 200 then
raise exception 'report_publisher_too_long' using errcode = 'P0001';
end if;
if p_year is not null and p_year not between 1800 and 2100 then
raise exception 'report_year_invalid' using errcode = 'P0001';
end if;
if v_link is not null and (char_length(v_link) > 500 or v_link !~ '^https?://') then
raise exception 'report_link_invalid' using errcode = 'P0001';
end if;
if p_list is not null and p_list not in ('owned', 'wishlist', 'shopping') then
raise exception 'report_list_invalid' using errcode = 'P0001';
end if;
-- Vorprüfung vor der Ähnlichkeit: Der Nutzer soll nicht erst Kandidaten bestätigen und dann am
-- Limit scheitern. Maßgeblich bleibt der Trigger (Sperre).
if (select count(*) from public.game_reports r
where r.owner_id = v_uid and r.origin = 'user'
and public.report_day(r.created_at) = public.report_day(now())) >= 10 then
raise exception 'report_daily_limit' using errcode = 'P0001';
end if;
if not coalesce(p_confirmed_distinct, false)
and exists (select 1 from public.find_similar_catalog_games(v_title, p_year)) then
raise exception 'report_similar_exists' using errcode = 'P0001';
end if;
insert into public.games (owner_id, title, year, visibility)
values (v_uid, v_title, p_year, 'private')
returning id into v_game_id;
begin
insert into public.game_reports (owner_id, title, publisher_name, year, link, provisional_game_id)
values (v_uid, v_title, v_publisher, p_year, v_link, v_game_id)
returning id into v_report_id;
exception when unique_violation then
get stacked diagnostics v_constraint = constraint_name;
if v_constraint = 'game_reports_provisional_game_id_key' then
raise exception 'report_provisional_in_use' using errcode = 'P0001';
end if;
raise;
end;
if p_list is not null then
insert into public.collection_items (owner_id, game_id, list)
values (v_uid, v_game_id, p_list)
returning id into v_item_id;
end if;
return jsonb_build_object(
'report_id', v_report_id,
'provisional_game_id', v_game_id,
'collection_item_id', v_item_id);
end;
$$;
revoke execute on function public.report_missing_game(text, text, integer, text, text, boolean)
from public, anon, authenticated, service_role;
grant execute on function public.report_missing_game(text, text, integer, text, text, boolean) to authenticated;
-- ---------------------------------------------------------------------------------------------
-- 6. RPC adopt_report_target (Krit. 5; Spec 4.4)
-- security definer, Eigentümer postgres. Begründung: Nutzer dürfen merged_into_id und hidden_at nicht
-- schreiben und repoint_game_references nicht ausführen; der Wechsel auf den Zieleintrag
-- ("abgelehnt mit Verweis") entscheidet aber der Nutzer selbst (Spec #2 Spec-F3).
-- Sicherheit: search_path leer (alles schema-qualifiziert); der Aufrufer wird nur über auth.uid()
-- bestimmt (nie über current_user); Meldung und vorläufiger Eintrag müssen dem Aufrufer gehören;
-- Ziel muss ein sichtbares Katalogspiel sein; hängen fremde Verweise am vorläufigen Eintrag, wird
-- abgebrochen. Innerhalb der Funktion ist current_user = postgres: Sie darf deshalb KEINE
-- admin_*-Funktion aufrufen (deren Rollenprüfung würde bestehen), weder jetzt noch später.
-- Sperrreihenfolge wie admin_resolve_report: erst die Meldung, dann beide Spiele nach id.
-- result_seen_at bleibt unverändert, damit der Hinweis am Zieleintrag erscheint, bis "Verstanden".
-- ---------------------------------------------------------------------------------------------
create function public.adopt_report_target(p_report uuid)
returns jsonb
language plpgsql
security definer
set search_path = ''
as $$
declare
v_uid uuid := auth.uid();
rep public.game_reports;
prov public.games;
result jsonb;
begin
if v_uid is null then
raise exception 'not_authenticated' using errcode = 'P0001';
end if;
-- Meldung des Aufrufers sperren; fremde und unbekannte Meldungen liefern denselben Fehler.
select * into rep from public.game_reports r
where r.id = p_report and r.owner_id = v_uid
for update;
if not found then
raise exception 'report_not_found' using errcode = 'P0001';
end if;
if rep.status <> 'rejected' or rep.target_game_id is null then
raise exception 'report_not_adoptable' using errcode = 'P0001';
end if;
if rep.provisional_game_id is null then
raise exception 'report_no_provisional' using errcode = 'P0001';
end if;
perform 1 from public.games g
where g.id in (rep.provisional_game_id, rep.target_game_id)
order by g.id for update;
select * into prov from public.games g where g.id = rep.provisional_game_id;
if not found or prov.visibility <> 'private' or prov.owner_id is distinct from v_uid then
raise exception 'report_not_found' using errcode = 'P0001';
end if;
if prov.merged_into_id is not null then
raise exception 'report_already_adopted' using errcode = 'P0001';
end if;
if not exists (
select 1 from public.games g
where g.id = rep.target_game_id 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;
-- Verweise mit fremdem oder fehlendem Eigentümer (null zählt als fremd) brechen ab.
if exists (select 1 from public.collection_items x where x.game_id = prov.id and x.owner_id is distinct from v_uid)
or exists (select 1 from public.favorite_games x where x.game_id = prov.id and x.profile_id is distinct from v_uid)
or exists (select 1 from public.plays x where x.game_id = prov.id and x.owner_id is distinct from v_uid)
or exists (select 1 from public.score_sheets x where x.game_id = prov.id and x.owner_id is distinct from v_uid) then
raise exception 'report_foreign_references' using errcode = 'P0001';
end if;
-- privat -> Katalog: nichts wird kopiert; gleichnamige Varianten abgebildet, sonst null (RB1, N15)
result := public.repoint_game_references(prov.id, rep.target_game_id);
-- merged_into_id verlangt hidden_at (Check), deshalb in einem Statement.
update public.games g
set merged_into_id = rep.target_game_id, hidden_at = coalesce(g.hidden_at, now())
where g.id = prov.id;
update public.game_reports r
set editions_cleared = coalesce((result ->> 'variants_cleared')::integer, 0)
where r.id = rep.id;
return result;
end;
$$;
revoke execute on function public.adopt_report_target(uuid) from public, anon, authenticated, service_role;
grant execute on function public.adopt_report_target(uuid) to authenticated;

View file

@ -12,6 +12,12 @@ insert into auth.users (id, email, raw_user_meta_data) values
('22222222-2222-2222-2222-222222222222', 'ben@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Ben"}'), ('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"}'); ('33333333-3333-3333-3333-333333333333', 'cara@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Cara"}');
-- Ab #13 (KAT-04) begrenzt der Trigger game_reports_daily_limit Nutzer-Meldungen auf 10 je Berliner Tag und
-- Nutzer, auch für Einfügungen als postgres. Die Fixtures dieser Datei legen für einzelne Nutzer mehr als 10
-- Meldungen "von heute" an; das Limit prüft 10_game_reports_user.test.sql. Hier ist der Trigger abgeschaltet
-- (DDL, mit dem Rollback am Ende zurückgenommen).
alter table public.game_reports disable trigger game_reports_daily_limit;
-- --------------------------------------------------------------------------------------------- -- ---------------------------------------------------------------------------------------------
-- Tabellenrechte (KAT-05 Kriterium 9) -- Tabellenrechte (KAT-05 Kriterium 9)
-- --------------------------------------------------------------------------------------------- -- ---------------------------------------------------------------------------------------------

View file

@ -0,0 +1,723 @@
-- KAT-04 (PR 13a): Fehlendes Spiel melden. Normalisierung (strip_leading_article, normalize_game_title),
-- report_day, Rechte/Policies/Trigger auf game_reports, find_similar_catalog_games, report_missing_game,
-- adopt_report_target (security definer), Ergebnis nach admin_resolve_report, Konto-Löschen.
-- Läuft auch mit eingespieltem Startbestand: eigene Fixtures mit festen IDs (Präfix a130/f13) und
-- unwahrscheinlichen Titeln (zyxq...), damit der Katalog nicht hineinspielt.
-- Fixtures legt postgres an; "als Nutzer" läuft wirklich als Rolle authenticated mit gesetztem
-- request.jwt.claims. Regel dieser Datei: Jede Verweigerung steht neben einem Positivfall.
begin;
select plan(206);
insert into auth.users (id, email, raw_user_meta_data) values
('a1300000-0000-4000-8000-0000000000a1', 'kat04-a@example.test', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"KA"}'),
('a1300000-0000-4000-8000-0000000000b2', 'kat04-b@example.test', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"KB"}'),
('a1300000-0000-4000-8000-0000000000c3', 'kat04-c@example.test', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"KC"}'),
('a1300000-0000-4000-8000-0000000000d4', 'kat04-d@example.test', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"KD"}'),
('a1300000-0000-4000-8000-0000000000e1', 'kat04-l1@example.test', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"L1"}'),
('a1300000-0000-4000-8000-0000000000e2', 'kat04-l2@example.test', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"L2"}'),
('a1300000-0000-4000-8000-0000000000e3', 'kat04-l3@example.test', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"L3"}'),
('a1300000-0000-4000-8000-0000000000e4', 'kat04-y@example.test', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"KY"}');
-- ---------------------------------------------------------------------------------------------
-- 1. Funktionen: Rechte, Attribute (Spec 4.1)
-- ---------------------------------------------------------------------------------------------
create temp table fn (sig text primary key, names text, granted boolean);
grant all on fn to public;
insert into fn values
('public.strip_leading_article(text)', 'strip_leading_article', true),
('public.normalize_game_title(text)', 'normalize_game_title', true),
('public.report_day(timestamptz)', 'report_day', true),
('public.find_similar_catalog_games(text, integer)', 'find_similar_catalog_games', true),
('public.report_missing_game(text, text, integer, text, text, boolean)', 'report_missing_game', true),
('public.adopt_report_target(uuid)', 'adopt_report_target', true),
('public.game_reports_daily_limit()', 'game_reports_daily_limit', false);
select is_empty($$select f.sig, r from fn f, unnest(array['anon', 'service_role']) r
where has_function_privilege(r, f.sig::regprocedure, 'execute')$$,
'Sicherheit Kriterium 2: anon und service_role dürfen keine der neuen Funktionen ausführen');
select is_empty($$select f.sig from fn f
where has_function_privilege('authenticated', f.sig::regprocedure, 'execute') <> f.granted$$,
'KAT-04: authenticated darf genau die RPCs und Hilfsfunktionen ausführen, die Trigger-Funktion nicht');
select is_empty($$select f.sig from fn f join pg_proc p on p.oid = f.sig::regprocedure
where p.proacl is null or exists (select 1 from aclexplode(p.proacl) a where a.grantee = 0)$$,
'KAT-04: keine neue Funktion ist für PUBLIC ausführbar (ACL gesetzt, kein Eintrag für grantee 0)');
select is_empty($$select f.sig from fn f join pg_proc p on p.oid = f.sig::regprocedure
where p.prosecdef <> (f.sig = 'public.adopt_report_target(uuid)')$$,
'KAT-04: nur adopt_report_target ist security definer');
select is((select pg_get_userbyid(proowner) from pg_proc where oid = 'public.adopt_report_target(uuid)'::regprocedure), 'postgres'::name,
'KAT-04 Kriterium 5: adopt_report_target gehört postgres');
select is_empty($$select f.sig from fn f join pg_proc p on p.oid = f.sig::regprocedure
where p.proconfig is null or not (p.proconfig @> array['search_path=""'])$$,
'KAT-04: alle neuen Funktionen haben search_path = "" als Funktionseinstellung');
-- ---------------------------------------------------------------------------------------------
-- 2. Normalisierung (Krit. 2, Klärung 15)
-- ---------------------------------------------------------------------------------------------
select is(public.strip_leading_article('die siedler von catan'), 'siedler von catan', 'KAT-04 Kriterium 2: führendes "die" entfällt');
select is(public.strip_leading_article('the crew'), 'crew', 'KAT-04 Kriterium 2: führendes "the" entfällt');
select is(public.strip_leading_article('a game of thrones'), 'game of thrones', 'KAT-04 Kriterium 2: führendes "a" entfällt');
select is(public.strip_leading_article('an apple'), 'apple', 'KAT-04 Kriterium 2: führendes "an" entfällt');
select is(public.strip_leading_article('einem haus'), 'haus', 'KAT-04 Kriterium 2: auch "einem" entfällt');
select is(public.strip_leading_article('das'), 'das', 'KAT-04 Kriterium 2: ein Titel nur aus dem Artikel bleibt unverändert');
select is(public.strip_leading_article('theater der welt'), 'theater der welt', 'KAT-04 Kriterium 2: kein Artikel, wenn das Wort nur damit beginnt');
select is(public.strip_leading_article('herr der ringe'), 'herr der ringe', 'KAT-04 Kriterium 2: Artikel mitten im Titel bleiben');
select is(public.strip_leading_article('der die das'), 'die das', 'KAT-04 Kriterium 2: nur einmal ein Artikel');
select is(public.strip_leading_article(null), null, 'KAT-04: strip_leading_article(null) ist null');
select is(public.normalize_game_title('Die Siedler von Catan'), public.normalize_game_title('siedler von catan'),
'KAT-04 Kriterium 2: "Die Siedler von Catan" und "siedler von catan" ergeben denselben Wert');
select is(public.normalize_game_title('CATAN!'), public.normalize_game_title('Catan'), 'KAT-04 Kriterium 2: Großbuchstaben und Sonderzeichen sind egal');
select is(public.normalize_game_title('The Crew'), public.normalize_game_title('Crew'), 'KAT-04 Kriterium 2: "The Crew" = "Crew"');
select is(public.normalize_game_title('Das'), 'das', 'KAT-04 Kriterium 2: ein Titel nur aus einem Artikel bleibt nicht leer');
select is(public.normalize_game_title('The'), 'the', 'KAT-04 Kriterium 2: auch "The" allein bleibt nicht leer');
select is(public.normalize_game_title('Mölkky'), public.normalize_game_title('Molkky'), 'KAT-04 Kriterium 2: Umlaute werden über search_normalize gefaltet');
select is(public.normalize_game_title('日本語'), '日本語', 'KAT-04 Kriterium 2: nicht-lateinischer Titel fällt auf lower(btrim) zurück');
select is(public.normalize_game_title(' !!! '), '!!!', 'KAT-04 Kriterium 2: Titel nur aus Sonderzeichen fällt auf lower(btrim) zurück');
select is(public.normalize_game_title(null), null, 'KAT-04: normalize_game_title(null) ist null');
select is(public.report_day('2026-01-15 23:30:00+00'), '2026-01-16'::date, 'KAT-04 Kriterium 7: 23:30 UTC im Winter ist schon der nächste Berliner Tag');
select is(public.report_day('2026-01-15 22:59:00+00'), '2026-01-15'::date, 'KAT-04 Kriterium 7: 22:59 UTC im Winter ist noch derselbe Tag');
select is(public.report_day('2026-07-15 21:59:00+00'), '2026-07-15'::date, 'KAT-04 Kriterium 7: 21:59 UTC im Sommer ist noch derselbe Tag');
select is(public.report_day('2026-07-15 22:00:00+00'), '2026-07-16'::date, 'KAT-04 Kriterium 7: 22:00 UTC im Sommer ist der nächste Tag');
-- ---------------------------------------------------------------------------------------------
-- 3. Rechte, Policies, Trigger auf game_reports (Spec 3.2)
-- ---------------------------------------------------------------------------------------------
select is((select array_agg(a.attname::text order by a.attname) from pg_attribute a
where a.attrelid = 'public.game_reports'::regclass and a.attnum > 0 and not a.attisdropped
and has_column_privilege('authenticated', 'public.game_reports', a.attname, 'insert')),
array['link', 'owner_id', 'provisional_game_id', 'publisher_name', 'title', 'year'],
'Sicherheit Kriterium 1: authenticated darf beim Anlegen genau owner_id, title, publisher_name, year, link, provisional_game_id setzen');
select is((select array_agg(a.attname::text order by a.attname) from pg_attribute a
where a.attrelid = 'public.game_reports'::regclass and a.attnum > 0 and not a.attisdropped
and has_column_privilege('authenticated', 'public.game_reports', a.attname, 'update')),
array['result_seen_at'],
'KAT-04 Kriterium 8: authenticated darf genau result_seen_at ändern');
select is_empty($$select a.attname, p from pg_attribute a, unnest(array['select', 'insert', 'update', 'references']) p
where a.attrelid = 'public.game_reports'::regclass and a.attnum > 0 and not a.attisdropped
and has_column_privilege('anon', 'public.game_reports', a.attname, p)$$,
'Sicherheit Kriterium 2: anon hat auf keine Spalte von game_reports ein Recht');
select is_empty($$select p from unnest(array['delete', 'truncate']) p where has_table_privilege('authenticated', 'public.game_reports', p)$$,
'Sicherheit Kriterium 1: authenticated darf Meldungen nicht löschen');
select is((select array_agg(policyname::text order by policyname) from pg_policies where tablename = 'game_reports' and cmd in ('INSERT', 'UPDATE')),
array['game_reports_insert_own', 'game_reports_update_seen'], 'KAT-04: genau eine Insert- und eine Update-Policy');
select ok((select pg_get_triggerdef(t.oid) ~ 'BEFORE INSERT ON public.game_reports FOR EACH ROW WHEN \(\(?new.origin = ''user''::text\)?\)'
from pg_trigger t where t.tgname = 'game_reports_daily_limit' and t.tgrelid = 'public.game_reports'::regclass),
'KAT-04 Kriterium 7: der Limit-Trigger läuft vor dem Insert und nur für origin = user');
-- ---------------------------------------------------------------------------------------------
-- 4. Katalog-Fixtures (postgres)
-- ---------------------------------------------------------------------------------------------
insert into public.games (id, title, year, visibility) values
('f13c0000-0000-4000-8000-000000000001', 'Quorblaxwitz Towers', 2020, 'catalog'),
('f13c0000-0000-4000-8000-000000000002', 'Die Zyxqwvland Saga', 2018, 'catalog'),
('f13c0000-0000-4000-8000-000000000003', 'Sortzyxq Reihenfolge', 2000, 'catalog'),
('f13c0000-0000-4000-8000-000000000004', 'Sortzyxq Reihenfolg', 2000, 'catalog'),
('f13c0000-0000-4000-8000-000000000005', 'Sortzyxq Reihenfol', 2000, 'catalog'),
('f13c0000-0000-4000-8000-00000000000d', 'Verstecktzyxq Spiel', 2015, 'catalog'),
('f13c0000-0000-4000-8000-00000000000e', 'Verstecktzyxq Spiel', 2015, 'catalog'),
('f13c0000-0000-4000-8000-00000000000f', 'Verstecktzyxq Spiel', 2015, 'catalog'),
('f13c0000-0000-4000-8000-000000000010', 'Jahrlosxyz Spiel', null, 'catalog'),
('f13c0000-0000-4000-8000-000000000011', '日本語ゲーム', 2010, 'catalog'),
('f13c0000-0000-4000-8000-000000000012', 'Das', 2010, 'catalog'),
('f13c0000-0000-4000-8000-000000000013', 'Adoptzyxq Ziel', 2021, 'catalog'),
('f13c0000-0000-4000-8000-000000000014', 'Adoptzyxq Verstecktes Ziel', 2021, 'catalog'),
('f13c0000-0000-4000-8000-000000000015', 'Adoptzyxq Zusammengefuehrtes Ziel', 2021, 'catalog'),
('f13c0000-0000-4000-8000-000000000017', repeat('x', 200), 2001, 'catalog');
insert into public.games (id, title, year, visibility)
select ('f13c0000-0000-4000-8000-' || lpad(to_hex(5 + i), 12, '0'))::uuid, 'Limitzyxq Spiel', 2005, 'catalog'
from generate_series(1, 7) i;
update public.games set hidden_at = now() where id in ('f13c0000-0000-4000-8000-00000000000e', 'f13c0000-0000-4000-8000-000000000014');
update public.games set hidden_at = now(), merged_into_id = 'f13c0000-0000-4000-8000-00000000000d'
where id in ('f13c0000-0000-4000-8000-00000000000f');
update public.games set hidden_at = now(), merged_into_id = 'f13c0000-0000-4000-8000-000000000013'
where id = 'f13c0000-0000-4000-8000-000000000015';
insert into public.game_alt_titles (game_id, title, locale) values
('f13c0000-0000-4000-8000-000000000002', 'The Zyxqwvland Chronicle', 'en');
insert into public.publishers (id, name) values
('f13c0000-0000-4000-8000-0000000000a1', 'Zyxq Verlag B'),
('f13c0000-0000-4000-8000-0000000000a2', 'Zyxq Verlag A');
insert into public.game_publishers (game_id, publisher_id) values
('f13c0000-0000-4000-8000-000000000001', 'f13c0000-0000-4000-8000-0000000000a1'),
('f13c0000-0000-4000-8000-000000000001', 'f13c0000-0000-4000-8000-0000000000a2');
insert into public.game_variants (id, game_id, name) values
('f13c0000-0000-4000-8000-0000000000b1', 'f13c0000-0000-4000-8000-000000000013', 'Standard');
-- private Spiele mit dem Titel des sichtbaren Katalogspiels: Bens und Annas eigener vorläufiger Eintrag
insert into public.games (id, title, year, visibility, owner_id) values
('f13a0000-0000-4000-8000-000000000001', 'Verstecktzyxq Spiel', 2015, 'private', 'a1300000-0000-4000-8000-0000000000b2'),
('f13a0000-0000-4000-8000-000000000002', 'Verstecktzyxq Spiel', 2015, 'private', 'a1300000-0000-4000-8000-0000000000a1');
-- Schwellen vorab nachrechnen, damit die Trigramm-Fälle wirklich an der Grenze von 0,8 liegen
select cmp_ok(extensions.similarity('quorblaxwitz towers', 'quorblaxwitz towe'), '>=', 0.8::real, 'Vorbedingung: "towe" liegt knapp über 0,8');
select cmp_ok(extensions.similarity('quorblaxwitz towers', 'quorblaxwitz tow'), '<', 0.8::real, 'Vorbedingung: "tow" liegt unter 0,8');
select cmp_ok(extensions.similarity('quorblaxwitz towers', 'quorblaxwitz tow'), '>=', 0.7::real, 'Vorbedingung: "tow" liegt über 0,7 (fängt die Schwelle 0,7)');
-- ---------------------------------------------------------------------------------------------
-- 5. find_similar_catalog_games (Krit. 2)
-- ---------------------------------------------------------------------------------------------
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000b2","role":"authenticated"}', true);
set local role authenticated;
select results_eq($$select game_id, title, matched_title, year, publishers, match_kind, similarity
from public.find_similar_catalog_games('Quorblaxwitz Towers', 2020)$$,
$$values ('f13c0000-0000-4000-8000-000000000001'::uuid, 'Quorblaxwitz Towers', 'Quorblaxwitz Towers', 2020::smallint,
array['Zyxq Verlag A', 'Zyxq Verlag B'], 'same_title', 1.0::real)$$,
'KAT-04 Kriterium 2: gleicher Titel liefert Treffer mit Haupttitel, Jahr, sortierten Verlagen, same_title und Ähnlichkeit 1');
select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Towers', 2030)), 1::bigint,
'KAT-04 Kriterium 2: gleicher normalisierter Titel gilt unabhängig vom Jahr');
select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Towers', null)), 1::bigint,
'KAT-04 Kriterium 2: gleicher Titel ohne Jahr trifft');
select is((select count(*) from public.find_similar_catalog_games('The QUORBLAXWITZ-TOWERS!', 1999)), 1::bigint,
'KAT-04 Kriterium 2: Artikel, Großbuchstaben und Sonderzeichen werden ignoriert');
select is((select count(*) from public.find_similar_catalog_games(' Quorblaxwitz Towers ', 2020)), 1::bigint,
'KAT-04 Kriterium 2: Leerraum am Rand ändert nichts');
select results_eq($$select match_kind, similarity > 0.8 and similarity < 1 from public.find_similar_catalog_games('Quorblaxwitz Towe', 2021)$$,
$$values ('similar_title', true)$$,
'KAT-04 Kriterium 2: Ähnlichkeit >= 0,8 bei Jahr + 1 trifft als similar_title mit echter Ähnlichkeit');
select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Towe', 2019)), 1::bigint,
'KAT-04 Kriterium 2: Ähnlichkeit >= 0,8 bei Jahr - 1 trifft');
select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Towe', 2020)), 1::bigint,
'KAT-04 Kriterium 2: Ähnlichkeit >= 0,8 bei gleichem Jahr trifft');
select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Towe', 2022)), 0::bigint,
'KAT-04 Kriterium 2: Ähnlichkeit >= 0,8 bei Jahr + 2 trifft nicht');
select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Towe', 2018)), 0::bigint,
'KAT-04 Kriterium 2: Ähnlichkeit >= 0,8 bei Jahr - 2 trifft nicht');
select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Towe', null)), 1::bigint,
'KAT-04 Kriterium 2 (Spec-F2): ohne Jahr gilt die Trigramm-Regel ohne Jahresbedingung');
select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Tow', 2020)), 0::bigint,
'KAT-04 Kriterium 2: Ähnlichkeit < 0,8 trifft nicht, auch bei gleichem Jahr');
select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Tow', null)), 0::bigint,
'KAT-04 Kriterium 2: Ähnlichkeit < 0,8 trifft auch ohne Jahr nicht');
select is((select count(*) from public.find_similar_catalog_games('Jahrlosxyz Spie', 2020)), 1::bigint,
'KAT-04 Kriterium 2 (Spec-F2): fehlt das Jahr am Katalogspiel, gilt die Trigramm-Regel ohne Jahresbedingung');
select is((select count(*) from public.find_similar_catalog_games('Jahrlosxyz Spi', 2020)), 0::bigint,
'KAT-04 Kriterium 2: auch ohne Katalogjahr zählt die Schwelle 0,8');
-- alternative Titel und Haupttitel mit Artikel
select results_eq($$select game_id, matched_title, match_kind from public.find_similar_catalog_games('Zyxqwvland Chronicle', 1900)$$,
$$values ('f13c0000-0000-4000-8000-000000000002'::uuid, 'The Zyxqwvland Chronicle', 'same_title')$$,
'KAT-04 Kriterium 2: Treffer über title_search des alternativen Titels (matched_title), Artikel am alternativen Titel ignoriert');
select results_eq($$select title, matched_title, match_kind from public.find_similar_catalog_games('zyxqwvland saga', 1900)$$,
$$values ('Die Zyxqwvland Saga', 'Die Zyxqwvland Saga', 'same_title')$$,
'KAT-04 Kriterium 2: Treffer über den Haupttitel (games.title_search), Artikel am Haupttitel ignoriert');
select results_eq($$select matched_title, match_kind from public.find_similar_catalog_games('Zyxqwvland Chronicl', 2019)$$,
$$values ('The Zyxqwvland Chronicle', 'similar_title')$$,
'KAT-04 Kriterium 2: Trigramm-Treffer über den alternativen Titel (Jahr + 1 zum Spiel)');
select is((select count(*) from public.find_similar_catalog_games('Zyxqwvland Chronicl', 2021)), 0::bigint,
'KAT-04 Kriterium 2: Trigramm-Treffer über den alternativen Titel gilt das Jahr des Spiels (± 2 trifft nicht)');
-- Sonderfälle der Normalisierung
select is((select count(*) from public.find_similar_catalog_games('日本語ゲーム', null)), 1::bigint,
'KAT-04 Kriterium 2: Titel ohne normalisierbare Zeichen trifft über den Rückfall lower(btrim)');
select is((select count(*) from public.find_similar_catalog_games('das', null)), 1::bigint,
'KAT-04 Kriterium 2: ein Titel nur aus einem Artikel trifft das gleichnamige Spiel');
-- nicht sichtbare Spiele (hidden, merged, privat von Ben und Anna) treffen nicht; das sichtbare Spiel schon
select results_eq($$select game_id from public.find_similar_catalog_games('Verstecktzyxq Spiel', 2015)$$,
$$values ('f13c0000-0000-4000-8000-00000000000d'::uuid)$$,
'KAT-04 Kriterium 2: nur das sichtbare Katalogspiel trifft, nicht ausgeblendete, zusammengeführte oder private Spiele (auch nicht Bens und das eigene)');
-- Eingabegrenzen, Reihenfolge, Höchstzahl
select is((select count(*) from public.find_similar_catalog_games(repeat('x', 200), null)), 1::bigint, 'KAT-04 Kriterium 2: Titel mit genau 200 Zeichen ist erlaubt und trifft das gleichlange Katalogspiel');
select is((select count(*) from public.find_similar_catalog_games(repeat('x', 201), null)), 0::bigint, 'KAT-04 Kriterium 2: Titel über 200 Zeichen liefert eine leere Menge (obwohl dieselben Trigramme ein Katalogspiel träfen)');
select is((select count(*) from public.find_similar_catalog_games(' ', null)), 0::bigint, 'KAT-04 Kriterium 2: leerer Titel liefert eine leere Menge');
select is((select count(*) from public.find_similar_catalog_games(null, null)), 0::bigint, 'KAT-04 Kriterium 2: Titel null liefert eine leere Menge');
select is((select count(*) from public.find_similar_catalog_games('!!!', null)), 0::bigint, 'KAT-04 Kriterium 2: Titel nur aus Sonderzeichen liefert keinen Treffer');
select is((select count(*) from public.find_similar_catalog_games('Limitzyxq Spiel', 2005)), 5::bigint,
'KAT-04 Kriterium 2: höchstens 5 Treffer');
select results_eq($$select game_id, match_kind from public.find_similar_catalog_games('Sortzyxq Reihenfolge', 2000)$$,
$$values ('f13c0000-0000-4000-8000-000000000003'::uuid, 'same_title'),
('f13c0000-0000-4000-8000-000000000004'::uuid, 'similar_title'),
('f13c0000-0000-4000-8000-000000000005'::uuid, 'similar_title')$$,
'KAT-04 Kriterium 2: same_title zuerst, danach nach Ähnlichkeit absteigend');
reset role;
-- ---------------------------------------------------------------------------------------------
-- 6. report_missing_game (Krit. 1, 2, 3, 7, 8)
-- ---------------------------------------------------------------------------------------------
-- nicht angemeldet
select set_config('request.jwt.claims', '{"role":"authenticated"}', true);
set local role authenticated;
select throws_ok($$select public.report_missing_game('Zyxqtest Ohne Anmeldung')$$, 'P0001', 'not_authenticated',
'Sicherheit Kriterium 2: ohne Nutzerkennung (auth.uid() null) wird nichts gemeldet');
reset role;
select set_config('request.jwt.claims', '{"role":"anon"}', true);
set local role anon;
select throws_ok($$select public.report_missing_game('Zyxqtest Anon')$$, '42501', null, 'Sicherheit Kriterium 2: anon darf report_missing_game nicht aufrufen');
select throws_ok($$select * from public.find_similar_catalog_games('Zyxqtest Anon')$$, '42501', null, 'Sicherheit Kriterium 2: anon darf find_similar_catalog_games nicht aufrufen');
select throws_ok($$select public.adopt_report_target('a1300000-0000-4000-8000-0000000000ff')$$, '42501', null, 'Sicherheit Kriterium 2: anon darf adopt_report_target nicht aufrufen');
select throws_ok($$select public.normalize_game_title('x')$$, '42501', null, 'Sicherheit Kriterium 2: anon darf normalize_game_title nicht aufrufen');
select throws_ok($$select public.strip_leading_article('x')$$, '42501', null, 'Sicherheit Kriterium 2: anon darf strip_leading_article nicht aufrufen');
select throws_ok($$select public.report_day(now())$$, '42501', null, 'Sicherheit Kriterium 2: anon darf report_day nicht aufrufen');
select throws_ok($$select 1 from public.game_reports$$, '42501', null, 'Sicherheit Kriterium 2: anon darf keine Meldungen lesen');
select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000a1', 'x', null)$$, '42501', null,
'Sicherheit Kriterium 2: anon darf keine Meldung anlegen');
select throws_ok($$update public.game_reports set result_seen_at = now()$$, '42501', null, 'Sicherheit Kriterium 2: anon darf keine Meldung ändern');
reset role;
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000a1","role":"authenticated"}', true);
set local role authenticated;
select results_eq($$select game_id from public.find_similar_catalog_games('Verstecktzyxq Spiel', 2015)$$,
$$values ('f13c0000-0000-4000-8000-00000000000d'::uuid)$$,
'KAT-04 Kriterium 2 (Spec-F7): auch Annas eigener vorläufiger Eintrag mit demselben Titel trifft nicht');
-- Validierung (jeder Fehler mit Code, nichts bleibt zurück)
select throws_ok($$select public.report_missing_game('')$$, 'P0001', 'report_title_required', 'KAT-04 Kriterium 1: leerer Titel');
select throws_ok($$select public.report_missing_game(' ')$$, 'P0001', 'report_title_required', 'KAT-04 Kriterium 1: Titel nur aus Leerzeichen');
select throws_ok($$select public.report_missing_game(null)$$, 'P0001', 'report_title_required', 'KAT-04 Kriterium 1: Titel null');
select throws_ok($$select public.report_missing_game(repeat('t', 201))$$, 'P0001', 'report_title_too_long', 'KAT-04 Kriterium 1: Titel über 200 Zeichen');
select throws_ok($$select public.report_missing_game('Zyxqtest Verlag', repeat('v', 201))$$, 'P0001', 'report_publisher_too_long', 'KAT-04 Kriterium 1: Verlag über 200 Zeichen');
select throws_ok($$select public.report_missing_game('Zyxqtest Jahr', null, 1799)$$, 'P0001', 'report_year_invalid', 'KAT-04 Kriterium 1: Jahr 1799');
select throws_ok($$select public.report_missing_game('Zyxqtest Jahr', null, 2101)$$, 'P0001', 'report_year_invalid', 'KAT-04 Kriterium 1: Jahr 2101');
select throws_ok($$select public.report_missing_game('Zyxqtest Jahr', null, 99999)$$, 'P0001', 'report_year_invalid', 'KAT-04 Kriterium 1: Jahr 99999 ergibt report_year_invalid, kein 22003');
select throws_ok($$select public.report_missing_game('Zyxqtest Link', null, null, 'ftp://example.org')$$, 'P0001', 'report_link_invalid', 'KAT-04 Kriterium 1: Link mit anderem Schema');
select throws_ok($$select public.report_missing_game('Zyxqtest Link', null, null, 'example.org/spiel')$$, 'P0001', 'report_link_invalid', 'KAT-04 Kriterium 1: Link ohne Schema');
select throws_ok($$select public.report_missing_game('Zyxqtest Link', null, null, 'HTTP://example.org')$$, 'P0001', 'report_link_invalid', 'KAT-04 Kriterium 1: Schema in Großbuchstaben (wie der Tabellen-Check)');
select throws_ok($$select public.report_missing_game('Zyxqtest Link', null, null, 'https://' || repeat('l', 493))$$, 'P0001', 'report_link_invalid', 'KAT-04 Kriterium 1: Link mit 501 Zeichen');
select throws_ok($$select public.report_missing_game('Zyxqtest Liste', null, null, null, 'foo')$$, 'P0001', 'report_list_invalid', 'KAT-04 Kriterium 3: unbekannte Liste');
select throws_ok($$select public.report_missing_game('Zyxqtest Liste', null, null, null, '')$$, 'P0001', 'report_list_invalid', 'KAT-04 Kriterium 3: leere Liste ist keine Liste');
select is((select count(*) from public.game_reports) + (select count(*) from public.games where owner_id = 'a1300000-0000-4000-8000-0000000000a1' and title like 'Zyxqtest%'),
0::bigint, 'KAT-04 Kriterium 1: nach allen Validierungsfehlern bleibt nichts zurück');
select lives_ok($$select public.report_missing_game('Zyxqtest Grenzwerte ' || repeat('t', 180), repeat('v', 200), 1800, 'https://' || repeat('l', 492))$$,
'KAT-04 Kriterium 1: Grenzwerte (Titel 200, Verlag 200, Jahr 1800, Link 500) sind gültig');
select lives_ok($$select public.report_missing_game('Zyxqtest Jahr 2100', null, 2100, 'http://example.org')$$, 'KAT-04 Kriterium 1: Jahr 2100 und http-Link sind gültig');
reset role;
select is((select count(*) from public.game_reports where owner_id = 'a1300000-0000-4000-8000-0000000000a1'), 2::bigint, 'Vorbedingung: Anna hat 2 Meldungen von den Grenzwert-Fällen');
delete from public.game_reports where owner_id = 'a1300000-0000-4000-8000-0000000000a1';
delete from public.games where owner_id = 'a1300000-0000-4000-8000-0000000000a1' and title like 'Zyxqtest%';
-- Erfolg ohne Liste: Trimmen, Felder, Rückgabe
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000a1","role":"authenticated"}', true);
set local role authenticated;
create temp table res1 as select public.report_missing_game(' Zyxqtest Eins ', ' ', 2019, ' https://example.org/eins ') as j;
grant all on res1 to public;
select ok((select j ? 'report_id' and j ? 'provisional_game_id' and j ? 'collection_item_id' and j->'collection_item_id' = 'null'::jsonb from res1),
'KAT-04 Kriterium 1: Rückgabe enthält report_id, provisional_game_id und collection_item_id null');
select results_eq($$select r.owner_id, r.origin, r.status, r.title, r.publisher_name, r.year, r.link, r.target_game_id, r.resolved_at, r.result_seen_at,
r.provisional_game_id = (res1.j->>'provisional_game_id')::uuid, r.created_at >= now() - interval '1 minute'
from public.game_reports r, res1 where r.id = (res1.j->>'report_id')::uuid$$,
$$values ('a1300000-0000-4000-8000-0000000000a1'::uuid, 'user', 'open', 'Zyxqtest Eins', null::text, 2019::smallint,
'https://example.org/eins', null::uuid, null::timestamptz, null::timestamptz, true, true)$$,
'KAT-04 Kriterium 1 und 8: Meldung gespeichert (getrimmt, Leerverlag null), Status offen, Herkunft user, kein Ziel');
select results_eq($$select g.title, g.year, g.visibility, g.owner_id, g.description, g.image_path, g.bgg_id, g.hidden_at, g.merged_into_id
from public.games g, res1 where g.id = (res1.j->>'provisional_game_id')::uuid$$,
$$values ('Zyxqtest Eins', 2019::smallint, 'private', 'a1300000-0000-4000-8000-0000000000a1'::uuid, null::text, null::text, null::integer, null::timestamptz, null::uuid)$$,
'KAT-04 Kriterium 3: vorläufiger Eintrag ist privat, gehört Anna und hat nur Titel und Jahr');
select is((select count(*) from public.collection_items where owner_id = 'a1300000-0000-4000-8000-0000000000a1'), 0::bigint,
'KAT-04 Kriterium 3: ohne p_list entsteht kein Sammlungseintrag');
-- Erfolg mit Liste
create temp table res2 as select public.report_missing_game('Zyxqtest Zwei', 'Zyxq Eigenverlag', null, null, 'wishlist') as j;
grant all on res2 to public;
select results_eq($$select c.owner_id, c.game_id = (res2.j->>'provisional_game_id')::uuid, c.list, c.variant_id, c.id = (res2.j->>'collection_item_id')::uuid
from public.collection_items c, res2 where c.id = (res2.j->>'collection_item_id')::uuid$$,
$$values ('a1300000-0000-4000-8000-0000000000a1'::uuid, true, 'wishlist', null::uuid, true)$$,
'KAT-04 Kriterium 3: p_list legt den Sammlungseintrag auf den vorläufigen Eintrag an, ohne Edition');
select is((select publisher_name from public.game_reports where id = (select (j->>'report_id')::uuid from res2)), 'Zyxq Eigenverlag',
'KAT-04 Kriterium 1: der Verlag wird als Freitext gespeichert');
create temp table res_own as select public.report_missing_game('Zyxqtest Besitz', null, null, null, 'owned') as j;
grant all on res_own to public;
select is((select list from public.collection_items where id = (select (j->>'collection_item_id')::uuid from res_own)), 'owned', 'KAT-04 Kriterium 3: Liste owned');
select lives_ok($$select public.report_missing_game('Zyxqtest Einkauf', null, null, null, 'shopping')$$, 'KAT-04 Kriterium 3: Liste shopping');
reset role;
-- Isolation: Ben sieht nichts von Anna
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000b2","role":"authenticated"}', true);
set local role authenticated;
select is((select count(*) from public.game_reports where title like 'Zyxqtest%'), 0::bigint, 'Sicherheit Kriterium 1: Ben sieht Annas Meldungen nicht');
select is((select count(*) from public.games where title like 'Zyxqtest%'), 0::bigint, 'Sicherheit Kriterium 3: Ben sieht Annas vorläufige Einträge nicht');
select is((select count(*) from public.collection_items where game_id in (select (j->>'provisional_game_id')::uuid from res1 union all select (j->>'provisional_game_id')::uuid from res2)), 0::bigint,
'Sicherheit Kriterium 3: Ben sieht Annas Listeneinträge nicht');
reset role;
-- Ähnlichkeitsprüfung beim Speichern (Krit. 2)
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000c3","role":"authenticated"}', true);
set local role authenticated;
select throws_ok($$select public.report_missing_game('Die Quorblaxwitz Towers', null, 2020)$$, 'P0001', 'report_similar_exists',
'KAT-04 Kriterium 2: ein sehr ähnlicher Katalogeintrag ohne Bestätigung wird nicht gespeichert');
select throws_ok($$select public.report_missing_game('Quorblaxwitz Towers', null, 2020, null, null, null)$$, 'P0001', 'report_similar_exists',
'KAT-04 Kriterium 2: p_confirmed_distinct = null gilt als nicht bestätigt');
select is((select count(*) from public.game_reports) + (select count(*) from public.games where owner_id = 'a1300000-0000-4000-8000-0000000000c3'), 0::bigint,
'KAT-04 Kriterium 2: ohne Bestätigung bleibt nichts zurück');
select throws_ok($$select public.report_missing_game('Quorblaxwitz Towe', null, 2021)$$, 'P0001', 'report_similar_exists',
'KAT-04 Kriterium 2: die RPC prüft die Trigramm-Regel mit dem gemeldeten Jahr (± 1 trifft)');
select lives_ok($$select public.report_missing_game('Quorblaxwitz Towe', null, 2022)$$,
'KAT-04 Kriterium 2: die RPC prüft die Trigramm-Regel mit dem gemeldeten Jahr (± 2 trifft nicht, ohne Bestätigung gespeichert)');
select lives_ok($$select public.report_missing_game('Die Quorblaxwitz Towers', null, 2020, null, null, true)$$,
'KAT-04 Kriterium 2: mit ausdrücklicher Bestätigung wird gespeichert');
select is((select count(*) from public.game_reports where title in ('Die Quorblaxwitz Towers', 'Quorblaxwitz Towe')), 2::bigint, 'KAT-04 Kriterium 2: die bestätigte und die nicht ähnliche Meldung sind gespeichert');
reset role;
-- 23505 am eindeutigen Index auf provisional_game_id: über die RPC nicht erreichbar (jeder Aufruf legt ein frisches Spiel an).
-- Ein Hilfstrigger lenkt den Insert in game_reports auf ein schon verwendetes Spiel um.
create function pg_temp.reuse_prov() returns trigger language plpgsql as $$
begin
new.provisional_game_id := (select r.provisional_game_id from public.game_reports r where r.title = 'Die Quorblaxwitz Towers' and r.owner_id = new.owner_id);
return new;
end $$;
create trigger zz_reuse_prov before insert on public.game_reports for each row execute function pg_temp.reuse_prov();
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000c3","role":"authenticated"}', true);
set local role authenticated;
select throws_ok($$select public.report_missing_game('Zyxqtest Doppelt belegt')$$, 'P0001', 'report_provisional_in_use',
'KAT-04 Kriterium 3: ein schon verwendeter vorläufiger Eintrag wird als report_provisional_in_use gemeldet');
select is((select count(*) from public.games where title = 'Zyxqtest Doppelt belegt'), 0::bigint, 'KAT-04 Kriterium 3: der Konflikt lässt kein vorläufiges Spiel zurück');
reset role;
drop trigger zz_reuse_prov on public.game_reports;
-- Rollback bei Fehler im Listeneintrag: ein Hilfstrigger lässt den Insert in collection_items scheitern
create function pg_temp.fail_ci() returns trigger language plpgsql as $$ begin raise exception 'boom' using errcode = 'P0001'; end $$;
create trigger fail_ci before insert on public.collection_items for each row execute function pg_temp.fail_ci();
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000c3","role":"authenticated"}', true);
set local role authenticated;
select throws_ok($$select public.report_missing_game('Zyxqtest Rollback', null, null, null, 'owned')$$, 'P0001', 'boom',
'Vorbedingung: der Listeneintrag scheitert');
select is((select count(*) from public.game_reports where title = 'Zyxqtest Rollback') + (select count(*) from public.games where title = 'Zyxqtest Rollback'), 0::bigint,
'KAT-04 Kriterium 3: scheitert der Listeneintrag, bleiben weder Meldung noch vorläufiges Spiel zurück');
reset role;
drop trigger fail_ci on public.collection_items;
-- ---------------------------------------------------------------------------------------------
-- 7. Tageslimit (Krit. 7)
-- ---------------------------------------------------------------------------------------------
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000e1","role":"authenticated"}', true);
set local role authenticated;
select lives_ok($$select public.report_missing_game('Tageslimit Meldung ' || i) from generate_series(1, 10) i$$,
'KAT-04 Kriterium 7: 10 Meldungen über report_missing_game sind erlaubt');
select throws_ok($$select public.report_missing_game('Tageslimit Meldung 11')$$, 'P0001', 'report_daily_limit', 'KAT-04 Kriterium 7: die 11. Meldung scheitert am Tageslimit');
select throws_ok($$select public.report_missing_game('Quorblaxwitz Towers', null, 2020)$$, 'P0001', 'report_daily_limit',
'KAT-04 Kriterium 7: das Limit wird vor der Ähnlichkeitsprüfung gemeldet');
select is((select count(*) from public.game_reports), 10::bigint, 'KAT-04 Kriterium 7: es bleiben genau 10 Meldungen');
select is((select count(*) from public.games where owner_id = 'a1300000-0000-4000-8000-0000000000e1'), 10::bigint, 'KAT-04 Kriterium 7: kein vorläufiges Spiel der abgelehnten Meldung bleibt übrig');
reset role;
-- Meldungen von gestern zählen nicht
insert into public.games (id, title, visibility, owner_id)
select ('f13b0000-0000-4000-8000-' || lpad(to_hex(i), 12, '0'))::uuid, 'Gestern ' || i, 'private', 'a1300000-0000-4000-8000-0000000000e4'
from generate_series(1, 13) i;
insert into public.game_reports (owner_id, title, provisional_game_id, created_at)
select 'a1300000-0000-4000-8000-0000000000e4', 'Gestern ' || i, ('f13b0000-0000-4000-8000-' || lpad(to_hex(i), 12, '0'))::uuid,
-- created_at ist nach dem Anlegen unveränderlich: feste Zeitstempel gleich beim Einfügen (Nr. 1 und 2 an der Berliner Tagesgrenze)
case i when 1 then '2026-01-15 22:59:00+00'::timestamptz when 2 then '2026-01-15 23:30:00+00'::timestamptz else '2026-01-01 12:00:00+00'::timestamptz end
from generate_series(1, 10) i;
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000e4","role":"authenticated"}', true);
set local role authenticated;
select lives_ok($$select public.report_missing_game('Heute nach zehn Meldungen von gestern')$$, 'KAT-04 Kriterium 7: Meldungen von gestern zählen nicht');
reset role;
-- Grenze des Berliner Tages mit festem Zeitstempel: 23:30 UTC im Winter (00:30 am Folgetag in Berlin) zählt nicht zu 22:59 UTC desselben Tages
select is((select count(*) from public.game_reports where owner_id = 'a1300000-0000-4000-8000-0000000000e4' and public.report_day(created_at) = '2026-01-15'), 1::bigint,
'KAT-04 Kriterium 7: der Berliner Kalendertag trennt 22:59 und 23:30 UTC im Winter');
-- Direkter Insert als authenticated: Trigger zählt, report_day ist für authenticated ausführbar
insert into public.games (id, title, visibility, owner_id)
select ('f13b0000-0000-4000-8000-' || lpad(to_hex(100 + i), 12, '0'))::uuid, 'Direkt ' || i, 'private', 'a1300000-0000-4000-8000-0000000000e2'
from generate_series(1, 11) i;
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000e2","role":"authenticated"}', true);
set local role authenticated;
select lives_ok($$insert into public.game_reports (owner_id, title, provisional_game_id)
select 'a1300000-0000-4000-8000-0000000000e2', 'Direkt ' || i, ('f13b0000-0000-4000-8000-' || lpad(to_hex(100 + i), 12, '0'))::uuid from generate_series(1, 10) i$$,
'KAT-04 Kriterium 7: 10 direkte Inserts als authenticated sind erlaubt (der Trigger darf report_day ausführen)');
select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values
('a1300000-0000-4000-8000-0000000000e2', 'Direkt 11', 'f13b0000-0000-4000-8000-00000000006f')$$, 'P0001', 'report_daily_limit',
'KAT-04 Kriterium 7: auch der direkte 11. Insert scheitert am Limit');
reset role;
-- Import-Meldungen zählen nicht und werden nicht begrenzt (N12)
insert into public.game_reports (owner_id, origin, title, bgg_id)
select 'a1300000-0000-4000-8000-0000000000e3', 'import', 'Import ' || i, 9000 + i from generate_series(1, 12) i;
select is((select count(*) from public.game_reports where owner_id = 'a1300000-0000-4000-8000-0000000000e3' and origin = 'import'), 12::bigint,
'KAT-04 Kriterium 7 (N12): mehr als 10 Import-Meldungen werden nicht begrenzt');
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000e3","role":"authenticated"}', true);
set local role authenticated;
select lives_ok($$select public.report_missing_game('Zyxqtest nach Import ' || i) from generate_series(1, 10) i$$,
'KAT-04 Kriterium 7 (N12): Import-Meldungen zählen nicht auf das Limit des Nutzers');
select throws_ok($$select public.report_missing_game('Zyxqtest nach Import 11')$$, 'P0001', 'report_daily_limit',
'KAT-04 Kriterium 7: danach greift das Limit für Nutzer-Meldungen');
reset role;
-- Meldungen von Nutzer B zählen nicht für Nutzer A: Anna hat oben nur wenige, Ben (e1 hat 10) ändert nichts
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000a1","role":"authenticated"}', true);
set local role authenticated;
select lives_ok($$select public.report_missing_game('Zyxqtest Anna nach Limit anderer')$$, 'KAT-04 Kriterium 7: die Meldungen anderer Nutzer zählen nicht für Anna');
reset role;
-- ---------------------------------------------------------------------------------------------
-- 8. Direkter Zugriff auf game_reports: Policies und Spaltenrechte (Krit. 8, Sicherheit 1 und 3)
-- ---------------------------------------------------------------------------------------------
insert into public.games (id, title, visibility, owner_id, hidden_at, merged_into_id) values
('f13a0000-0000-4000-8000-000000000011', 'Direkt ok', 'private', 'a1300000-0000-4000-8000-0000000000c3', null, null),
('f13a0000-0000-4000-8000-000000000012', 'Direkt versteckt', 'private', 'a1300000-0000-4000-8000-0000000000c3', now(), null),
('f13a0000-0000-4000-8000-000000000013', 'Direkt zusammengeführt', 'private', 'a1300000-0000-4000-8000-0000000000c3', now(), 'f13c0000-0000-4000-8000-00000000000d'),
('f13a0000-0000-4000-8000-000000000014', 'Bens Privatspiel', 'private', 'a1300000-0000-4000-8000-0000000000b2', null, null),
('f13a0000-0000-4000-8000-000000000015', 'Direkt ok 2', 'private', 'a1300000-0000-4000-8000-0000000000c3', null, null);
-- Hilfsfunktion (läuft mit den Rechten des Aufrufers): result_seen_at setzen und die Zahl der geänderten Zeilen liefern.
-- (Ein datenändernder WITH-Block ist in einer Unterabfrage nicht erlaubt.)
create function pg_temp.mark_seen_all(p_at timestamptz) returns bigint language plpgsql as $$
declare n bigint;
begin
update public.game_reports set result_seen_at = p_at; -- bewusst ohne WHERE: nur die Update-Policy begrenzt die Zeilen
get diagnostics n = row_count;
return n;
end $$;
create function pg_temp.mark_seen(p_id uuid, p_at timestamptz) returns bigint language plpgsql as $$
declare n bigint;
begin
update public.game_reports set result_seen_at = p_at where p_id is null or id = p_id;
get diagnostics n = row_count;
return n;
end $$;
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000c3","role":"authenticated"}', true);
set local role authenticated;
select lives_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000c3', 'Direkt ok', 'f13a0000-0000-4000-8000-000000000011')$$,
'Sicherheit Kriterium 1: Cara legt direkt eine Meldung zu ihrem eigenen privaten Spiel an (Positivfall)');
select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000b2', 'Im Namen von Ben', 'f13a0000-0000-4000-8000-000000000014')$$, '42501', null,
'Sicherheit Kriterium 1: Cara kann keine Meldung für Ben anlegen');
select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000b2', 'Fremder Besitzer, eigenes Spiel', 'f13a0000-0000-4000-8000-000000000015')$$, '42501', null,
'Sicherheit Kriterium 1: owner_id eines anderen Nutzers wird abgelehnt, auch wenn das vorläufige Spiel Cara gehört');
select throws_ok($$insert into public.game_reports (owner_id, title) values ('a1300000-0000-4000-8000-0000000000c3', 'Ohne vorläufigen Eintrag')$$, '42501', null,
'Sicherheit Kriterium 1: eine Meldung ohne vorläufigen Eintrag wird abgelehnt');
select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000c3', 'Bens Spiel', 'f13a0000-0000-4000-8000-000000000014')$$, '42501', null,
'Sicherheit Kriterium 1: ein fremdes privates Spiel als vorläufiger Eintrag wird abgelehnt');
select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000c3', 'Katalogspiel', 'f13c0000-0000-4000-8000-000000000001')$$, '42501', null,
'Sicherheit Kriterium 1: ein Katalogspiel als vorläufiger Eintrag wird abgelehnt');
select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000c3', 'Versteckt', 'f13a0000-0000-4000-8000-000000000012')$$, '42501', null,
'Sicherheit Kriterium 1: ein ausgeblendetes Spiel als vorläufiger Eintrag wird abgelehnt');
select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000c3', 'Zusammengeführt', 'f13a0000-0000-4000-8000-000000000013')$$, '42501', null,
'Sicherheit Kriterium 1: ein zusammengeführtes Spiel als vorläufiger Eintrag wird abgelehnt');
select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000c3', 'Doppelt', 'f13a0000-0000-4000-8000-000000000011')$$, '23505', null,
'Sicherheit Kriterium 1: eine zweite Meldung zum selben vorläufigen Eintrag scheitert am eindeutigen Index');
select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id, status) values ('a1300000-0000-4000-8000-0000000000c3', 'S', 'f13a0000-0000-4000-8000-000000000015', 'approved')$$, '42501', null,
'KAT-04 Kriterium 8: status lässt sich beim Anlegen nicht setzen');
select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id, origin) values ('a1300000-0000-4000-8000-0000000000c3', 'S', 'f13a0000-0000-4000-8000-000000000015', 'import')$$, '42501', null,
'KAT-04 Kriterium 7: origin lässt sich beim Anlegen nicht setzen (Import-Meldungen umgehen das Limit nicht)');
select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id, created_at) values ('a1300000-0000-4000-8000-0000000000c3', 'S', 'f13a0000-0000-4000-8000-000000000015', '2026-01-01')$$, '42501', null,
'KAT-04 Kriterium 7: created_at lässt sich beim Anlegen nicht setzen');
select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id, target_game_id) values ('a1300000-0000-4000-8000-0000000000c3', 'S', 'f13a0000-0000-4000-8000-000000000015', 'f13c0000-0000-4000-8000-000000000001')$$, '42501', null,
'KAT-04 Kriterium 8: target_game_id lässt sich beim Anlegen nicht setzen');
select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id, resolved_at) values ('a1300000-0000-4000-8000-0000000000c3', 'S', 'f13a0000-0000-4000-8000-000000000015', now())$$, '42501', null,
'KAT-04 Kriterium 8: resolved_at lässt sich beim Anlegen nicht setzen');
select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id, bgg_id) values ('a1300000-0000-4000-8000-0000000000c3', 'S', 'f13a0000-0000-4000-8000-000000000015', 1)$$, '42501', null,
'KAT-04: bgg_id lässt sich beim Anlegen nicht setzen');
select throws_ok($$insert into public.game_reports (id, owner_id, title, provisional_game_id) values (gen_random_uuid(), 'a1300000-0000-4000-8000-0000000000c3', 'S', 'f13a0000-0000-4000-8000-000000000015')$$, '42501', null,
'KAT-04: id lässt sich beim Anlegen nicht setzen');
select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000c3', '', 'f13a0000-0000-4000-8000-000000000015')$$, '23514', null,
'KAT-04 Kriterium 1: ein leerer Titel scheitert am Tabellen-Check');
select throws_ok($$insert into public.game_reports (owner_id, title, year, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000c3', 'S', 1700, 'f13a0000-0000-4000-8000-000000000015')$$, '23514', null,
'KAT-04 Kriterium 1: ein Jahr außerhalb 1800-2100 scheitert am Tabellen-Check');
select throws_ok($$update public.game_reports set status = 'approved' where owner_id = 'a1300000-0000-4000-8000-0000000000c3'$$, '42501', null,
'KAT-04 Kriterium 8: status lässt sich nicht ändern');
select throws_ok($$update public.game_reports set title = 'Umbenannt' where owner_id = 'a1300000-0000-4000-8000-0000000000c3'$$, '42501', null,
'KAT-04: title lässt sich nicht ändern');
select throws_ok($$update public.game_reports set target_game_id = 'f13c0000-0000-4000-8000-000000000001' where owner_id = 'a1300000-0000-4000-8000-0000000000c3'$$, '42501', null,
'KAT-04 Kriterium 8: target_game_id lässt sich nicht ändern');
select throws_ok($$update public.game_reports set resolved_at = now() where owner_id = 'a1300000-0000-4000-8000-0000000000c3'$$, '42501', null,
'KAT-04 Kriterium 8: resolved_at lässt sich nicht ändern');
select throws_ok($$delete from public.game_reports where owner_id = 'a1300000-0000-4000-8000-0000000000c3'$$, '42501', null,
'Sicherheit Kriterium 1: eigene Meldungen lassen sich nicht löschen');
-- result_seen_at bei offener Meldung: 0 Zeilen
select is(pg_temp.mark_seen(null, now()), 0::bigint,
'KAT-04 Kriterium 8: result_seen_at an einer offenen Meldung ändert keine Zeile');
reset role;
select throws_ok($$update public.game_reports set result_seen_at = now() where owner_id = 'a1300000-0000-4000-8000-0000000000c3'$$, '23514', null,
'KAT-04 Kriterium 8: auch postgres kann result_seen_at bei einer offenen Meldung nicht setzen (Check)');
-- ---------------------------------------------------------------------------------------------
-- 9. Ergebnis nach admin_resolve_report (Krit. 4 und 6), result_seen_at
-- ---------------------------------------------------------------------------------------------
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000a1","role":"authenticated"}', true);
set local role authenticated;
create temp table res_ap as select public.report_missing_game('Freigabezyxq Neu', 'Freigabe Verlag', 2023, null, 'owned') as j;
grant all on res_ap to public;
create temp table res_rej as select public.report_missing_game('Ablehnzyxq Spiel', null, null, null, 'wishlist') as j;
grant all on res_rej to public;
update public.collection_items set note = 'Annas Notiz', acquired_on = '2025-05-05'
where id = (select (j->>'collection_item_id')::uuid from res_ap);
reset role;
insert into public.game_variants (id, game_id, name) values
('f13a0000-0000-4000-8000-0000000000b1', (select (j->>'provisional_game_id')::uuid from res_ap), 'Eigene Edition');
update public.collection_items set variant_id = 'f13a0000-0000-4000-8000-0000000000b1'
where id = (select (j->>'collection_item_id')::uuid from res_ap);
insert into public.games (id, title, year, visibility) values ('f13c0000-0000-4000-8000-000000000016', 'Freigabezyxq Neu', 2023, 'catalog');
select lives_ok($$select public.admin_resolve_report((select (j->>'report_id')::uuid from res_ap), 'approved', 'f13c0000-0000-4000-8000-000000000016')$$,
'Vorbedingung: der Admin gibt die Meldung frei');
select lives_ok($$select public.admin_resolve_report((select (j->>'report_id')::uuid from res_rej), 'rejected')$$,
'Vorbedingung: der Admin lehnt die zweite Meldung ohne Ziel ab');
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000a1","role":"authenticated"}', true);
set local role authenticated;
select results_eq($$select game_id, list, note, acquired_on, variant_id from public.collection_items where id = (select (j->>'collection_item_id')::uuid from res_ap)$$,
$$values ('f13c0000-0000-4000-8000-000000000016'::uuid, 'owned', 'Annas Notiz', '2025-05-05'::date, null::uuid)$$,
'KAT-04 Kriterium 4: der Katalogeintrag ersetzt den vorläufigen; Liste, Notiz und Kaufdatum bleiben, die Edition ohne gleichnamige Variante ist leer');
select results_eq($$select r.status, r.target_game_id, r.editions_cleared, r.result_seen_at from public.game_reports r where r.id = (select (j->>'report_id')::uuid from res_ap)$$,
$$values ('approved', 'f13c0000-0000-4000-8000-000000000016'::uuid, 1, null::timestamptz)$$,
'KAT-04 Kriterium 4: Meldung freigegeben, editions_cleared zählt die geleerte Edition, Hinweis noch ungesehen');
select is((select count(*) from public.game_variants where id = 'f13a0000-0000-4000-8000-0000000000b1'), 1::bigint,
'KAT-04 Kriterium 4: Annas private Variante bleibt für sie lesbar');
select results_eq($$select g.visibility, g.merged_into_id, g.hidden_at is not null from public.games g where g.id = (select (j->>'provisional_game_id')::uuid from res_ap)$$,
$$values ('private', 'f13c0000-0000-4000-8000-000000000016'::uuid, true)$$,
'KAT-04 Kriterium 4: der vorläufige Eintrag ist ausgeblendet und zeigt auf den Katalogeintrag, Anna liest ihn weiter');
select is(pg_temp.mark_seen((select (j->>'report_id')::uuid from res_ap), '2026-11-04 10:00:00+00'), 1::bigint,
'KAT-04 Kriterium 4: Anna kann den Hinweis an der freigegebenen Meldung wegklicken (result_seen_at)');
select is((select result_seen_at from public.game_reports where id = (select (j->>'report_id')::uuid from res_ap)), '2026-11-04 10:00:00+00'::timestamptz,
'KAT-04 Kriterium 4: result_seen_at ist gesetzt');
-- Krit. 6
select results_eq($$select r.status, r.target_game_id, r.provisional_game_id is not null from public.game_reports r where r.id = (select (j->>'report_id')::uuid from res_rej)$$,
$$values ('rejected', null::uuid, true)$$, 'KAT-04 Kriterium 6: abgelehnt ohne Ziel, der vorläufige Eintrag ist noch verknüpft');
select results_eq($$select g.visibility, g.merged_into_id, g.hidden_at from public.games g where g.id = (select (j->>'provisional_game_id')::uuid from res_rej)$$,
$$values ('private', null::uuid, null::timestamptz)$$, 'KAT-04 Kriterium 6: der vorläufige Eintrag bleibt privat, sichtbar und lesbar');
select is((select count(*) from public.collection_items where id = (select (j->>'collection_item_id')::uuid from res_rej) and game_id = (select (j->>'provisional_game_id')::uuid from res_rej)), 1::bigint,
'KAT-04 Kriterium 6: der Listeneintrag bleibt am vorläufigen Eintrag');
select is(pg_temp.mark_seen((select (j->>'report_id')::uuid from res_rej), now()), 1::bigint,
'KAT-04 Kriterium 6: auch an der abgelehnten Meldung lässt sich der Hinweis wegklicken');
reset role;
-- Fremder Nutzer kann nichts wegklicken
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000b2","role":"authenticated"}', true);
set local role authenticated;
select is(pg_temp.mark_seen((select (j->>'report_id')::uuid from res_ap), now()), 0::bigint,
'Sicherheit Kriterium 1: Ben kann bei fremden Meldungen nichts wegklicken (0 Zeilen)');
select is(pg_temp.mark_seen_all(now()), 0::bigint,
'Sicherheit Kriterium 1: auch ein Update ohne WHERE trifft bei Ben keine fremde Meldung (Update-Policy)');
reset role;
-- ---------------------------------------------------------------------------------------------
-- 10. adopt_report_target (Krit. 5)
-- ---------------------------------------------------------------------------------------------
-- R1: vollständiger Wechsel. Anna hat am vorläufigen Eintrag Sammlung, Favorit, Partien, Wertungsbogen, zwei Varianten.
insert into public.games (id, title, year, visibility, owner_id) values
('f13a0000-0000-4000-8000-000000000101', 'Adoptzyxq Eigen', 2021, 'private', 'a1300000-0000-4000-8000-0000000000a1');
insert into public.game_variants (id, game_id, name) values
('f13a0000-0000-4000-8000-0000000001b1', 'f13a0000-0000-4000-8000-000000000101', ' standard '),
('f13a0000-0000-4000-8000-0000000001b2', 'f13a0000-0000-4000-8000-000000000101', 'Prototyp');
insert into public.collection_items (id, owner_id, game_id, variant_id, list, note, acquired_on) values
('f13a0000-0000-4000-8000-0000000001c1', 'a1300000-0000-4000-8000-0000000000a1', 'f13a0000-0000-4000-8000-000000000101', 'f13a0000-0000-4000-8000-0000000001b2', 'owned', 'Notiz bleibt', '2025-01-02');
insert into public.favorite_games (id, profile_id, game_id, position) values
('f13a0000-0000-4000-8000-0000000001d1', 'a1300000-0000-4000-8000-0000000000a1', 'f13a0000-0000-4000-8000-000000000101', 1);
insert into public.plays (id, owner_id, game_id, variant_id, played_at, client_id) values
('f13a0000-0000-4000-8000-0000000001e1', 'a1300000-0000-4000-8000-0000000000a1', 'f13a0000-0000-4000-8000-000000000101', 'f13a0000-0000-4000-8000-0000000001b1', '2026-02-01', 'f13a0000-0000-4000-8000-0000000001e1'),
('f13a0000-0000-4000-8000-0000000001e2', 'a1300000-0000-4000-8000-0000000000a1', 'f13a0000-0000-4000-8000-000000000101', 'f13a0000-0000-4000-8000-0000000001b2', '2026-02-02', 'f13a0000-0000-4000-8000-0000000001e2');
insert into public.score_sheets (id, owner_id, game_id, name) values
('f13a0000-0000-4000-8000-0000000001f1', 'a1300000-0000-4000-8000-0000000000a1', 'f13a0000-0000-4000-8000-000000000101', 'Bogen');
insert into public.game_reports (id, owner_id, title, provisional_game_id, status, target_game_id, resolved_at, created_at) values
('f13a0000-0000-4000-8000-000000000a01', 'a1300000-0000-4000-8000-0000000000a1', 'Adoptzyxq Eigen', 'f13a0000-0000-4000-8000-000000000101', 'rejected', 'f13c0000-0000-4000-8000-000000000013', now(), '2026-02-01');
-- Hilfsfälle für die Fehlerpfade
insert into public.games (id, title, visibility, owner_id) values
('f13a0000-0000-4000-8000-000000000102', 'Adopt offen', 'private', 'a1300000-0000-4000-8000-0000000000a1'),
('f13a0000-0000-4000-8000-000000000103', 'Adopt ohne Ziel', 'private', 'a1300000-0000-4000-8000-0000000000a1'),
('f13a0000-0000-4000-8000-000000000104', 'Adopt freigegeben', 'private', 'a1300000-0000-4000-8000-0000000000a1'),
('f13a0000-0000-4000-8000-000000000105', 'Adopt Ziel versteckt', 'private', 'a1300000-0000-4000-8000-0000000000a1'),
('f13a0000-0000-4000-8000-000000000106', 'Adopt Ziel zusammengefuehrt', 'private', 'a1300000-0000-4000-8000-0000000000a1'),
('f13a0000-0000-4000-8000-000000000107', 'Adopt Bens Eintrag', 'private', 'a1300000-0000-4000-8000-0000000000b2'),
('f13a0000-0000-4000-8000-0000000001a1', 'Adopt fremd Partie', 'private', 'a1300000-0000-4000-8000-0000000000a1'),
('f13a0000-0000-4000-8000-0000000001a2', 'Adopt Partie ohne Eigentuemer', 'private', 'a1300000-0000-4000-8000-0000000000a1'),
('f13a0000-0000-4000-8000-0000000001a3', 'Adopt fremd Sammlung', 'private', 'a1300000-0000-4000-8000-0000000000a1'),
('f13a0000-0000-4000-8000-0000000001a4', 'Adopt fremd Favorit', 'private', 'a1300000-0000-4000-8000-0000000000a1'),
('f13a0000-0000-4000-8000-0000000001a5', 'Adopt fremd Bogen', 'private', 'a1300000-0000-4000-8000-0000000000a1');
insert into public.game_reports (id, owner_id, title, provisional_game_id, status, target_game_id, resolved_at, created_at) values
('f13a0000-0000-4000-8000-000000000a02', 'a1300000-0000-4000-8000-0000000000a1', 'offen', 'f13a0000-0000-4000-8000-000000000102', 'open', null, null, '2026-02-01'),
('f13a0000-0000-4000-8000-000000000a03', 'a1300000-0000-4000-8000-0000000000a1', 'ohne Ziel', 'f13a0000-0000-4000-8000-000000000103', 'rejected', null, now(), '2026-02-01'),
('f13a0000-0000-4000-8000-000000000a04', 'a1300000-0000-4000-8000-0000000000a1', 'freigegeben', 'f13a0000-0000-4000-8000-000000000104', 'approved', 'f13c0000-0000-4000-8000-000000000013', now(), '2026-02-01'),
('f13a0000-0000-4000-8000-000000000a05', 'a1300000-0000-4000-8000-0000000000a1', 'ohne vorläufigen Eintrag', null, 'rejected', 'f13c0000-0000-4000-8000-000000000013', now(), '2026-02-01'),
('f13a0000-0000-4000-8000-000000000a06', 'a1300000-0000-4000-8000-0000000000a1', 'Ziel versteckt', 'f13a0000-0000-4000-8000-000000000105', 'rejected', 'f13c0000-0000-4000-8000-000000000014', now(), '2026-02-01'),
('f13a0000-0000-4000-8000-000000000a07', 'a1300000-0000-4000-8000-0000000000a1', 'Ziel zusammengeführt', 'f13a0000-0000-4000-8000-000000000106', 'rejected', 'f13c0000-0000-4000-8000-000000000015', now(), '2026-02-01'),
('f13a0000-0000-4000-8000-000000000a08', 'a1300000-0000-4000-8000-0000000000a1', 'Eintrag gehört Ben', 'f13a0000-0000-4000-8000-000000000107', 'rejected', 'f13c0000-0000-4000-8000-000000000013', now(), '2026-02-01'),
('f13a0000-0000-4000-8000-000000000a09', 'a1300000-0000-4000-8000-0000000000a1', 'Eintrag ist Katalog', 'f13c0000-0000-4000-8000-000000000001', 'rejected', 'f13c0000-0000-4000-8000-000000000013', now(), '2026-02-01'),
('f13a0000-0000-4000-8000-000000000a11', 'a1300000-0000-4000-8000-0000000000a1', 'fremde Partie', 'f13a0000-0000-4000-8000-0000000001a1', 'rejected', 'f13c0000-0000-4000-8000-000000000013', now(), '2026-02-01'),
('f13a0000-0000-4000-8000-000000000a12', 'a1300000-0000-4000-8000-0000000000a1', 'Partie ohne Eigentümer', 'f13a0000-0000-4000-8000-0000000001a2', 'rejected', 'f13c0000-0000-4000-8000-000000000013', now(), '2026-02-01'),
('f13a0000-0000-4000-8000-000000000a13', 'a1300000-0000-4000-8000-0000000000a1', 'fremde Sammlung', 'f13a0000-0000-4000-8000-0000000001a3', 'rejected', 'f13c0000-0000-4000-8000-000000000013', now(), '2026-02-01'),
('f13a0000-0000-4000-8000-000000000a14', 'a1300000-0000-4000-8000-0000000000a1', 'fremder Favorit', 'f13a0000-0000-4000-8000-0000000001a4', 'rejected', 'f13c0000-0000-4000-8000-000000000013', now(), '2026-02-01'),
('f13a0000-0000-4000-8000-000000000a15', 'a1300000-0000-4000-8000-0000000000a1', 'fremder Bogen', 'f13a0000-0000-4000-8000-0000000001a5', 'rejected', 'f13c0000-0000-4000-8000-000000000013', now(), '2026-02-01');
insert into public.plays (id, owner_id, game_id, played_at, client_id) values
('f13a0000-0000-4000-8000-0000000001e3', 'a1300000-0000-4000-8000-0000000000b2', 'f13a0000-0000-4000-8000-0000000001a1', '2026-02-03', 'f13a0000-0000-4000-8000-0000000001e3'),
('f13a0000-0000-4000-8000-0000000001e4', null, 'f13a0000-0000-4000-8000-0000000001a2', '2026-02-04', 'f13a0000-0000-4000-8000-0000000001e4');
insert into public.collection_items (owner_id, game_id) values ('a1300000-0000-4000-8000-0000000000b2', 'f13a0000-0000-4000-8000-0000000001a3');
insert into public.favorite_games (profile_id, game_id, position) values ('a1300000-0000-4000-8000-0000000000b2', 'f13a0000-0000-4000-8000-0000000001a4', 1);
insert into public.score_sheets (owner_id, game_id, name) values ('a1300000-0000-4000-8000-0000000000b2', 'f13a0000-0000-4000-8000-0000000001a5', 'Bogen Ben');
-- Fremde und ungültige Aufrufe
select set_config('request.jwt.claims', '{"role":"authenticated"}', true);
set local role authenticated;
select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a01')$$, 'P0001', 'not_authenticated',
'Sicherheit Kriterium 2: ohne Nutzerkennung (auth.uid() null) wird nichts übernommen');
reset role;
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000b2","role":"authenticated"}', true);
set local role authenticated;
select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a01')$$, 'P0001', 'report_not_found',
'Sicherheit Kriterium 1: Ben kann Annas Meldung nicht übernehmen');
select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-00000000ffff')$$, 'P0001', 'report_not_found',
'KAT-04 Kriterium 5: unbekannte Meldung (gleicher Fehler wie fremde)');
select throws_ok($$select public.adopt_report_target(null)$$, 'P0001', 'report_not_found', 'KAT-04 Kriterium 5: Meldung null');
select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a08')$$, 'P0001', 'report_not_found',
'Sicherheit Kriterium 3: Ben kann Annas Meldung nicht mit seinem eigenen privaten Spiel als vorläufigem Eintrag übernehmen');
reset role;
select is((select merged_into_id from public.games where id = 'f13a0000-0000-4000-8000-000000000107'), null::uuid,
'Sicherheit Kriterium 3: Bens Spiel wurde dabei nicht zusammengeführt');
select is((select merged_into_id from public.games where id = 'f13a0000-0000-4000-8000-000000000101'), null::uuid,
'Sicherheit Kriterium 1: Bens Versuch hat Annas vorläufigen Eintrag nicht verändert');
select is((select game_id from public.collection_items where id = 'f13a0000-0000-4000-8000-0000000001c1'), 'f13a0000-0000-4000-8000-000000000101'::uuid,
'Sicherheit Kriterium 1: Bens Versuch hat Annas Sammlung nicht umgehängt');
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000a1","role":"authenticated"}', true);
set local role authenticated;
select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a02')$$, 'P0001', 'report_not_adoptable', 'KAT-04 Kriterium 5: offene Meldung');
select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a03')$$, 'P0001', 'report_not_adoptable', 'KAT-04 Kriterium 6: abgelehnt ohne Ziel gibt es nichts zu übernehmen');
select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a04')$$, 'P0001', 'report_not_adoptable', 'KAT-04 Kriterium 5: freigegebene Meldung');
select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a05')$$, 'P0001', 'report_no_provisional', 'KAT-04 Kriterium 5: vorläufiger Eintrag fehlt (vom Nutzer gelöscht)');
select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a06')$$, 'P0001', 'report_target_not_listed', 'KAT-04 Kriterium 5: Ziel ist ausgeblendet');
select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a07')$$, 'P0001', 'report_target_not_listed', 'KAT-04 Kriterium 5: Ziel ist zusammengeführt');
select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a08')$$, 'P0001', 'report_not_found', 'Sicherheit Kriterium 3: der vorläufige Eintrag gehört einem anderen Nutzer');
select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a09')$$, 'P0001', 'report_not_found', 'Sicherheit Kriterium 3: der vorläufige Eintrag ist ein Katalogspiel');
select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a11')$$, 'P0001', 'report_foreign_references', 'Sicherheit Kriterium 3: Partie eines anderen Eigentümers am vorläufigen Eintrag');
select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a12')$$, 'P0001', 'report_foreign_references', 'Sicherheit Kriterium 3: Partie ohne Eigentümer (null zählt als fremd)');
select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a13')$$, 'P0001', 'report_foreign_references', 'Sicherheit Kriterium 3: Sammlungseintrag eines anderen Nutzers');
select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a14')$$, 'P0001', 'report_foreign_references', 'Sicherheit Kriterium 3: Favorit eines anderen Nutzers');
select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a15')$$, 'P0001', 'report_foreign_references', 'Sicherheit Kriterium 3: Wertungsbogen eines anderen Nutzers');
reset role;
select is((select count(*) from public.games where id::text like 'f13a0000-0000-4000-8000-0000000001a%' and merged_into_id is null and hidden_at is null), 5::bigint,
'Sicherheit Kriterium 3: nach allen Fehlern ist keiner der geprüften vorläufigen Einträge verändert');
select is((select count(*) from public.plays where id in ('f13a0000-0000-4000-8000-0000000001e3', 'f13a0000-0000-4000-8000-0000000001e4') and game_id::text like 'f13a0000-0000-4000-8000-0000000001a%'), 2::bigint,
'Sicherheit Kriterium 3: fremde Partien wurden nicht umgehängt');
-- Erfolg
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000a1","role":"authenticated"}', true);
set local role authenticated;
create temp table res_adopt as select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a01') as j;
grant all on res_adopt to public;
select results_eq($$select (j->>'collection_items')::int, (j->>'favorite_games')::int, (j->>'plays')::int, (j->>'score_sheets')::int,
(j->>'variants_copied')::int, (j->>'variants_mapped')::int, (j->>'variants_cleared')::int from res_adopt$$,
$$values (1, 1, 2, 1, 0, 1, 2)$$,
'KAT-04 Kriterium 5: Rückgabe zählt Sammlung, Favorit, Partien, Wertungsbogen; keine Variante kopiert, eine abgebildet, zwei geleert');
select results_eq($$select game_id, list, note, acquired_on, variant_id from public.collection_items where id = 'f13a0000-0000-4000-8000-0000000001c1'$$,
$$values ('f13c0000-0000-4000-8000-000000000013'::uuid, 'owned', 'Notiz bleibt', '2025-01-02'::date, null::uuid)$$,
'KAT-04 Kriterium 5: Sammlungseintrag zeigt aufs Ziel, Liste/Notiz/Kaufdatum bleiben, die nicht passende Edition ist geleert');
select results_eq($$select id, game_id, variant_id from public.plays where owner_id = 'a1300000-0000-4000-8000-0000000000a1' and id in ('f13a0000-0000-4000-8000-0000000001e1', 'f13a0000-0000-4000-8000-0000000001e2') order by id$$,
$$values ('f13a0000-0000-4000-8000-0000000001e1'::uuid, 'f13c0000-0000-4000-8000-000000000013'::uuid, 'f13c0000-0000-4000-8000-0000000000b1'::uuid),
('f13a0000-0000-4000-8000-0000000001e2'::uuid, 'f13c0000-0000-4000-8000-000000000013'::uuid, null::uuid)$$,
'KAT-04 Kriterium 5: Partien zeigen aufs Ziel, die gleichnamige Edition wird abgebildet, die andere geleert');
select is((select game_id from public.favorite_games where id = 'f13a0000-0000-4000-8000-0000000001d1'), 'f13c0000-0000-4000-8000-000000000013'::uuid, 'KAT-04 Kriterium 5: Favorit zeigt aufs Ziel');
select is((select game_id from public.score_sheets where id = 'f13a0000-0000-4000-8000-0000000001f1'), 'f13c0000-0000-4000-8000-000000000013'::uuid, 'KAT-04 Kriterium 5: Wertungsbogen zeigt aufs Ziel');
select results_eq($$select visibility, merged_into_id, hidden_at is not null from public.games where id = 'f13a0000-0000-4000-8000-000000000101'$$,
$$values ('private', 'f13c0000-0000-4000-8000-000000000013'::uuid, true)$$,
'KAT-04 Kriterium 5: der vorläufige Eintrag ist ausgeblendet und zeigt aufs Ziel, bleibt privat');
select results_eq($$select status, target_game_id, result_seen_at, editions_cleared from public.game_reports where id = 'f13a0000-0000-4000-8000-000000000a01'$$,
$$values ('rejected', 'f13c0000-0000-4000-8000-000000000013'::uuid, null::timestamptz, 2)$$,
'KAT-04 Kriterium 5: Status bleibt rejected, result_seen_at bleibt null, editions_cleared ist gesetzt');
select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a01')$$, 'P0001', 'report_already_adopted', 'KAT-04 Kriterium 5: ein zweiter Wechsel scheitert');
reset role;
-- ---------------------------------------------------------------------------------------------
-- 11. Datenschutz: Konto löschen (QUE-04, Klärung 10)
-- ---------------------------------------------------------------------------------------------
select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000d4","role":"authenticated"}', true);
set local role authenticated;
select lives_ok($$select public.report_missing_game('Zyxqtest Danas Spiel', null, null, null, 'owned')$$, 'Vorbedingung: Dana meldet ein Spiel');
reset role;
select is((select count(*) from public.game_reports where owner_id = 'a1300000-0000-4000-8000-0000000000d4'), 1::bigint, 'Vorbedingung: Dana hat eine Meldung');
select lives_ok($$delete from auth.users where id = 'a1300000-0000-4000-8000-0000000000d4'$$, 'QUE-04: Danas Konto lässt sich löschen');
select is((select count(*) from public.game_reports where owner_id = 'a1300000-0000-4000-8000-0000000000d4')
+ (select count(*) from public.games where owner_id = 'a1300000-0000-4000-8000-0000000000d4')
+ (select count(*) from public.collection_items where owner_id = 'a1300000-0000-4000-8000-0000000000d4'), 0::bigint,
'QUE-04: Meldungen, vorläufige Spiele und Listeneinträge sind mit dem Konto gelöscht');
select cmp_ok((select count(*) from public.game_reports where owner_id = 'a1300000-0000-4000-8000-0000000000a1'), '>', 0::bigint, 'QUE-04: Anna hat weiterhin ihre Meldungen');
select * from finish();
rollback;