diff --git a/docs/katalog-pflege.md b/docs/katalog-pflege.md index 5d0b200..c6934dd 100644 --- a/docs/katalog-pflege.md +++ b/docs/katalog-pflege.md @@ -2,9 +2,9 @@ Dieses Dokument beschreibt, wie der Admin (Chris) den Katalog in R1 pflegt: ausschließlich über Supabase Studio bzw. `psql`, es gibt keine Admin-Oberfläche in der App (Klärungen 25/44). Grundlage ist die Spec `docs/specs/2-katalog-daten.md`. -Stand: PR 2b (Datenmodell, Startbestand, Ausblenden, Zusammenführen). Der Abschnitt **Meldungen bearbeiten** (PR 2c) kommt mit dem jeweiligen PR dazu. +Stand: PR 2c (Datenmodell, Startbestand, Ausblenden, Zusammenführen, Meldungen bearbeiten). -**Die SQL-Bausteine hier laufen unverändert in den pgTAP-Tests** (Abschnitte 3 und 4 in `supabase/tests/database/06_catalog_foundation.test.sql`, Abschnitt 5 in `07_catalog_merge.test.sql`; Kommentare mit Abschnittsverweis). Ändert sich ein Baustein, muss der Test mitgezogen werden, damit das Runbook nicht veraltet. Die Bausteine stehen ohne `begin;`/`commit;`, im Studio bzw. in `psql` wird alles in eine Transaktion gepackt (siehe 1). +**Die SQL-Bausteine hier laufen unverändert in den pgTAP-Tests** (Abschnitte 3 und 4 in `supabase/tests/database/06_catalog_foundation.test.sql`, Abschnitt 5 in `07_catalog_merge.test.sql`, Abschnitt 6 in `08_game_reports.test.sql`; Kommentare mit Abschnittsverweis). Ändert sich ein Baustein, muss der Test mitgezogen werden, damit das Runbook nicht veraltet. Die Bausteine stehen ohne `begin;`/`commit;`, im Studio bzw. in `psql` wird alles in eine Transaktion gepackt (siehe 1). ## 1. Zugang @@ -190,7 +190,7 @@ Zwei Katalogspiele, die dasselbe Spiel beschreiben (Duplikat), führt man mit de - **Quelle** ist das Duplikat, das verschwindet. Sie wird **nicht gelöscht**, sondern ausgeblendet und auf das Ziel verwiesen (`hidden_at`, `merged_into_id`); ihre ID bleibt gültig, die App leitet alte Links auf das Ziel um (KAT-02). - **Ziel** ist der Eintrag, der bleibt. Er muss sichtbar sein (nicht ausgeblendet, nicht selbst zusammengeführt). Das Ziel behält seine Fakten, Kurztexte und Zuordnungen (Verlag, Genre, Mechanik, Kategorie); die Zuordnungen der Quelle bleiben am ausgeblendeten Quell-Eintrag. Was am Ziel fehlt, ergänzt man vorher oder nachher mit den Bausteinen aus 3. -- Beide müssen **Katalogspiele** sein. Einen vorläufigen privaten Eintrag eines Nutzers führt man nicht hiermit zusammen (dafür gibt es ab PR 2c die Meldungs-Funktion). +- Beide müssen **Katalogspiele** sein. Einen vorläufigen privaten Eintrag eines Nutzers führt man nicht hiermit zusammen (dafür gibt es die Meldungs-Funktion, siehe 6). - **Es gibt kein Rückgängigmachen.** Vor dem Zusammenführen den Stand des Backups prüfen; im Zweifel zuerst die Vorschau (unten) lesen. **Schritt 1: Duplikate finden** (normalisierter Titel + Jahr, nur sichtbare Katalogspiele; die älteste ID steht zuerst): @@ -265,7 +265,7 @@ Die Quelle ist ausgeblendet und verweist auf das Ziel, das Ziel ist unverändert {"plays": 7, "target_id": "…", "source_id": "…", "score_sheets": 1, "favorite_games": 1, "collection_items": 3, "variants_copied": 2, "variants_mapped": 1, "variants_cleared": 0, "chain_flattened": 0, "favorite_conflicts": 0, "bgg_id_transferred": true, "source_transferred": false, - "collection_conflicts": 1} + "collection_conflicts": 1, "reports_repointed": 0} ``` | Schlüssel | Bedeutung | @@ -275,11 +275,12 @@ Die Quelle ist ausgeblendet und verweist auf das Ziel, das Ziel ist unverändert | `favorite_games`, `favorite_conflicts` | Favoriten umgehängt bzw. wegen vorhandenem Favoriten am Ziel entfallen | | `plays`, `score_sheets` | umgehängte Partien und Wertungsbögen | | `variants_copied`, `variants_mapped` | Varianten, die am Ziel neu angelegt bzw. auf eine gleichnamige abgebildet wurden | -| `variants_cleared` | Sammlungseinträge und Partien, deren Edition geleert wurde. Beim Zusammenführen zweier Katalogspiele bleibt das `0`; es kommt nur beim Umhängen privater Einträge vor (PR 2c) | +| `variants_cleared` | Sammlungseinträge und Partien, deren Edition geleert wurde. Beim Zusammenführen zweier Katalogspiele bleibt das `0`; es kommt nur beim Umhängen privater Einträge vor (Abschnitt 6) | | `bgg_id_transferred`, `source_transferred` | `true`, wenn das Ziel die BGG-Kennung bzw. die Quelle der Quelle übernommen hat | | `bgg_id_conflict` | nur vorhanden, wenn beide eine **unterschiedliche** BGG-Kennung hatten: `{"source": 13, "target": 42}`. Das Ziel behält seinen Wert, der Wert der Quelle ist geleert und steht **nur noch hier**. Bei Bedarf notieren und prüfen, ob das Ziel das richtige Spiel ist | | `source_conflict` | wie `bgg_id_conflict` für die Quelle (`source` und `target` mit `catalog_source_id`, `source_ref`, `source_url`, `source_synced_at`) | | `chain_flattened` | Anzahl früher zusammengeführter Spiele, die jetzt direkt auf das Ziel zeigen | +| `reports_repointed` | Meldungen (`game_reports`), deren Zieleintrag die Quelle war und die jetzt auf das Ziel zeigen (Abschnitt 6) | **Fehlermeldungen** (alle ohne Änderung, `P0001`): @@ -296,7 +297,145 @@ Ein Aufruf über die App-Schnittstelle (als `anon` oder `authenticated`, auch mi ## 6. Meldungen bearbeiten -Kommt mit PR 2c. +Nutzer melden fehlende Spiele (KAT-04, #13); Meldungen, die ein Import anlegt, gibt es ab SAM-08 (`origin = 'import'`). In der Tabelle `game_reports` steht, was der Nutzer angegeben hat (Titel, Verlag, Jahr, Link, BGG-Kennung) und, falls vorhanden, sein **vorläufiger privater Eintrag** (`provisional_game_id`, nur für ihn sichtbar). Der Admin entscheidet in drei Wegen, immer mit der Funktion `public.admin_resolve_report(meldung, status, ziel)`: + +| Entscheidung | Aufruf | Wirkung | +|---|---|---| +| **freigeben** | `'approved'` + Ziel | Der Eintrag ist wegen dieser Meldung **neu** entstanden. Der vorläufige Eintrag des Nutzers wird auf das Ziel umgehängt (Sammlung, Favoriten, Partien, Wertungsbögen) und ausgeblendet. | +| **zusammenführen** | `'merged'` + Ziel | Der Eintrag war **schon vorhanden**. Wirkung wie bei `'approved'`. | +| **ablehnen** | `'rejected'`, optional mit Ziel | Mit Ziel wird nur der Verweis gespeichert (die App kann dem Nutzer den Zieleintrag anbieten); der vorläufige Eintrag bleibt unverändert. Ohne Ziel ändert sich nichts außer Status und Zeitpunkt. | + +**Regeln** + +- **Nie einen privaten Eintrag öffentlich schalten.** `visibility` und `owner_id` eines Spiels lassen sich nicht ändern (der Trigger `games_visibility_immutable` lehnt es für jede Rolle ab, auch für `postgres`, Fehler `visibility_immutable`). Freigeben heißt immer: **neuen** Katalogeintrag anlegen. +- **Beim Anlegen nur Fakten aus den Meldungsfeldern übernehmen:** `title`, `publisher_name`, `year`; `link` dient nur als Recherchehilfe. **Nie** `description` oder `image_path` des vorläufigen Eintrags übernehmen (das sind private Angaben des Nutzers). Den Kurztext schreibt der Admin selbst (SB4, deutsch). +- **Datenschutz:** vom vorläufigen Eintrag nur ansehen, was die Entscheidung braucht (Anzahl Verweise, Namen der Editionen). Die Meldung selbst gehört zum Konto des Nutzers und wird mit ihm gelöscht. +- **Editionen werden nicht kopiert** (RB1, Klärung N15). Hat der Katalogeintrag eine gleichnamige Edition (Vergleich ohne Groß-/Kleinschreibung und ohne Leerzeichen am Rand), bleibt die Edition des Nutzers erhalten, sonst wird sie an Sammlungseintrag und Partien geleert (`variants_cleared` im Ergebnis). Soll die Edition erhalten bleiben, **vorher** am Katalogeintrag eine Variante anlegen, und zwar nur aus belegbaren Fakten (Name und Jahr der Edition aus Verlags- oder Schachtelangaben), nie durch Übernahme der privaten Variante. Die private Variante bleibt am ausgeblendeten vorläufigen Eintrag und wird mit dem Konto gelöscht. +- Das Ziel muss ein sichtbares Katalogspiel sein (nicht ausgeblendet, nicht zusammengeführt). Es gibt kein Rückgängigmachen; in einer Transaktion arbeiten (`begin;` davor, nach dem Lesen des Ergebnisses `commit;`, bei Zweifel `rollback;`; die Bausteine stehen wie überall ohne `begin`/`commit`). +- Wird ein Zieleintrag später mit einem anderen Katalogspiel zusammengeführt (Abschnitt 5), zeigen die Meldungen automatisch auf das neue Ziel (`reports_repointed` im Ergebnis). + +In den Bausteinen stehen Beispiel-IDs: `7c1d2c3d-0000-4000-8000-000000000001` ist die Meldung, `…0002` das neu angelegte Katalogspiel, `…0004` ein vorhandenes Katalogspiel. Immer durch die echten IDs ersetzen (neue ID erzeugen: `select gen_random_uuid();`). + +**Schritt 1: Offene Meldungen** (älteste zuerst): + +```sql +select r.id, r.created_at, r.origin, r.title, r.publisher_name, r.year, r.link, r.bgg_id, r.provisional_game_id +from public.game_reports r +where r.status = 'open' +order by r.created_at, r.id; +``` + +**Schritt 2: Ähnliche Spiele im Katalog suchen** (gleicher Titel ohne Groß-/Kleinschreibung oder gleiche BGG-Kennung wie die Meldung). Gibt es schon einen Eintrag, entscheidet man sich für `'merged'`: + +```sql +select g.id, g.title, g.year, g.bgg_id +from public.game_reports r +join public.catalog_games g + on lower(btrim(g.title)) = lower(btrim(r.title)) + or (r.bgg_id is not null and g.bgg_id = r.bgg_id) +where r.id = '7c1d2c3d-0000-4000-8000-000000000001' +order by g.title, g.id; +``` + +Was hängt am vorläufigen Eintrag des Nutzers? Nur Anzahlen: + +```sql +select g.id, g.title, + (select count(*) from public.collection_items c where c.game_id = g.id) as sammlung, + (select count(*) from public.favorite_games f where f.game_id = g.id) as favoriten, + (select count(*) from public.plays p where p.game_id = g.id) as partien, + (select count(*) from public.score_sheets s where s.game_id = g.id) as wertungsboegen +from public.game_reports r +join public.games g on g.id = r.provisional_game_id +where r.id = '7c1d2c3d-0000-4000-8000-000000000001'; +``` + +Und die Namen seiner Editionen (für die Entscheidung, ob am Katalogeintrag eine gleichnamige Variante angelegt wird): + +```sql +select v.id, v.name, v.year, v.language +from public.game_reports r +join public.game_variants v on v.game_id = r.provisional_game_id +where r.id = '7c1d2c3d-0000-4000-8000-000000000001' +order by v.name, v.id; +``` + +**Schritt 3a: Freigeben** (neuer Eintrag). Zuerst den Katalogeintrag mit den Fakten aus der Meldung und einem eigenen Kurztext anlegen (jedes gesetzte Feld wird automatisch in `manual_fields` vermerkt): + +```sql +insert into public.games (id, title, year, description, description_locale, visibility) +values ('7c1d2c3d-0000-4000-8000-000000000002', 'Meldungsspiel', 2024, 'Eigener Kurztext des Admins.', 'de', 'catalog'); +``` + +Verlag zuordnen (ein vorhandener Verlag wird wiederverwendet, die Schreibweise entscheidet nicht): + +```sql +insert into public.publishers (name) values ('Beispielverlag') on conflict ((lower(name))) do nothing; +insert into public.game_publishers (game_id, publisher_id) +select '7c1d2c3d-0000-4000-8000-000000000002', p.id from public.publishers p where lower(p.name) = lower('Beispielverlag'); +``` + +Optional, wenn die Edition des Nutzers erhalten bleiben soll (nur belegbare Fakten, siehe Regeln): + +```sql +insert into public.game_variants (game_id, name, year) values ('7c1d2c3d-0000-4000-8000-000000000002', 'Deluxe', 2024); +``` + +Dann die Meldung abschließen und das Ergebnis lesen: + +```sql +select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000001', 'approved', '7c1d2c3d-0000-4000-8000-000000000002'); +``` + +**Schritt 3b: Zusammenführen** (der Eintrag war schon da, Ziel ist das vorhandene Katalogspiel): + +```sql +select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000005', 'merged', '7c1d2c3d-0000-4000-8000-000000000004'); +``` + +**Schritt 3c: Ablehnen**, ohne Verweis oder mit Verweis auf ein vorhandenes Katalogspiel: + +```sql +select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000006', 'rejected'); +``` + +```sql +select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000007', 'rejected', '7c1d2c3d-0000-4000-8000-000000000004'); +``` + +**Schritt 4: Nachprüfen.** Status der Meldung und Zustand des vorläufigen Eintrags (bei `approved`/`merged`: ausgeblendet und verwiesen; bei `rejected`: unverändert): + +```sql +select r.id, r.status, r.target_game_id, r.resolved_at is not null as erledigt, + g.hidden_at is not null as vorlaeufig_ausgeblendet, g.merged_into_id +from public.game_reports r +left join public.games g on g.id = r.provisional_game_id +where r.id = '7c1d2c3d-0000-4000-8000-000000000001'; +``` + +**Ergebnis lesen** (Beispiel, `approved` mit vorläufigem Eintrag): + +``` +{"plays": 2, "status": "approved", "repointed": true, "report_id": "…", "target_id": "…", "score_sheets": 1, + "favorite_games": 1, "collection_items": 1, "variants_copied": 0, "variants_mapped": 1, "variants_cleared": 1, + "favorite_conflicts": 0, "collection_conflicts": 0, "provisional_game_id": "…"} +``` + +`repointed` ist `false`, wenn nichts umgehängt wurde (`rejected` oder Meldung ohne vorläufigen Eintrag); dann stehen die Zähler nicht im Ergebnis. Die Zähler haben dieselbe Bedeutung wie bei der Zusammenführung (Abschnitt 5). Wichtig hier: **`variants_cleared`** zählt die Sammlungseinträge und Partien, deren Edition geleert wurde, weil der Katalogeintrag keine gleichnamige Variante hatte. Das ist der Hinweis, dass der Nutzer die Edition neu wählen muss. Eine Kollision (`collection_conflicts`) bedeutet: Der Nutzer hatte das Ziel schon in seiner Sammlung; der Zieleintrag bleibt, leere Felder füllt der Eintrag aus dem vorläufigen Spiel. + +**Fehlermeldungen** (alle ohne Änderung, `P0001`): + +| Meldung | Bedeutung | +|---|---| +| `admin_only` | Die Sitzung läuft nicht als `postgres` bzw. `supabase_admin` (`select current_user;` prüfen) | +| `report_not_found` | Die Meldung gibt es nicht (ID vertippt?) | +| `report_not_open` | Die Meldung ist schon bearbeitet (`approved`, `merged` oder `rejected`) | +| `report_status_invalid` | Der Status ist nicht `approved`, `merged` oder `rejected` | +| `report_target_required` | `approved` und `merged` brauchen ein Ziel | +| `report_target_not_listed` | Das Ziel ist kein sichtbares Katalogspiel (privat, ausgeblendet, zusammengeführt oder unbekannt); das endgültige Ziel wählen oder zuerst wieder einblenden (siehe 4) | +| `report_provisional_not_private` | Der vorläufige Eintrag der Meldung ist kein privates Spiel (Datenfehler); nicht umhängen, sondern die Meldung prüfen | + +Ein Aufruf über die App-Schnittstelle (als `anon` oder `authenticated`, auch mit Admin-Claims) scheitert mit `42501`; die Funktion ist nur für die Datenbankrolle ausführbar (E10). Meldungen anlegen, ändern oder löschen können Nutzer in dieser Etappe nicht (das Anlegen kommt mit KAT-04); sie lesen nur ihre eigenen. Das Löschen eines Zielspiels, auf das eine Meldung verweist, schlägt mit `23503` fehl (gewollt: ausblenden statt löschen). ## 7. Admin-Rolle in der App diff --git a/supabase/migrations/20261103195151_game_reports.sql b/supabase/migrations/20261103195151_game_reports.sql new file mode 100644 index 0000000..494a86c --- /dev/null +++ b/supabase/migrations/20261103195151_game_reports.sql @@ -0,0 +1,280 @@ +-- KAT-05 / PR 2c: Meldungen (Spec docs/specs/2-katalog-daten.md, Abschnitt 3.4). +-- Nur additiv. Enthält: +-- games_visibility_immutable Trigger: Sichtbarkeit und Eigentümer eines Spiels sind unveränderlich (E14) +-- game_reports Tabelle mit RLS (Nutzer lesen nur ihre eigenen Meldungen) +-- admin_resolve_report Admin-Funktion: Meldung freigeben, zusammenführen oder ablehnen +-- admin_merge_games per create or replace erweitert: hängt auch game_reports.target_game_id um +-- Anlegen von Meldungen durch Nutzer (Insert-Recht, Insert-Policy, Tageslimit) liefert #13 (KAT-04). + +-- --------------------------------------------------------------------------------------------- +-- E14: visibility und owner_id einer bestehenden games-Zeile ändern sich nie, für keine Rolle +-- (auch nicht für postgres). Ein privater Eintrag wird nie öffentlich geschaltet; freigegebene +-- Meldungen bekommen immer einen neuen Katalogeintrag. Das Konto-Löschen ist nicht betroffen +-- (private Spiele werden per on delete cascade gelöscht, nicht geändert). +-- Hinweis: Ein BEFORE-Trigger läuft vor der RLS-Prüfung (with check). Ein Nutzer, der visibility +-- ändert, bekommt deshalb P0001 visibility_immutable statt 42501. +-- --------------------------------------------------------------------------------------------- +create function public.games_visibility_immutable() +returns trigger +language plpgsql +set search_path = '' +as $$ +begin + if new.visibility is distinct from old.visibility or new.owner_id is distinct from old.owner_id then + raise exception 'visibility_immutable' using errcode = 'P0001'; + end if; + return new; +end; +$$; + +create trigger games_visibility_immutable before update on public.games + for each row execute function public.games_visibility_immutable(); + +-- --------------------------------------------------------------------------------------------- +-- Tabelle game_reports (Klärungen 6, 10, 26, 27, N12, N12a; E12) +-- --------------------------------------------------------------------------------------------- +create table public.game_reports ( + id uuid primary key default gen_random_uuid(), + -- meldender Nutzer; Klärung 10: beim Konto-Löschen hart gelöscht + owner_id uuid not null references public.profiles (id) on delete cascade, + origin text not null default 'user' check (origin in ('user', 'import')), + title text not null check (char_length(title) between 1 and 200), + publisher_name text check (char_length(publisher_name) <= 200), + year smallint check (year between 1800 and 2100), + link text check (char_length(link) <= 500 and link ~ '^https?://'), + bgg_id integer, + -- vorläufiger privater Eintrag des Meldenden (KAT-04 Krit. 3); verschwindet er, bleibt die Meldung + provisional_game_id uuid references public.games (id) on delete set null, + status text not null default 'open' check (status in ('open', 'approved', 'rejected', 'merged')), + -- Zieleintrag: wie merged_into_id on delete restrict (Katalogspiele werden ausgeblendet, nicht gelöscht) + target_game_id uuid references public.games (id) on delete restrict, + resolved_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint game_reports_resolved_matches_status check ((status = 'open') = (resolved_at is null)), + constraint game_reports_open_has_no_target check (status <> 'open' or target_game_id is null), + constraint game_reports_target_required check (status not in ('approved', 'merged') or target_game_id is not null) +); + +create trigger set_updated_at before update on public.game_reports + for each row execute function public.set_updated_at(); + +-- Tageslimit (#13): (owner_id, created_at) +create index game_reports_owner_created_idx on public.game_reports (owner_id, created_at); +-- N12a: je Nutzer höchstens eine offene Import-Meldung je BGG-Kennung +create unique index game_reports_import_open_key on public.game_reports (owner_id, bgg_id) + where origin = 'import' and status = 'open' and bgg_id is not null; +-- Admin-Liste: offene Meldungen, älteste zuerst +create index game_reports_open_idx on public.game_reports (created_at) where status = 'open'; +-- Fremdschlüssel (Ergänzung zur Spec: Index auf jeden Fremdschlüssel). Ein vorläufiger Eintrag +-- gehört zu höchstens einer Meldung (1:1), deshalb eindeutig; mehrere Meldungen können dasselbe +-- Ziel haben, daher dort ein einfacher Index. +create unique index game_reports_provisional_game_id_key on public.game_reports (provisional_game_id) + where provisional_game_id is not null; +create index game_reports_target_game_id_idx on public.game_reports (target_game_id); + +-- RLS: Der Meldende liest seine eigenen Meldungen (Ergebnishinweis, KAT-04 Krit. 4-6). Die +-- Admin-Policy (is_admin) ist für R4 vorbereitet, in R1 wird die Rolle niemandem vergeben (E10). +-- Schreibrechte gibt es für anon und authenticated nicht; #13 ergänzt Insert-Recht und -Policy. +alter table public.game_reports enable row level security; +revoke all on public.game_reports from anon, authenticated; +grant select on public.game_reports to authenticated; + +create policy game_reports_select_own on public.game_reports + for select to authenticated + using (owner_id = (select auth.uid())); +create policy game_reports_select_admin on public.game_reports + for select to authenticated + using (public.is_admin()); + +-- --------------------------------------------------------------------------------------------- +-- Admin-Funktion: Meldung bearbeiten (Krit. 5). security invoker, nur postgres/supabase_admin. +-- --------------------------------------------------------------------------------------------- +create function public.admin_resolve_report(p_report uuid, p_status text, p_target uuid default null) +returns jsonb +language plpgsql +security invoker +set search_path = '' +as $$ +declare + rep public.game_reports; + prov public.games; + result jsonb := '{}'::jsonb; + repointed boolean := false; +begin + if current_user not in ('postgres', 'supabase_admin') then + raise exception 'admin_only' using errcode = 'P0001'; + end if; + + select * into rep from public.game_reports r where r.id = p_report for update; + if not found then + raise exception 'report_not_found' using errcode = 'P0001'; + end if; + if rep.status <> 'open' then + raise exception 'report_not_open' using errcode = 'P0001'; + end if; + if p_status is null or p_status not in ('approved', 'merged', 'rejected') then + raise exception 'report_status_invalid' using errcode = 'P0001'; + end if; + if p_status in ('approved', 'merged') and p_target is null then + raise exception 'report_target_required' using errcode = 'P0001'; + end if; + + -- Beteiligte Spiele in fester Reihenfolge sperren (wie admin_merge_games). + perform 1 from public.games g + where g.id in (p_target, rep.provisional_game_id) order by g.id for update; + + if p_target is not null and not exists ( + select 1 from public.games g + where g.id = p_target and g.visibility = 'catalog' and g.hidden_at is null and g.merged_into_id is null + ) then + raise exception 'report_target_not_listed' using errcode = 'P0001'; + end if; + + if p_status in ('approved', 'merged') and rep.provisional_game_id is not null then + select * into prov from public.games g where g.id = rep.provisional_game_id; + -- Zusatzprüfung (nicht in der Spec): Nur ein privater Eintrag darf als vorläufiger Eintrag + -- umgehängt werden; ein Katalogspiel würde sonst über den Variantenfall "Katalog" kopiert. + if prov.visibility <> 'private' then + raise exception 'report_provisional_not_private' using errcode = 'P0001'; + end if; + -- privat -> Katalog: nichts wird kopiert (RB1, Klärung N15) + result := public.repoint_game_references(prov.id, p_target); + repointed := true; + -- merged_into_id verlangt hidden_at (Check), deshalb in einem Statement. + update public.games g + set merged_into_id = p_target, hidden_at = coalesce(g.hidden_at, now()) + where g.id = prov.id; + end if; + + -- Bei "rejected" mit Ziel wird nur der Verweis gespeichert (Spec-F3); der vorläufige Eintrag + -- bleibt unverändert, ob der Nutzer wechselt, entscheidet er in der App. + update public.game_reports r + set status = p_status, target_game_id = p_target, resolved_at = now() + where r.id = rep.id; + + return result || jsonb_build_object( + 'report_id', rep.id, + 'status', p_status, + 'target_id', p_target, + 'provisional_game_id', rep.provisional_game_id, + 'repointed', repointed + ); +end; +$$; + +-- --------------------------------------------------------------------------------------------- +-- admin_merge_games: Rumpf aus PR 2b unverändert, nur Schritt 4 neu (game_reports.target_game_id +-- von der Quelle auf das Ziel umhängen) und der Zähler reports_repointed im Ergebnis. +-- --------------------------------------------------------------------------------------------- +create or replace function public.admin_merge_games(p_source uuid, p_target uuid) +returns jsonb +language plpgsql +security invoker +set search_path = '' +as $$ +declare + s public.games; + t public.games; + result jsonb; + n_chain integer; + n_reports integer; + bgg_transferred boolean := false; + source_transferred boolean := false; +begin + if current_user not in ('postgres', 'supabase_admin') then + raise exception 'admin_only' using errcode = 'P0001'; + end if; + if p_source = p_target then + raise exception 'merge_same_game' using errcode = 'P0001'; + end if; + if p_source is null or p_target is null then + raise exception 'merge_game_not_found' using errcode = 'P0001'; + end if; + + -- Beide Zeilen in fester Reihenfolge sperren, damit zwei gleichzeitige Aufrufe nicht verklemmen. + perform 1 from public.games g where g.id in (p_source, p_target) order by g.id for update; + select * into s from public.games g where g.id = p_source; + if not found then + raise exception 'merge_game_not_found' using errcode = 'P0001'; + end if; + select * into t from public.games g where g.id = p_target; + if not found then + raise exception 'merge_game_not_found' using errcode = 'P0001'; + end if; + if s.visibility <> 'catalog' or t.visibility <> 'catalog' then + raise exception 'merge_not_catalog' using errcode = 'P0001'; + end if; + if t.hidden_at is not null or t.merged_into_id is not null then + raise exception 'merge_target_not_listed' using errcode = 'P0001'; + end if; + if s.merged_into_id is not null then + raise exception 'merge_source_already_merged' using errcode = 'P0001'; + end if; + + -- 1. Verweise umhängen (beide Katalog -> Varianten werden kopiert) + result := public.repoint_game_references(p_source, p_target); + + -- 2. BGG-Kennung und Quelle übernehmen (W6). Wegen der eindeutigen Indizes (bgg_id im Katalog, + -- (Quelle, source_ref) im Katalog) zuerst an der Quelle leeren, dann am Ziel setzen. + -- Gleichzeitig wird die Quelle als zusammengeführt und ausgeblendet markiert (ein Statement: + -- merged_into_id verlangt hidden_at). Bereits ausgeblendete Quellen behalten ihr hidden_at. + update public.games g + set bgg_id = null, + catalog_source_id = null, source_ref = null, source_url = null, source_synced_at = null, + merged_into_id = p_target, + hidden_at = coalesce(g.hidden_at, now()) + where g.id = p_source; + + if s.bgg_id is not null then + if t.bgg_id is null then + update public.games g set bgg_id = s.bgg_id where g.id = p_target; + bgg_transferred := true; + elsif t.bgg_id <> s.bgg_id then + -- Das Ziel behält seinen Wert; der geleerte Wert der Quelle bleibt hier erhalten. + result := result || jsonb_build_object('bgg_id_conflict', + jsonb_build_object('source', s.bgg_id, 'target', t.bgg_id)); + end if; + end if; + + if s.catalog_source_id is not null then + if t.catalog_source_id is null then + update public.games g + set catalog_source_id = s.catalog_source_id, source_ref = s.source_ref, + source_url = s.source_url, source_synced_at = s.source_synced_at + where g.id = p_target; + source_transferred := true; + elsif (t.catalog_source_id, t.source_ref) is distinct from (s.catalog_source_id, s.source_ref) then + result := result || jsonb_build_object('source_conflict', jsonb_build_object( + 'source', jsonb_build_object('catalog_source_id', s.catalog_source_id, 'source_ref', s.source_ref, + 'source_url', s.source_url, 'source_synced_at', s.source_synced_at), + 'target', jsonb_build_object('catalog_source_id', t.catalog_source_id, 'source_ref', t.source_ref, + 'source_url', t.source_url, 'source_synced_at', t.source_synced_at))); + end if; + end if; + + -- 3. Ketten glätten: Spiele, die bisher auf die Quelle zeigten, zeigen jetzt auf das Ziel. + update public.games g set merged_into_id = p_target where g.merged_into_id = p_source; + get diagnostics n_chain = row_count; + + -- 4. (neu in 2c) Meldungen, die auf die Quelle als Zieleintrag zeigen, zeigen jetzt auf das Ziel. + update public.game_reports r set target_game_id = p_target where r.target_game_id = p_source; + get diagnostics n_reports = row_count; + + return result || jsonb_build_object( + 'source_id', p_source, + 'target_id', p_target, + 'bgg_id_transferred', bgg_transferred, + 'source_transferred', source_transferred, + 'chain_flattened', n_chain, + 'reports_repointed', n_reports + ); +end; +$$; + +-- Rechte-Regel: nur der Eigentümer darf ausführen. Nach dem create or replace wird der revoke +-- für admin_merge_games ausdrücklich wiederholt (create or replace behält zwar die bisherigen +-- Rechte, aber das soll nicht still vorausgesetzt werden). +revoke execute on function public.games_visibility_immutable() from public, anon, authenticated, service_role; +revoke execute on function public.admin_resolve_report(uuid, text, uuid) from public, anon, authenticated, service_role; +revoke execute on function public.admin_merge_games(uuid, uuid) from public, anon, authenticated, service_role; diff --git a/supabase/tests/database/00_schema.test.sql b/supabase/tests/database/00_schema.test.sql index 359840d..c6ee175 100644 --- a/supabase/tests/database/00_schema.test.sql +++ b/supabase/tests/database/00_schema.test.sql @@ -2,8 +2,8 @@ begin; select plan(5); -select is((select count(*) from pg_tables where schemaname = 'public'), 20::bigint, - 'R1 hat genau 20 Tabellen (17 + catalog_sources, publishers, game_publishers)'); +select is((select count(*) from pg_tables where schemaname = 'public'), 21::bigint, + 'R1 hat genau 21 Tabellen (17 + catalog_sources, publishers, game_publishers, game_reports)'); select is_empty($$ select c.relname from pg_class c join pg_namespace n on n.oid = c.relnamespace diff --git a/supabase/tests/database/07_catalog_merge.test.sql b/supabase/tests/database/07_catalog_merge.test.sql index 28f690f..1831bdc 100644 --- a/supabase/tests/database/07_catalog_merge.test.sql +++ b/supabase/tests/database/07_catalog_merge.test.sql @@ -2,8 +2,8 @@ -- Fremdschlüssel-Wächter und die SQL-Bausteine aus docs/katalog-pflege.md, Abschnitt 5 -- (unverändert, ohne begin/commit; Auswahl immer per id). -- Läuft auch mit eingespieltem Startbestand: eigene Fixtures mit festen IDs (Präfix f7/7a1b), gezählt wird nur darüber. --- Hinweis für 2c: Der Fremdschlüssel-Wächter am Ende muss um game_reports.target_game_id (behandelt) und --- game_reports.provisional_game_id (bewusst ausgelassen) erweitert werden, sonst wird er rot. +-- Ab 2c kennt der Fremdschlüssel-Wächter am Ende auch game_reports.target_game_id (behandelt) und +-- game_reports.provisional_game_id (bewusst ausgelassen); admin_merge_games meldet reports_repointed. begin; select plan(89); @@ -209,7 +209,7 @@ select is((select admin_merge_games from rb_merge), 'source_id', '7a1b2c3d-0000-4000-8000-000000000002', 'target_id', '7a1b2c3d-0000-4000-8000-000000000003', 'collection_items', 1, 'collection_conflicts', 3, 'favorite_games', 1, 'favorite_conflicts', 1, 'plays', 3, 'score_sheets', 1, 'variants_copied', 1, 'variants_mapped', 1, 'variants_cleared', 0, - 'bgg_id_transferred', true, 'source_transferred', true, 'chain_flattened', 0), + 'bgg_id_transferred', true, 'source_transferred', true, 'chain_flattened', 0, 'reports_repointed', 0), 'KAT-05 Kriterium 8: Runbook 5, das Ergebnis-JSON zählt alles richtig'); -- Runbook 5, Schritt 4: Nachprüfen (der Vorschau-Baustein aus Schritt 2 liefert überall 0) @@ -402,7 +402,7 @@ select is((select count(*) from public.game_variants where game_id = 'f7000000-0 insert into public.games (id, title, visibility) values ('f7000000-0000-0000-0000-000000000161', 'Leer Quelle', 'catalog'), ('f7000000-0000-0000-0000-000000000162', 'Leer Ziel', 'catalog'); select is(public.admin_merge_games('f7000000-0000-0000-0000-000000000161', 'f7000000-0000-0000-0000-000000000162') - 'source_id' - 'target_id', - '{"collection_items":0,"collection_conflicts":0,"favorite_games":0,"favorite_conflicts":0,"plays":0,"score_sheets":0,"variants_copied":0,"variants_mapped":0,"variants_cleared":0,"bgg_id_transferred":false,"source_transferred":false,"chain_flattened":0}'::jsonb, + '{"collection_items":0,"collection_conflicts":0,"favorite_games":0,"favorite_conflicts":0,"plays":0,"score_sheets":0,"variants_copied":0,"variants_mapped":0,"variants_cleared":0,"bgg_id_transferred":false,"source_transferred":false,"chain_flattened":0,"reports_repointed":0}'::jsonb, 'Zusammenführen ohne Verweise liefert lauter Nullen'); -- --------------------------------------------------------------------------------------------- @@ -475,9 +475,11 @@ select set_eq($$select fk from fk_found$$, $$values -- behandelt von repoint_game_references / admin_merge_games ('collection_items.game_id'), ('favorite_games.game_id'), ('plays.game_id'), ('score_sheets.game_id'), - ('game_variants.game_id'), ('games.merged_into_id'), + ('game_variants.game_id'), ('games.merged_into_id'), ('game_reports.target_game_id'), -- bewusst ausgelassen: bleiben am ausgeblendeten Quell-Eintrag - ('game_genres.game_id'), ('game_mechanics.game_id'), ('game_categories.game_id'), ('game_publishers.game_id')$$, + ('game_genres.game_id'), ('game_mechanics.game_id'), ('game_categories.game_id'), ('game_publishers.game_id'), + -- bewusst ausgelassen: zeigt auf ein privates Spiel, das Zusammenführen zweier Katalogspiele betrifft ihn nicht + ('game_reports.provisional_game_id')$$, 'KAT-05 Kriterium 4: Fremdschlüssel-Wächter: neue Verweise auf games erzwingen eine Entscheidung im Zusammenführen'); select * from finish(); diff --git a/supabase/tests/database/08_game_reports.test.sql b/supabase/tests/database/08_game_reports.test.sql new file mode 100644 index 0000000..ab4629b --- /dev/null +++ b/supabase/tests/database/08_game_reports.test.sql @@ -0,0 +1,741 @@ +-- KAT-05 (PR 2c): Meldungen. Tabelle game_reports (RLS, Checks, Indizes, Fremdschlüssel), +-- Trigger games_visibility_immutable, admin_resolve_report, admin_merge_games nach dem +-- create or replace (Rechte, Schritt 4), Konto-Löschen nach Freigabe, Fremdschlüssel-Wächter und die +-- SQL-Bausteine aus docs/katalog-pflege.md, Abschnitt 6 (unverändert, ohne begin/commit; Auswahl per id). +-- Läuft auch mit eingespieltem Startbestand: eigene Fixtures mit festen IDs (Präfix 7c1d/f8), gezählt wird nur darüber. +-- Regel dieser Datei: Jede Verweigerung steht neben einem Positivfall, sonst würde "alles verweigern" bestehen. +begin; +select plan(152); + +insert into auth.users (id, email, raw_user_meta_data) values + ('11111111-1111-1111-1111-111111111111', 'anna@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Anna"}'), + ('22222222-2222-2222-2222-222222222222', 'ben@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Ben"}'), + ('33333333-3333-3333-3333-333333333333', 'cara@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Cara"}'); + +-- --------------------------------------------------------------------------------------------- +-- Tabellenrechte (KAT-05 Kriterium 9) +-- --------------------------------------------------------------------------------------------- +select ok((select relrowsecurity from pg_class where oid = 'public.game_reports'::regclass), + 'Sicherheit Kriterium 1: game_reports hat Row Level Security'); +select ok(has_table_privilege('authenticated', 'public.game_reports', 'select'), + 'KAT-05 Kriterium 5: authenticated darf game_reports lesen (eigene Zeilen, Policy)'); +-- Formuliert stabil gegenüber späteren Spaltenrechten (#13 ergänzt Insert-Recht und update (result_seen_at)): +-- geprüft wird je Spalte, nicht "kein Insert/Update auf der Tabelle". +select is_empty($$select c, p from unnest(array['status', 'target_game_id', 'resolved_at', 'origin', 'owner_id', 'bgg_id', 'created_at']) c, + unnest(array['insert', 'update']) p + where has_column_privilege('authenticated', 'public.game_reports', c, p)$$, + 'KAT-05 Kriterium 9: authenticated hat kein Schreibrecht auf status, target_game_id, resolved_at, origin, owner_id, bgg_id, created_at'); +select is_empty($$select c, p from (select attname as c from pg_attribute where attrelid = 'public.game_reports'::regclass and attnum > 0 and not attisdropped) a, + unnest(array['insert', 'update']) p + where has_column_privilege('anon', 'public.game_reports', c, p)$$, + 'KAT-05 Kriterium 9: anon hat auf keine Spalte von game_reports ein Schreibrecht'); +select is_empty($$select r, p from unnest(array['anon', 'authenticated']) r, unnest(array['delete', 'truncate', 'references', 'trigger']) p + where has_table_privilege(r, 'public.game_reports', p)$$, + 'KAT-05 Kriterium 9: kein Löschen, Leeren oder Trigger-Recht für anon und authenticated'); +select is_empty($$select 1 from pg_policies where tablename = 'game_reports' and cmd in ('DELETE', 'ALL')$$, + 'KAT-05 Kriterium 9: game_reports hat keine Delete- oder All-Policy'); +select ok(not has_table_privilege('anon', 'public.game_reports', 'select'), + 'Sicherheit Kriterium 3: anon darf game_reports nicht einmal lesen'); + +-- --------------------------------------------------------------------------------------------- +-- Fixtures (als postgres) +-- --------------------------------------------------------------------------------------------- +insert into public.games (id, title, visibility) values + ('7c1d2c3d-0000-4000-8000-000000000004', 'Bestehendes Spiel', 'catalog'), + ('f8000000-0000-0000-0000-00000000000c', 'MELDUNGSSPIEL', 'catalog'), + ('f8000000-0000-0000-0000-00000000000e', 'Unbeteiligt', 'catalog'); +update public.games set year = 2023 where id = 'f8000000-0000-0000-0000-00000000000c'; +insert into public.games (id, title, year, bgg_id, visibility) values + ('f8000000-0000-0000-0000-00000000000d', 'Anderer Titel', 2020, 4711, 'catalog'); +insert into public.game_variants (id, game_id, name) values + ('f8000000-0000-0000-0000-0000000000a4', '7c1d2c3d-0000-4000-8000-000000000004', 'Standard'); + +-- vorläufige private Einträge: P1 (Anna, freigeben), P2 (Anna, zusammenführen), P3 und P4 (Cara, ablehnen) +insert into public.games (id, title, year, description, image_path, visibility, owner_id) values + ('7c1d2c3d-0000-4000-8000-000000000003', 'Meldungsspiel', 2024, 'Annas privater Text', 'private/anna.png', 'private', '11111111-1111-1111-1111-111111111111'), + ('7c1d2c3d-0000-4000-8000-000000000008', 'Bestehendes Spiel', 2019, null, null, 'private', '11111111-1111-1111-1111-111111111111'), + ('7c1d2c3d-0000-4000-8000-000000000009', 'Caras Prototyp', 2025, null, null, 'private', '33333333-3333-3333-3333-333333333333'), + ('7c1d2c3d-0000-4000-8000-00000000000a', 'Caras Wunsch', 2025, null, null, 'private', '33333333-3333-3333-3333-333333333333'); + +insert into public.game_reports (id, owner_id, title, publisher_name, year, link, bgg_id, provisional_game_id, created_at) values + ('7c1d2c3d-0000-4000-8000-000000000001', '11111111-1111-1111-1111-111111111111', 'Meldungsspiel', 'Beispielverlag', 2024, 'https://example.org/spiel', 4711, '7c1d2c3d-0000-4000-8000-000000000003', '2026-01-01'), + ('7c1d2c3d-0000-4000-8000-000000000005', '11111111-1111-1111-1111-111111111111', 'Bestehendes Spiel', null, null, null, null, '7c1d2c3d-0000-4000-8000-000000000008', '2026-01-03'), + ('7c1d2c3d-0000-4000-8000-000000000006', '33333333-3333-3333-3333-333333333333', 'Caras Prototyp', null, null, null, null, '7c1d2c3d-0000-4000-8000-000000000009', '2026-01-02'), + ('7c1d2c3d-0000-4000-8000-000000000007', '33333333-3333-3333-3333-333333333333', 'Caras Wunsch', null, null, null, null, '7c1d2c3d-0000-4000-8000-00000000000a', '2026-01-04'), + ('7c1d2c3d-0000-4000-8000-00000000000b', '22222222-2222-2222-2222-222222222222', 'Bens Meldung ohne Eintrag', null, null, null, null, null, '2026-01-05'); + +-- Verweise an den vorläufigen Einträgen +insert into public.game_variants (id, game_id, name) values + ('f8000000-0000-0000-0000-0000000000e1', '7c1d2c3d-0000-4000-8000-000000000003', 'Deluxe'), + ('f8000000-0000-0000-0000-0000000000e2', '7c1d2c3d-0000-4000-8000-000000000003', 'Prototyp'), + ('f8000000-0000-0000-0000-0000000000e3', '7c1d2c3d-0000-4000-8000-000000000008', ' standard'); +insert into public.collection_items (id, owner_id, game_id, variant_id, list, note) values + ('f8000000-0000-0000-0000-0000000000c1', '11111111-1111-1111-1111-111111111111', '7c1d2c3d-0000-4000-8000-000000000003', 'f8000000-0000-0000-0000-0000000000e2', 'owned', 'Annas Notiz'), + ('f8000000-0000-0000-0000-0000000000c2', '11111111-1111-1111-1111-111111111111', '7c1d2c3d-0000-4000-8000-000000000008', 'f8000000-0000-0000-0000-0000000000e3', 'wishlist', 'Annas Wunsch'), + ('f8000000-0000-0000-0000-0000000000c3', '11111111-1111-1111-1111-111111111111', '7c1d2c3d-0000-4000-8000-000000000004', null, 'owned', null), + ('f8000000-0000-0000-0000-0000000000c4', '22222222-2222-2222-2222-222222222222', '7c1d2c3d-0000-4000-8000-000000000004', 'f8000000-0000-0000-0000-0000000000a4', 'owned', 'Bens Notiz'), + ('f8000000-0000-0000-0000-0000000000c5', '33333333-3333-3333-3333-333333333333', '7c1d2c3d-0000-4000-8000-000000000009', null, 'owned', 'Caras Notiz'), + ('f8000000-0000-0000-0000-0000000000c6', '33333333-3333-3333-3333-333333333333', '7c1d2c3d-0000-4000-8000-00000000000a', null, 'wishlist', 'Caras Wunschnotiz'); +insert into public.favorite_games (id, profile_id, game_id, position) values + ('f8000000-0000-0000-0000-0000000000d1', '11111111-1111-1111-1111-111111111111', '7c1d2c3d-0000-4000-8000-000000000003', 1); +insert into public.plays (id, owner_id, game_id, variant_id, played_at, client_id) values + ('f8000000-0000-0000-0000-0000000000b1', '11111111-1111-1111-1111-111111111111', '7c1d2c3d-0000-4000-8000-000000000003', 'f8000000-0000-0000-0000-0000000000e1', '2026-01-01', 'f8000000-0000-0000-0000-0000000000b1'), + ('f8000000-0000-0000-0000-0000000000b2', '11111111-1111-1111-1111-111111111111', '7c1d2c3d-0000-4000-8000-000000000003', null, '2026-01-02', 'f8000000-0000-0000-0000-0000000000b2'), + ('f8000000-0000-0000-0000-0000000000b3', '22222222-2222-2222-2222-222222222222', '7c1d2c3d-0000-4000-8000-000000000004', 'f8000000-0000-0000-0000-0000000000a4', '2026-01-03', 'f8000000-0000-0000-0000-0000000000b3'), + ('f8000000-0000-0000-0000-0000000000b4', '33333333-3333-3333-3333-333333333333', '7c1d2c3d-0000-4000-8000-000000000009', null, '2026-01-04', 'f8000000-0000-0000-0000-0000000000b4'); +insert into public.score_sheets (id, owner_id, game_id, name) values + ('f8000000-0000-0000-0000-0000000000b5', '11111111-1111-1111-1111-111111111111', '7c1d2c3d-0000-4000-8000-000000000003', 'Bogen'); + +-- --------------------------------------------------------------------------------------------- +-- Lesen, RLS (Sicherheit Kriterium 1 und 3, KAT-05 Kriterium 5 und 9) +-- --------------------------------------------------------------------------------------------- +select cmp_ok((select count(*) from public.game_reports where owner_id <> '11111111-1111-1111-1111-111111111111'), '>', 0::bigint, + 'Vorbedingung: es gibt Meldungen anderer Nutzer'); + +select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true); +set local role authenticated; +select is((select count(*) from public.game_reports where owner_id = '11111111-1111-1111-1111-111111111111'), 2::bigint, + 'Sicherheit Kriterium 3: Anna liest ihre beiden eigenen Meldungen'); +select is((select count(*) from public.game_reports where owner_id <> '11111111-1111-1111-1111-111111111111'), 0::bigint, + 'Sicherheit Kriterium 3: Anna liest keine fremden Meldungen'); +select is_empty($$select 1 from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000006'$$, + 'Sicherheit Kriterium 3: Anna findet Caras Meldung auch per ID nicht'); +select throws_ok($$insert into public.game_reports (owner_id, title) values ('22222222-2222-2222-2222-222222222222', 'Im Namen von Ben')$$, '42501', null, + 'KAT-05 Kriterium 9: Anna kann keine Meldung für einen anderen Nutzer anlegen'); +select throws_ok($$insert into public.game_reports (owner_id, title, status, resolved_at, target_game_id) + values ('11111111-1111-1111-1111-111111111111', 'Selbst freigegeben', 'approved', now(), '7c1d2c3d-0000-4000-8000-000000000004')$$, '42501', null, + 'KAT-05 Kriterium 9: Anna kann keine bereits freigegebene Meldung anlegen'); +select throws_ok($$update public.game_reports set target_game_id = '7c1d2c3d-0000-4000-8000-000000000004' where id = '7c1d2c3d-0000-4000-8000-000000000001'$$, '42501', null, + 'KAT-05 Kriterium 9: Anna kann das Ziel ihrer Meldung nicht setzen'); +select throws_ok($$update public.game_reports set status = 'approved' where id = '7c1d2c3d-0000-4000-8000-000000000001'$$, '42501', null, + 'KAT-05 Kriterium 9: Anna kann ihre Meldung nicht selbst freigeben'); +select throws_ok($$delete from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000001'$$, '42501', null, + 'KAT-05 Kriterium 9: Anna kann ihre Meldung nicht löschen'); +reset role; + +select set_config('request.jwt.claims', '{"sub":"22222222-2222-2222-2222-222222222222","role":"authenticated"}', true); +set local role authenticated; +select is((select count(*) from public.game_reports where owner_id = '22222222-2222-2222-2222-222222222222'), 1::bigint, + 'Sicherheit Kriterium 3: Ben liest seine eigene Meldung'); +select is((select count(*) from public.game_reports where owner_id <> '22222222-2222-2222-2222-222222222222'), 0::bigint, + 'Sicherheit Kriterium 3: Ben liest Annas und Caras Meldungen nicht'); +reset role; + +-- Gefälschte Rolle im user_metadata (vom Client änderbar) öffnet nichts +select set_config('request.jwt.claims', + '{"sub":"22222222-2222-2222-2222-222222222222","role":"authenticated","user_metadata":{"role":"admin"}}', true); +set local role authenticated; +select is((select count(*) from public.game_reports where owner_id <> '22222222-2222-2222-2222-222222222222'), 0::bigint, + 'Sicherheit Kriterium 3: user_metadata.role = admin öffnet keine fremden Meldungen'); +reset role; + +-- Die Admin-Policy (R4) ist vorbereitet: nur app_metadata (serverseitig gesetzt) öffnet das Lesen, schreiben kann auch der Admin nicht +select set_config('request.jwt.claims', + '{"sub":"22222222-2222-2222-2222-222222222222","role":"authenticated","app_metadata":{"role":"admin"}}', true); +set local role authenticated; +select cmp_ok((select count(*) from public.game_reports where owner_id <> '22222222-2222-2222-2222-222222222222'), '>=', 4::bigint, + 'Admin-Policy für R4: app_metadata.role = admin liest alle Meldungen'); +select throws_ok($$update public.game_reports set status = 'rejected', resolved_at = now() where id = '7c1d2c3d-0000-4000-8000-000000000001'$$, '42501', null, + 'KAT-05 Kriterium 9: auch mit app_metadata.role = admin kein Ändern des Status über die Schnittstelle'); +select throws_ok($$delete from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000001'$$, '42501', null, + 'KAT-05 Kriterium 9: auch mit app_metadata.role = admin kein Löschen über die Schnittstelle'); +reset role; + +set local role anon; +select throws_ok($$select count(*) from public.game_reports$$, '42501', null, + 'Sicherheit Kriterium 3: anon liest keine Meldungen'); +select throws_ok($$insert into public.game_reports (owner_id, title) values ('11111111-1111-1111-1111-111111111111', 'Neu')$$, '42501', null, + 'KAT-05 Kriterium 9: anon kann keine Meldung anlegen'); +select throws_ok($$update public.game_reports set status = 'rejected', resolved_at = now() where id = '7c1d2c3d-0000-4000-8000-000000000001'$$, '42501', null, + 'KAT-05 Kriterium 9: anon kann keine Meldung ändern'); +reset role; + +-- --------------------------------------------------------------------------------------------- +-- Checks (jeweils mit dem Namen der verletzten Regel, damit nicht ein anderer Fehler besteht) +-- --------------------------------------------------------------------------------------------- +select lives_ok($$insert into public.game_reports (id, owner_id, title, link, year, publisher_name) values + ('f8000000-0000-0000-0000-000000000a01', '22222222-2222-2222-2222-222222222222', 'Gültig https', 'https://example.org/x', 1800, repeat('v', 200)), + ('f8000000-0000-0000-0000-000000000a02', '22222222-2222-2222-2222-222222222222', repeat('t', 200), 'http://example.org/x', 2100, null)$$, + 'Gültige Meldungen (https/http, Jahresgrenzen, Längengrenzen) lassen sich anlegen'); +select throws_ok($$insert into public.game_reports (owner_id, title, origin) values ('22222222-2222-2222-2222-222222222222', 'X', 'bot')$$, + '23514', 'new row for relation "game_reports" violates check constraint "game_reports_origin_check"', 'Check: origin nur user/import'); +select throws_ok($$insert into public.game_reports (owner_id, title) values ('22222222-2222-2222-2222-222222222222', '')$$, + '23514', 'new row for relation "game_reports" violates check constraint "game_reports_title_check"', 'Check: Titel nicht leer'); +select throws_ok($$insert into public.game_reports (owner_id, title) values ('22222222-2222-2222-2222-222222222222', repeat('t', 201))$$, + '23514', 'new row for relation "game_reports" violates check constraint "game_reports_title_check"', 'Check: Titel höchstens 200 Zeichen'); +select throws_ok($$insert into public.game_reports (owner_id, title, publisher_name) values ('22222222-2222-2222-2222-222222222222', 'X', repeat('v', 201))$$, + '23514', 'new row for relation "game_reports" violates check constraint "game_reports_publisher_name_check"', 'Check: Verlag höchstens 200 Zeichen'); +select throws_ok($$insert into public.game_reports (owner_id, title, year) values ('22222222-2222-2222-2222-222222222222', 'X', 1799)$$, + '23514', 'new row for relation "game_reports" violates check constraint "game_reports_year_check"', 'Check: Jahr mindestens 1800'); +select throws_ok($$insert into public.game_reports (owner_id, title, year) values ('22222222-2222-2222-2222-222222222222', 'X', 2101)$$, + '23514', 'new row for relation "game_reports" violates check constraint "game_reports_year_check"', 'Check: Jahr höchstens 2100'); +select throws_ok($$insert into public.game_reports (owner_id, title, link) values ('22222222-2222-2222-2222-222222222222', 'X', 'ftp://example.org')$$, + '23514', 'new row for relation "game_reports" violates check constraint "game_reports_link_check"', 'Check: Link nur http(s)'); +select throws_ok($$insert into public.game_reports (owner_id, title, link) values ('22222222-2222-2222-2222-222222222222', 'X', 'https://' || repeat('a', 493))$$, + '23514', 'new row for relation "game_reports" violates check constraint "game_reports_link_check"', 'Check: Link höchstens 500 Zeichen'); +select throws_ok($$insert into public.game_reports (owner_id, title, status, resolved_at) values ('22222222-2222-2222-2222-222222222222', 'X', 'done', now())$$, + '23514', 'new row for relation "game_reports" violates check constraint "game_reports_status_check"', 'Check: Status nur open/approved/rejected/merged'); +select throws_ok($$insert into public.game_reports (owner_id, title, status, resolved_at) values ('22222222-2222-2222-2222-222222222222', 'X', 'open', now())$$, + '23514', 'new row for relation "game_reports" violates check constraint "game_reports_resolved_matches_status"', 'Check: offene Meldung hat kein resolved_at'); +select throws_ok($$insert into public.game_reports (owner_id, title, status) values ('22222222-2222-2222-2222-222222222222', 'X', 'rejected')$$, + '23514', 'new row for relation "game_reports" violates check constraint "game_reports_resolved_matches_status"', 'Check: erledigte Meldung braucht resolved_at'); +select throws_ok($$insert into public.game_reports (owner_id, title, target_game_id) values ('22222222-2222-2222-2222-222222222222', 'X', '7c1d2c3d-0000-4000-8000-000000000004')$$, + '23514', 'new row for relation "game_reports" violates check constraint "game_reports_open_has_no_target"', 'Check: offene Meldung hat kein Ziel'); +select throws_ok($$insert into public.game_reports (owner_id, title, status, resolved_at) values ('22222222-2222-2222-2222-222222222222', 'X', 'approved', now())$$, + '23514', 'new row for relation "game_reports" violates check constraint "game_reports_target_required"', 'Check: approved braucht ein Ziel'); +select throws_ok($$insert into public.game_reports (owner_id, title, status, resolved_at) values ('22222222-2222-2222-2222-222222222222', 'X', 'merged', now())$$, + '23514', 'new row for relation "game_reports" violates check constraint "game_reports_target_required"', 'Check: merged braucht ein Ziel'); +select lives_ok($$insert into public.game_reports (id, owner_id, title, status, resolved_at, target_game_id) values + ('f8000000-0000-0000-0000-000000000a03', '22222222-2222-2222-2222-222222222222', 'rejected mit Ziel', 'rejected', now(), '7c1d2c3d-0000-4000-8000-000000000004'), + ('f8000000-0000-0000-0000-000000000a04', '22222222-2222-2222-2222-222222222222', 'rejected ohne Ziel', 'rejected', now(), null)$$, + 'Positiv: rejected ist mit und ohne Ziel zulässig'); +select is((select origin || ':' || status from public.game_reports where id = 'f8000000-0000-0000-0000-000000000a01'), 'user:open', + 'Standardwerte: origin = user, status = open'); + +-- --------------------------------------------------------------------------------------------- +-- N12a: eine offene Import-Meldung je Nutzer und BGG-Kennung +-- --------------------------------------------------------------------------------------------- +insert into public.game_reports (id, owner_id, title, origin, bgg_id) values + ('f8000000-0000-0000-0000-000000000b01', '22222222-2222-2222-2222-222222222222', 'Import 1', 'import', 9001); +select throws_ok($$insert into public.game_reports (owner_id, title, origin, bgg_id) + values ('22222222-2222-2222-2222-222222222222', 'Import 1 doppelt', 'import', 9001)$$, '23505', null, + 'N12a: zweite offene Import-Meldung desselben Nutzers mit derselben BGG-Kennung wird abgelehnt'); +select lives_ok($$insert into public.game_reports (owner_id, title, origin, bgg_id) values + ('33333333-3333-3333-3333-333333333333', 'Import anderer Nutzer', 'import', 9001), + ('22222222-2222-2222-2222-222222222222', 'Import andere Kennung', 'import', 9002), + ('22222222-2222-2222-2222-222222222222', 'Von Hand dieselbe Kennung', 'user', 9001), + ('22222222-2222-2222-2222-222222222222', 'Von Hand dieselbe Kennung 2', 'user', 9001), + ('22222222-2222-2222-2222-222222222222', 'Import ohne Kennung', 'import', null), + ('22222222-2222-2222-2222-222222222222', 'Import ohne Kennung 2', 'import', null)$$, + 'N12a: anderer Nutzer, andere Kennung, origin = user und fehlende Kennung bleiben zulässig'); +insert into public.game_reports (id, owner_id, title, origin, bgg_id, status, resolved_at) values + ('f8000000-0000-0000-0000-000000000b02', '22222222-2222-2222-2222-222222222222', 'Import 1 erledigt', 'import', 9001, 'rejected', now()); +select lives_ok($$insert into public.game_reports (owner_id, title, origin, bgg_id) + values ('22222222-2222-2222-2222-222222222222', 'Import 1 neu', 'import', 9003)$$, + 'N12a: Vorbedingung für die nächste Prüfung'); +update public.game_reports set status = 'rejected', resolved_at = now() where id = 'f8000000-0000-0000-0000-000000000b01'; +select lives_ok($$insert into public.game_reports (owner_id, title, origin, bgg_id) + values ('22222222-2222-2222-2222-222222222222', 'Import 1 nach Erledigung', 'import', 9001)$$, + 'N12a: ist die frühere Meldung erledigt, darf eine neue offene für dieselbe Kennung angelegt werden'); +select is((select indexdef from pg_indexes where indexname = 'game_reports_open_idx'), + 'CREATE INDEX game_reports_open_idx ON public.game_reports USING btree (created_at) WHERE (status = ''open''::text)', + 'Index für die Admin-Liste (offene Meldungen nach Alter)'); +select is((select count(*) from pg_indexes where tablename = 'game_reports' and indexname in + ('game_reports_owner_created_idx', 'game_reports_provisional_game_id_key', 'game_reports_target_game_id_idx')), 3::bigint, + 'Indizes für Tageslimit und beide Fremdschlüssel auf games vorhanden'); +select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) + values ('11111111-1111-1111-1111-111111111111', 'Zweite Meldung zum selben Eintrag', '7c1d2c3d-0000-4000-8000-000000000003')$$, '23505', null, + 'Ein vorläufiger Eintrag gehört zu höchstens einer Meldung (1:1)'); +select lives_ok($$insert into public.game_reports (owner_id, title, target_game_id, status, resolved_at) values + ('22222222-2222-2222-2222-222222222222', 'Gleiches Ziel 1', '7c1d2c3d-0000-4000-8000-000000000004', 'merged', now()), + ('22222222-2222-2222-2222-222222222222', 'Gleiches Ziel 2', '7c1d2c3d-0000-4000-8000-000000000004', 'merged', now())$$, + 'Mehrere Meldungen dürfen dasselbe Ziel haben, und mehrere dürfen ohne vorläufigen Eintrag sein'); + +-- --------------------------------------------------------------------------------------------- +-- Fremdschlüssel-Regeln +-- --------------------------------------------------------------------------------------------- +insert into public.games (id, title, visibility, owner_id) values + ('f8000000-0000-0000-0000-000000000c01', 'Wird gelöscht', 'private', '22222222-2222-2222-2222-222222222222'); +insert into public.game_reports (id, owner_id, title, provisional_game_id) values + ('f8000000-0000-0000-0000-000000000c02', '22222222-2222-2222-2222-222222222222', 'Vorläufiger Eintrag verschwindet', 'f8000000-0000-0000-0000-000000000c01'); +delete from public.games where id = 'f8000000-0000-0000-0000-000000000c01'; +select is((select count(*) || ':' || count(provisional_game_id) from public.game_reports where id = 'f8000000-0000-0000-0000-000000000c02'), '1:0', + 'Fremdschlüssel: wird der vorläufige Eintrag gelöscht, bleibt die Meldung und der Verweis wird leer (on delete set null)'); +select throws_ok($$delete from public.games where id = '7c1d2c3d-0000-4000-8000-000000000004'$$, '23503', null, + 'Fremdschlüssel: ein Ziel mit Verweis aus einer Meldung lässt sich nicht löschen (on delete restrict)'); +select throws_ok($$insert into public.game_reports (owner_id, title) values (gen_random_uuid(), 'Ohne Konto')$$, '23503', null, + 'Fremdschlüssel: Meldung braucht ein vorhandenes Profil'); + +-- --------------------------------------------------------------------------------------------- +-- Trigger games_visibility_immutable (E14), KAT-05 Kriterium 9 +-- --------------------------------------------------------------------------------------------- +select is_empty($$select p.proname, r from pg_proc p join pg_namespace n on n.oid = p.pronamespace, + unnest(array['anon', 'authenticated', 'service_role']) as r + where n.nspname = 'public' and p.proname in ('games_visibility_immutable', 'admin_resolve_report') + and has_function_privilege(r, p.oid, 'execute')$$, + 'KAT-05 Kriterium 9: games_visibility_immutable und admin_resolve_report sind für anon, authenticated und service_role nicht ausführbar'); +select is((select count(*) from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname in ('games_visibility_immutable', 'admin_resolve_report', 'admin_merge_games') + and not p.prosecdef and p.proconfig = array['search_path=""']), 3::bigint, + 'Alle drei Funktionen sind security invoker mit leerem search_path'); + +-- als postgres: private -> Katalog (mit allem, was der Check games_owner_matches_visibility verlangt) +select throws_ok($$update public.games set visibility = 'catalog', owner_id = null where id = '7c1d2c3d-0000-4000-8000-000000000003'$$, + 'P0001', 'visibility_immutable', 'KAT-05 Kriterium 9: auch postgres kann einen privaten Eintrag nicht öffentlich schalten'); +select throws_ok($$update public.games set visibility = 'catalog' where id = '7c1d2c3d-0000-4000-8000-000000000003'$$, + 'P0001', 'visibility_immutable', 'KAT-05 Kriterium 9: auch nur visibility allein wird abgelehnt'); +select throws_ok($$update public.games set visibility = 'private', owner_id = '11111111-1111-1111-1111-111111111111' where id = '7c1d2c3d-0000-4000-8000-000000000004'$$, + 'P0001', 'visibility_immutable', 'KAT-05 Kriterium 9: auch postgres kann ein Katalogspiel nicht privat schalten'); +select throws_ok($$update public.games set owner_id = '22222222-2222-2222-2222-222222222222' where id = '7c1d2c3d-0000-4000-8000-000000000003'$$, + 'P0001', 'visibility_immutable', 'KAT-05 Kriterium 9: der Eigentümer eines privaten Spiels ist unveränderlich'); +select throws_ok($$update public.games set owner_id = null where id = '7c1d2c3d-0000-4000-8000-000000000003'$$, + 'P0001', 'visibility_immutable', 'KAT-05 Kriterium 9: auch ein leerer Eigentümer wird abgelehnt'); +select is((select visibility || ':' || owner_id::text from public.games where id = '7c1d2c3d-0000-4000-8000-000000000003'), + 'private:11111111-1111-1111-1111-111111111111', 'Nach den Fehlern ist das Spiel unverändert'); +select lives_ok($$update public.games set title = 'Meldungsspiel', visibility = 'private' where id = '7c1d2c3d-0000-4000-8000-000000000003'$$, + 'Positiv: dieselbe Sichtbarkeit nochmals zu setzen ist keine Änderung'); +select lives_ok($$update public.games set year = 2022 where id = '7c1d2c3d-0000-4000-8000-000000000004'$$, + 'Positiv: andere Felder eines Katalogspiels bleiben als postgres änderbar'); +update public.games set year = null where id = '7c1d2c3d-0000-4000-8000-000000000004'; + +-- als Nutzer: der Trigger läuft vor der RLS-Prüfung, deshalb P0001 statt 42501 +select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true); +set local role authenticated; +select throws_ok($$update public.games set visibility = 'catalog' where id = '7c1d2c3d-0000-4000-8000-000000000003'$$, + 'P0001', 'visibility_immutable', 'KAT-05 Kriterium 9: Anna kann ihren privaten Eintrag nicht öffentlich schalten (Trigger vor RLS)'); +select lives_ok($$update public.games set title = 'Meldungsspiel (neu)' where id = '7c1d2c3d-0000-4000-8000-000000000003'$$, + 'Positiv: Anna ändert den Titel ihres privaten Eintrags weiterhin'); +update public.games set title = 'Meldungsspiel' where id = '7c1d2c3d-0000-4000-8000-000000000003'; +reset role; +select is((select visibility from public.games where id = '7c1d2c3d-0000-4000-8000-000000000003'), 'private', + 'Annas Eintrag ist weiterhin privat'); + +-- --------------------------------------------------------------------------------------------- +-- admin_resolve_report: Rechte (KAT-05 Kriterium 9) +-- --------------------------------------------------------------------------------------------- +select set_config('request.jwt.claims', + '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated","app_metadata":{"role":"admin"}}', true); +set local role authenticated; +select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'rejected')$$, '42501', null, + 'KAT-05 Kriterium 9: authenticated (auch mit app_metadata.role = admin) kann admin_resolve_report nicht aufrufen'); +reset role; +set local role anon; +select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'rejected')$$, '42501', null, + 'KAT-05 Kriterium 9: anon kann admin_resolve_report nicht aufrufen'); +reset role; +set local role service_role; +select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'rejected')$$, '42501', null, + 'KAT-05 Kriterium 9: service_role kann admin_resolve_report nicht aufrufen'); +reset role; +select is((select status from public.game_reports where id = '7c1d2c3d-0000-4000-8000-00000000000b'), 'open', + 'Die verweigerten Aufrufe haben nichts verändert'); + +-- Die Rollenprüfung im Funktionskörper: eine Rolle MIT execute-Recht, aber ohne postgres/supabase_admin +create role report_tester nologin; +grant report_tester to postgres with set true; +grant usage on schema public to report_tester; +grant execute on function public.admin_resolve_report(uuid, text, uuid) to report_tester; +set local role report_tester; +do $$ +begin + perform public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'rejected'); + perform set_config('t.resolve_err', 'kein Fehler', true); +exception when others then + perform set_config('t.resolve_err', sqlstate || ':' || sqlerrm, true); +end +$$; +reset role; +select is(current_setting('t.resolve_err'), 'P0001:admin_only', + 'KAT-05 Kriterium 9: admin_resolve_report prüft die Datenbankrolle (admin_only)'); +select is((select status from public.game_reports where id = '7c1d2c3d-0000-4000-8000-00000000000b'), 'open', + 'admin_only ändert nichts'); + +-- --------------------------------------------------------------------------------------------- +-- admin_resolve_report: Fehlerfälle (alle ohne Änderung) +-- --------------------------------------------------------------------------------------------- +insert into public.games (id, title, visibility, hidden_at) values + ('f8000000-0000-0000-0000-000000000d01', 'Ziel versteckt', 'catalog', '2020-01-01'); +insert into public.games (id, title, visibility, hidden_at, merged_into_id) values + ('f8000000-0000-0000-0000-000000000d02', 'Ziel zusammengeführt', 'catalog', '2020-01-01', '7c1d2c3d-0000-4000-8000-000000000004'); +insert into public.games (id, title, visibility, owner_id) values + ('f8000000-0000-0000-0000-000000000d03', 'Ziel privat', 'private', '22222222-2222-2222-2222-222222222222'); +create temp table rb_before as +select (select count(*) from public.game_reports where status <> 'open') as erledigt, + (select count(*) from public.games where hidden_at is not null) as ausgeblendet, + (select string_agg(id::text || target_game_id::text || status, ',' order by id) from public.game_reports where id::text like '7c1d2c3d%') as meldungen; + +select throws_ok($$select public.admin_resolve_report(gen_random_uuid(), 'rejected')$$, 'P0001', 'report_not_found', + 'KAT-05 Kriterium 5: unbekannte Meldung (report_not_found)'); +select throws_ok($$select public.admin_resolve_report(null, 'rejected')$$, 'P0001', 'report_not_found', + 'KAT-05 Kriterium 5: fehlende Meldung (null) ist report_not_found'); +select throws_ok($$select public.admin_resolve_report('f8000000-0000-0000-0000-000000000a04', 'rejected')$$, 'P0001', 'report_not_open', + 'KAT-05 Kriterium 5: bereits abgelehnte Meldung (report_not_open)'); +select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'open')$$, 'P0001', 'report_status_invalid', + 'KAT-05 Kriterium 5: Status open ist kein Ergebnis (report_status_invalid)'); +select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'fertig')$$, 'P0001', 'report_status_invalid', + 'KAT-05 Kriterium 5: unbekannter Status (report_status_invalid)'); +select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', null)$$, 'P0001', 'report_status_invalid', + 'KAT-05 Kriterium 5: fehlender Status (null) ist report_status_invalid'); +select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'fertig', null)$$, 'P0001', 'report_status_invalid', + 'KAT-05 Kriterium 5: ungültiger Status geht vor fehlendem Ziel'); +select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'approved')$$, 'P0001', 'report_target_required', + 'KAT-05 Kriterium 5: approved ohne Ziel (report_target_required)'); +select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'merged', null)$$, 'P0001', 'report_target_required', + 'KAT-05 Kriterium 5: merged ohne Ziel (report_target_required)'); +select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'approved', 'f8000000-0000-0000-0000-000000000d01')$$, + 'P0001', 'report_target_not_listed', 'KAT-05 Kriterium 5: ausgeblendetes Ziel (report_target_not_listed)'); +select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'merged', 'f8000000-0000-0000-0000-000000000d02')$$, + 'P0001', 'report_target_not_listed', 'KAT-05 Kriterium 5: zusammengeführtes Ziel (report_target_not_listed)'); +select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'approved', 'f8000000-0000-0000-0000-000000000d03')$$, + 'P0001', 'report_target_not_listed', 'KAT-05 Kriterium 5: privates Ziel (report_target_not_listed)'); +select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'approved', 'f8000000-0000-0000-0000-0000000000ff')$$, + 'P0001', 'report_target_not_listed', 'KAT-05 Kriterium 5: unbekanntes Ziel (report_target_not_listed)'); +select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'rejected', 'f8000000-0000-0000-0000-000000000d01')$$, + 'P0001', 'report_target_not_listed', 'KAT-05 Kriterium 5: auch bei rejected wird das Ziel geprüft'); + +-- Datenfehler: vorläufiger Eintrag ist ein Katalogspiel (Zusatzprüfung gegenüber der Spec) +insert into public.game_reports (id, owner_id, title, provisional_game_id) values + ('f8000000-0000-0000-0000-000000000e01', '22222222-2222-2222-2222-222222222222', 'Vorläufig ist Katalog', 'f8000000-0000-0000-0000-00000000000e'); +select throws_ok($$select public.admin_resolve_report('f8000000-0000-0000-0000-000000000e01', 'merged', '7c1d2c3d-0000-4000-8000-000000000004')$$, + 'P0001', 'report_provisional_not_private', 'Zusatzprüfung: ein Katalogspiel als vorläufiger Eintrag wird nicht umgehängt'); +select is((select status from public.game_reports where id = 'f8000000-0000-0000-0000-000000000e01'), 'open', + 'Nach report_provisional_not_private ist die Meldung weiterhin offen'); +select is((select hidden_at is null and merged_into_id is null from public.games where id = 'f8000000-0000-0000-0000-00000000000e'), true, + 'Das Katalogspiel wurde nicht verändert'); +select is((select row(erledigt, ausgeblendet, meldungen)::text from rb_before), + (select row((select count(*) from public.game_reports where status <> 'open'), + (select count(*) from public.games where hidden_at is not null), + (select string_agg(id::text || target_game_id::text || status, ',' order by id) from public.game_reports where id::text like '7c1d2c3d%'))::text), + 'Nach allen Fehlerfällen ist nichts verändert (Meldungen, Ausblendungen)'); + +-- --------------------------------------------------------------------------------------------- +-- Runbook Abschnitt 6, Schritt 1 und 2 (SQL-Bausteine unverändert) +-- --------------------------------------------------------------------------------------------- +-- Vorbereitung für Schritt 2 (ähnliche Spiele): Titel gleich bzw. BGG-Kennung 4711 am Katalogspiel +-- (Fixtures oben: MELDUNGSSPIEL, Anderer Titel) + +-- Schritt 1: Offene Meldungen +create temp table rb_open as +select r.id, r.created_at, r.origin, r.title, r.publisher_name, r.year, r.link, r.bgg_id, r.provisional_game_id +from public.game_reports r +where r.status = 'open' +order by r.created_at, r.id; +select results_eq($$select id from rb_open where id::text like '7c1d2c3d%' order by created_at, id$$, + $$values ('7c1d2c3d-0000-4000-8000-000000000001'::uuid), ('7c1d2c3d-0000-4000-8000-000000000006'), ('7c1d2c3d-0000-4000-8000-000000000005'), + ('7c1d2c3d-0000-4000-8000-000000000007'), ('7c1d2c3d-0000-4000-8000-00000000000b')$$, + 'KAT-05 Kriterium 8: Runbook 6, offene Meldungen (älteste zuerst)'); +select is((select row(origin, title, publisher_name, year, link, bgg_id, provisional_game_id)::text from rb_open where id = '7c1d2c3d-0000-4000-8000-000000000001'), + '(user,Meldungsspiel,Beispielverlag,2024,https://example.org/spiel,4711,7c1d2c3d-0000-4000-8000-000000000003)', + 'KAT-05 Kriterium 8: Runbook 6, die Spalten der Meldung stehen in der Liste'); + +-- Schritt 2: Ähnliche Spiele im Katalog +create temp table rb_similar as +select g.id, g.title, g.year, g.bgg_id +from public.game_reports r +join public.catalog_games g + on lower(btrim(g.title)) = lower(btrim(r.title)) + or (r.bgg_id is not null and g.bgg_id = r.bgg_id) +where r.id = '7c1d2c3d-0000-4000-8000-000000000001' +order by g.title, g.id; +select results_eq($$select id, title, year, bgg_id from rb_similar$$, + $$values ('f8000000-0000-0000-0000-00000000000d'::uuid, 'Anderer Titel'::text, 2020::smallint, 4711::integer), + ('f8000000-0000-0000-0000-00000000000c', 'MELDUNGSSPIEL', 2023::smallint, null::integer)$$, + 'KAT-05 Kriterium 8: Runbook 6, ähnliche Spiele (gleicher Titel ohne Groß-/Kleinschreibung oder gleiche BGG-Kennung)'); + +-- Schritt 2: Was hängt am vorläufigen Eintrag? +create temp table rb_counts as +select g.id, g.title, + (select count(*) from public.collection_items c where c.game_id = g.id) as sammlung, + (select count(*) from public.favorite_games f where f.game_id = g.id) as favoriten, + (select count(*) from public.plays p where p.game_id = g.id) as partien, + (select count(*) from public.score_sheets s where s.game_id = g.id) as wertungsboegen +from public.game_reports r +join public.games g on g.id = r.provisional_game_id +where r.id = '7c1d2c3d-0000-4000-8000-000000000001'; +select results_eq($$select id, title, sammlung, favoriten, partien, wertungsboegen from rb_counts$$, + $$values ('7c1d2c3d-0000-4000-8000-000000000003'::uuid, 'Meldungsspiel'::text, 1::bigint, 1::bigint, 2::bigint, 1::bigint)$$, + 'KAT-05 Kriterium 8: Runbook 6, Anzahlen am vorläufigen Eintrag'); + +-- Schritt 2: Editionen des vorläufigen Eintrags +create temp table rb_editions as +select v.id, v.name, v.year, v.language +from public.game_reports r +join public.game_variants v on v.game_id = r.provisional_game_id +where r.id = '7c1d2c3d-0000-4000-8000-000000000001' +order by v.name, v.id; +select results_eq($$select id, name from rb_editions$$, + $$values ('f8000000-0000-0000-0000-0000000000e1'::uuid, 'Deluxe'::text), ('f8000000-0000-0000-0000-0000000000e2', 'Prototyp')$$, + 'KAT-05 Kriterium 8: Runbook 6, Namen der Editionen des vorläufigen Eintrags'); + +-- --------------------------------------------------------------------------------------------- +-- Runbook Abschnitt 6, Schritt 3a: Freigeben (neuer Katalogeintrag, nur Fakten aus der Meldung) +-- --------------------------------------------------------------------------------------------- +insert into public.games (id, title, year, description, description_locale, visibility) +values ('7c1d2c3d-0000-4000-8000-000000000002', 'Meldungsspiel', 2024, 'Eigener Kurztext des Admins.', 'de', 'catalog'); + +insert into public.publishers (name) values ('Beispielverlag') on conflict ((lower(name))) do nothing; +insert into public.game_publishers (game_id, publisher_id) +select '7c1d2c3d-0000-4000-8000-000000000002', p.id from public.publishers p where lower(p.name) = lower('Beispielverlag'); + +insert into public.game_variants (game_id, name, year) values ('7c1d2c3d-0000-4000-8000-000000000002', 'Deluxe', 2024); + +select is((select manual_fields from public.games where id = '7c1d2c3d-0000-4000-8000-000000000002'), + array['description', 'description_locale', 'publishers', 'title', 'year'], + 'KAT-05 Kriterium 8: Runbook 6, der neue Katalogeintrag vermerkt seine Felder in manual_fields'); + +-- Ben hat das neue Spiel nicht; Bens Daten am vorhandenen Ziel (…04) merken, sie dürfen sich nicht ändern +create temp table ben_before as +select c.id, c.game_id, c.variant_id, c.list, c.note from public.collection_items c where c.owner_id = '22222222-2222-2222-2222-222222222222' +union all +select p.id, p.game_id, p.variant_id, 'play', null from public.plays p where p.owner_id = '22222222-2222-2222-2222-222222222222'; + +create temp table rb_approve as +select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000001', 'approved', '7c1d2c3d-0000-4000-8000-000000000002') as r; +select is((select r - 'report_id' from rb_approve), + jsonb_build_object( + 'status', 'approved', 'target_id', '7c1d2c3d-0000-4000-8000-000000000002', + 'provisional_game_id', '7c1d2c3d-0000-4000-8000-000000000003', 'repointed', true, + 'collection_items', 1, 'collection_conflicts', 0, 'favorite_games', 1, 'favorite_conflicts', 0, + 'plays', 2, 'score_sheets', 1, 'variants_copied', 0, 'variants_mapped', 1, 'variants_cleared', 1), + 'KAT-05 Kriterium 5: Runbook 6, approved: Ergebnis-JSON zählt alles richtig (nichts kopiert, eine Edition abgebildet, eine geleert)'); +select is((select r ->> 'report_id' from rb_approve), '7c1d2c3d-0000-4000-8000-000000000001', 'Das Ergebnis nennt die Meldung'); + +-- Schritt 4: Nachprüfen +create temp table rb_check as +select r.id, r.status, r.target_game_id, r.resolved_at is not null as erledigt, + g.hidden_at is not null as vorlaeufig_ausgeblendet, g.merged_into_id +from public.game_reports r +left join public.games g on g.id = r.provisional_game_id +where r.id = '7c1d2c3d-0000-4000-8000-000000000001'; +select results_eq($$select id, status, target_game_id, erledigt, vorlaeufig_ausgeblendet, merged_into_id from rb_check$$, + $$values ('7c1d2c3d-0000-4000-8000-000000000001'::uuid, 'approved'::text, '7c1d2c3d-0000-4000-8000-000000000002'::uuid, true, true, '7c1d2c3d-0000-4000-8000-000000000002'::uuid)$$, + 'KAT-05 Kriterium 8: Runbook 6, Nachprüfen: Meldung freigegeben, vorläufiger Eintrag ausgeblendet und verwiesen'); + +select is((select row(visibility, owner_id)::text from public.games where id = '7c1d2c3d-0000-4000-8000-000000000003'), + '(private,11111111-1111-1111-1111-111111111111)', 'KAT-05 Kriterium 5: der vorläufige Eintrag bleibt privat und gehört weiter Anna (E14)'); +select is((select row(description, image_path)::text from public.games where id = '7c1d2c3d-0000-4000-8000-000000000002'), + '("Eigener Kurztext des Admins.",)', 'Beschreibung und Bild des vorläufigen Eintrags werden nie übernommen'); +select is((select row(visibility, owner_id, hidden_at is null, merged_into_id is null)::text from public.games where id = '7c1d2c3d-0000-4000-8000-000000000002'), + '(catalog,,t,t)', 'Das neue Katalogspiel ist sichtbar und gehört niemandem'); +select is((select count(*) from public.catalog_games where id = '7c1d2c3d-0000-4000-8000-000000000002'), 1::bigint, + 'Das neue Katalogspiel erscheint in catalog_games'); +select is((select count(*) from public.catalog_games where id = '7c1d2c3d-0000-4000-8000-000000000003'), 0::bigint, + 'Der vorläufige Eintrag erscheint nie in catalog_games'); +select is((select resolved_at is not null and updated_at >= created_at from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000001'), true, + 'resolved_at ist gesetzt'); + +-- Verweise +select is((select row(game_id, variant_id, list, note)::text from public.collection_items where id = 'f8000000-0000-0000-0000-0000000000c1'), + '(7c1d2c3d-0000-4000-8000-000000000002,,owned,"Annas Notiz")', + 'RB1: Sammlungseintrag am neuen Spiel, Edition "Prototyp" ohne Gegenstück ist geleert'); +select is((select row(p.game_id, v.name, v.game_id)::text from public.plays p join public.game_variants v on v.id = p.variant_id + where p.id = 'f8000000-0000-0000-0000-0000000000b1'), + '(7c1d2c3d-0000-4000-8000-000000000002,Deluxe,7c1d2c3d-0000-4000-8000-000000000002)', + 'RB1: die Edition "Deluxe" der Partie zeigt auf die vom Admin angelegte gleichnamige Variante am Katalogeintrag'); +select is((select row(game_id, variant_id)::text from public.plays where id = 'f8000000-0000-0000-0000-0000000000b2'), + '(7c1d2c3d-0000-4000-8000-000000000002,)', 'Partie ohne Edition wechselt auf das neue Spiel'); +select is((select game_id from public.favorite_games where id = 'f8000000-0000-0000-0000-0000000000d1'), '7c1d2c3d-0000-4000-8000-000000000002'::uuid, + 'Favorit wechselt auf das neue Spiel'); +select is((select game_id from public.score_sheets where id = 'f8000000-0000-0000-0000-0000000000b5'), '7c1d2c3d-0000-4000-8000-000000000002'::uuid, + 'Wertungsbogen wechselt auf das neue Spiel'); +select is((select count(*) from public.game_variants where game_id = '7c1d2c3d-0000-4000-8000-000000000002'), 1::bigint, + 'RB1: am Katalogeintrag gibt es nur die vom Admin angelegte Variante, die private wurde nicht kopiert'); +select is((select count(*) from public.game_variants where game_id = '7c1d2c3d-0000-4000-8000-000000000003'), 2::bigint, + 'RB1: die privaten Varianten bleiben am vorläufigen Eintrag'); +select is((select count(*) from public.collection_items where game_id = '7c1d2c3d-0000-4000-8000-000000000003'), 0::bigint, + 'Nichts hängt mehr am vorläufigen Eintrag'); + +-- --------------------------------------------------------------------------------------------- +-- Runbook Abschnitt 6, Schritt 3b: Zusammenführen mit einem vorhandenen Eintrag (Kollision) +-- --------------------------------------------------------------------------------------------- +create temp table rb_merged as +select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000005', 'merged', '7c1d2c3d-0000-4000-8000-000000000004') as r; +select is((select r - 'report_id' from rb_merged), + jsonb_build_object( + 'status', 'merged', 'target_id', '7c1d2c3d-0000-4000-8000-000000000004', + 'provisional_game_id', '7c1d2c3d-0000-4000-8000-000000000008', 'repointed', true, + 'collection_items', 0, 'collection_conflicts', 1, 'favorite_games', 0, 'favorite_conflicts', 0, + 'plays', 0, 'score_sheets', 0, 'variants_copied', 0, 'variants_mapped', 1, 'variants_cleared', 0), + 'KAT-05 Kriterium 5: Runbook 6, merged: Kollision (Anna hatte das Ziel schon), gleichnamige Variante abgebildet'); +select is((select row(status, target_game_id)::text from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000005'), + '(merged,7c1d2c3d-0000-4000-8000-000000000004)', 'merged: Status und Ziel gespeichert'); +select is((select row(hidden_at is not null, merged_into_id)::text from public.games where id = '7c1d2c3d-0000-4000-8000-000000000008'), + '(t,7c1d2c3d-0000-4000-8000-000000000004)', 'merged: der vorläufige Eintrag ist ausgeblendet und verwiesen'); +select is((select row(list, note, variant_id)::text from public.collection_items where id = 'f8000000-0000-0000-0000-0000000000c3'), + '(owned,"Annas Wunsch",f8000000-0000-0000-0000-0000000000a4)', + 'Spec-F1: Anna: der Zieleintrag gewinnt (owned vor wishlist), die leere Notiz und Edition füllt der vorläufige Eintrag (Edition über die gleichnamige Variante)'); +select is((select count(*) from public.collection_items where id = 'f8000000-0000-0000-0000-0000000000c2'), 0::bigint, + 'Spec-F1: der Quell-Eintrag der Kollision ist gelöscht'); +select is((select count(*) from public.game_variants where game_id = '7c1d2c3d-0000-4000-8000-000000000004'), 1::bigint, + 'RB1: das vorhandene Katalogspiel bekommt keine Variante'); +select is((select count(*) from public.game_variants where game_id = '7c1d2c3d-0000-4000-8000-000000000008'), 1::bigint, + 'RB1: die private Variante bleibt am vorläufigen Eintrag'); +select results_eq($$select c.id, c.game_id, c.variant_id, c.list, c.note from public.collection_items c + where c.owner_id = '22222222-2222-2222-2222-222222222222' + union all select p.id, p.game_id, p.variant_id, 'play', null from public.plays p where p.owner_id = '22222222-2222-2222-2222-222222222222'$$, + $$select id, game_id, variant_id, list, note from ben_before$$, + 'Daten anderer Nutzer am Ziel (Bens Sammlung und Partie) sind unverändert'); + +-- --------------------------------------------------------------------------------------------- +-- Runbook Abschnitt 6, Schritt 3c: Ablehnen +-- --------------------------------------------------------------------------------------------- +create temp table rb_rej_snapshot as +select 'c5' as k, game_id::text || coalesce(variant_id::text, '') || list as v from public.collection_items where id = 'f8000000-0000-0000-0000-0000000000c5' +union all select 'c6', game_id::text || coalesce(variant_id::text, '') || list from public.collection_items where id = 'f8000000-0000-0000-0000-0000000000c6' +union all select 'b4', game_id::text from public.plays where id = 'f8000000-0000-0000-0000-0000000000b4' +union all select 'g9', visibility || coalesce(hidden_at::text, '') || coalesce(merged_into_id::text, '') from public.games where id = '7c1d2c3d-0000-4000-8000-000000000009' +union all select 'ga', visibility || coalesce(hidden_at::text, '') || coalesce(merged_into_id::text, '') from public.games where id = '7c1d2c3d-0000-4000-8000-00000000000a'; + +create temp table rb_rej1 as +select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000006', 'rejected') as r; +create temp table rb_rej2 as +select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000007', 'rejected', '7c1d2c3d-0000-4000-8000-000000000004') as r; +select is((select r - 'report_id' from rb_rej1), + '{"status":"rejected","target_id":null,"provisional_game_id":"7c1d2c3d-0000-4000-8000-000000000009","repointed":false}'::jsonb, + 'KAT-05 Kriterium 5: Runbook 6, rejected ohne Ziel: nichts umgehängt, keine Zähler'); +select is((select r - 'report_id' from rb_rej2), + '{"status":"rejected","target_id":"7c1d2c3d-0000-4000-8000-000000000004","provisional_game_id":"7c1d2c3d-0000-4000-8000-00000000000a","repointed":false}'::jsonb, + 'KAT-05 Kriterium 5: Runbook 6, rejected mit Ziel: nur der Verweis, nichts umgehängt'); +select is((select row(status, target_game_id, resolved_at is not null)::text from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000006'), + '(rejected,,t)', 'rejected ohne Ziel: Status und Zeitpunkt gespeichert, kein Ziel'); +select is((select row(status, target_game_id, resolved_at is not null)::text from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000007'), + '(rejected,7c1d2c3d-0000-4000-8000-000000000004,t)', 'rejected mit Ziel: der Verweis auf den Zieleintrag ist gespeichert (Spec-F3)'); +select results_eq($$select 'c5', game_id::text || coalesce(variant_id::text, '') || list from public.collection_items where id = 'f8000000-0000-0000-0000-0000000000c5' + union all select 'c6', game_id::text || coalesce(variant_id::text, '') || list from public.collection_items where id = 'f8000000-0000-0000-0000-0000000000c6' + union all select 'b4', game_id::text from public.plays where id = 'f8000000-0000-0000-0000-0000000000b4' + union all select 'g9', visibility || coalesce(hidden_at::text, '') || coalesce(merged_into_id::text, '') from public.games where id = '7c1d2c3d-0000-4000-8000-000000000009' + union all select 'ga', visibility || coalesce(hidden_at::text, '') || coalesce(merged_into_id::text, '') from public.games where id = '7c1d2c3d-0000-4000-8000-00000000000a'$$, + $$select k, v from rb_rej_snapshot$$, + 'KAT-05 Kriterium 5: bei rejected (mit und ohne Ziel) bleiben vorläufige Einträge, Sammlung und Partien unverändert'); + +-- approved ohne vorläufigen Eintrag: nur Status und Ziel +create temp table rb_noprov as +select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'approved', '7c1d2c3d-0000-4000-8000-000000000004') as r; +select is((select r - 'report_id' from rb_noprov), + '{"status":"approved","target_id":"7c1d2c3d-0000-4000-8000-000000000004","provisional_game_id":null,"repointed":false}'::jsonb, + 'approved ohne vorläufigen Eintrag: nichts umzuhängen (repointed = false)'); +select is((select row(status, target_game_id)::text from public.game_reports where id = '7c1d2c3d-0000-4000-8000-00000000000b'), + '(approved,7c1d2c3d-0000-4000-8000-000000000004)', 'approved ohne vorläufigen Eintrag: Status und Ziel gespeichert'); +select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000001', 'rejected')$$, 'P0001', 'report_not_open', + 'KAT-05 Kriterium 5: eine freigegebene Meldung lässt sich nicht erneut bearbeiten'); + +-- Der Nutzer sieht das Ergebnis seiner Meldung (KAT-04 Krit. 4-6), aber keine fremden +select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true); +set local role authenticated; +select is((select status || ':' || target_game_id::text from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000001'), + 'approved:7c1d2c3d-0000-4000-8000-000000000002', 'Anna sieht das Ergebnis ihrer Meldung'); +select is((select count(*) from public.game_reports where owner_id <> '11111111-1111-1111-1111-111111111111'), 0::bigint, + 'Anna sieht weiterhin keine fremden Meldungen'); +select cmp_ok((select count(*) from public.games where id = '7c1d2c3d-0000-4000-8000-000000000003'), '=', 1::bigint, + 'Anna sieht ihren ausgeblendeten vorläufigen Eintrag weiterhin'); +reset role; + +-- --------------------------------------------------------------------------------------------- +-- admin_merge_games nach dem create or replace (Schritt 4: Meldungen umhängen) und Rechte +-- --------------------------------------------------------------------------------------------- +select is_empty($$select p.proname, r from pg_proc p join pg_namespace n on n.oid = p.pronamespace, + unnest(array['anon', 'authenticated', 'service_role']) as r + where n.nspname = 'public' and p.proname in ('admin_merge_games', 'repoint_game_references') + and has_function_privilege(r, p.oid, 'execute')$$, + 'KAT-05 Kriterium 9: nach dem create or replace sind admin_merge_games und repoint_game_references für anon, authenticated und service_role nicht ausführbar'); +select set_config('request.jwt.claims', + '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated","app_metadata":{"role":"admin"}}', true); +set local role authenticated; +select throws_ok($$select public.admin_merge_games(gen_random_uuid(), gen_random_uuid())$$, '42501', null, + 'KAT-05 Kriterium 9: authenticated (auch mit app_metadata.role = admin) kann admin_merge_games weiterhin nicht aufrufen'); +reset role; +set local role anon; +select throws_ok($$select public.admin_merge_games(gen_random_uuid(), gen_random_uuid())$$, '42501', null, + 'KAT-05 Kriterium 9: anon kann admin_merge_games weiterhin nicht aufrufen'); +reset role; +set local role service_role; +select throws_ok($$select public.admin_merge_games(gen_random_uuid(), gen_random_uuid())$$, '42501', null, + 'KAT-05 Kriterium 9: service_role kann admin_merge_games weiterhin nicht aufrufen'); +reset role; +create role merge_tester nologin; +grant merge_tester to postgres with set true; +grant usage on schema public to merge_tester; +grant execute on function public.admin_merge_games(uuid, uuid) to merge_tester; +set local role merge_tester; +do $$ +begin + perform public.admin_merge_games('f8000000-0000-0000-0000-000000000f01', 'f8000000-0000-0000-0000-000000000f02'); + perform set_config('t.merge_err', 'kein Fehler', true); +exception when others then + perform set_config('t.merge_err', sqlstate || ':' || sqlerrm, true); +end +$$; +reset role; +select is(current_setting('t.merge_err'), 'P0001:admin_only', + 'KAT-05 Kriterium 9: admin_merge_games prüft nach dem create or replace weiterhin die Datenbankrolle'); + +-- Schritt 4: Quelle (…f01) -> Ziel (…f02); Meldungen mit Ziel = Quelle ziehen mit +insert into public.games (id, title, visibility) values + ('f8000000-0000-0000-0000-000000000f01', 'Merge Quelle', 'catalog'), + ('f8000000-0000-0000-0000-000000000f02', 'Merge Ziel', 'catalog'), + ('f8000000-0000-0000-0000-000000000f03', 'Anderes Ziel', 'catalog'); +insert into public.game_reports (id, owner_id, title, status, resolved_at, target_game_id) values + ('f8000000-0000-0000-0000-000000000f11', '22222222-2222-2222-2222-222222222222', 'Freigegeben auf Quelle', 'approved', now(), 'f8000000-0000-0000-0000-000000000f01'), + ('f8000000-0000-0000-0000-000000000f12', '22222222-2222-2222-2222-222222222222', 'Abgelehnt mit Verweis auf Quelle', 'rejected', now(), 'f8000000-0000-0000-0000-000000000f01'), + ('f8000000-0000-0000-0000-000000000f13', '22222222-2222-2222-2222-222222222222', 'Zusammengeführt mit anderem', 'merged', now(), 'f8000000-0000-0000-0000-000000000f03'); +insert into public.game_reports (id, owner_id, title) values + ('f8000000-0000-0000-0000-000000000f14', '22222222-2222-2222-2222-222222222222', 'Offen'); +create temp table rb_mg as select public.admin_merge_games('f8000000-0000-0000-0000-000000000f01', 'f8000000-0000-0000-0000-000000000f02') as r; +select is((select r -> 'reports_repointed' from rb_mg), '2'::jsonb, 'KAT-05 Kriterium 4: admin_merge_games meldet 2 umgehängte Meldungen'); +select results_eq($$select id, target_game_id from public.game_reports where id::text like 'f8000000-0000-0000-0000-000000000f1%' order by id$$, + $$values ('f8000000-0000-0000-0000-000000000f11'::uuid, 'f8000000-0000-0000-0000-000000000f02'::uuid), + ('f8000000-0000-0000-0000-000000000f12', 'f8000000-0000-0000-0000-000000000f02'), + ('f8000000-0000-0000-0000-000000000f13', 'f8000000-0000-0000-0000-000000000f03'), + ('f8000000-0000-0000-0000-000000000f14', null)$$, + 'KAT-05 Kriterium 4: Meldungen mit Ziel = Quelle zeigen auf das Ziel, andere Meldungen bleiben unverändert'); +select is((select row(status, resolved_at is not null)::text from public.game_reports where id = 'f8000000-0000-0000-0000-000000000f11'), '(approved,t)', + 'Das Umhängen ändert Status und Zeitpunkt der Meldung nicht'); +select is(public.admin_merge_games('f8000000-0000-0000-0000-000000000f03', 'f8000000-0000-0000-0000-000000000f02') -> 'reports_repointed', '1'::jsonb, + 'Ein weiteres Zusammenführen hängt die nächste Meldung um'); +select lives_ok($$delete from public.games where id = 'f8000000-0000-0000-0000-000000000f01'$$, + 'Positiv: Die Quelle ist nach dem Umhängen nicht mehr Ziel einer Meldung und lässt sich löschen (keine verwaiste Restrict-Sperre)'); + +-- --------------------------------------------------------------------------------------------- +-- Fremdschlüssel-Wächter (wie in 07): jeder Verweis auf games ist behandelt oder bewusst ausgelassen +-- --------------------------------------------------------------------------------------------- +create temp table fk_found as +select (select relname from pg_class where oid = c.conrelid) || '.' || + (select attname from pg_attribute where attrelid = c.conrelid and attnum = c.conkey[1]) as fk +from pg_constraint c +where c.contype = 'f' and c.confrelid = 'public.games'::regclass; +select is((select count(*) from pg_constraint c where c.contype = 'f' and c.confrelid = 'public.games'::regclass and cardinality(c.conkey) > 1), 0::bigint, + 'Fremdschlüssel-Wächter: alle Verweise auf games sind einspaltig'); +select set_eq($$select fk from fk_found$$, + $$values + -- behandelt von repoint_game_references / admin_merge_games + ('collection_items.game_id'), ('favorite_games.game_id'), ('plays.game_id'), ('score_sheets.game_id'), + ('game_variants.game_id'), ('games.merged_into_id'), ('game_reports.target_game_id'), + -- bewusst ausgelassen: bleiben am ausgeblendeten Quell-Eintrag + ('game_genres.game_id'), ('game_mechanics.game_id'), ('game_categories.game_id'), ('game_publishers.game_id'), + -- bewusst ausgelassen: zeigt auf ein privates Spiel, das Zusammenführen zweier Katalogspiele betrifft ihn nicht + ('game_reports.provisional_game_id')$$, + 'KAT-05 Kriterium 4: Fremdschlüssel-Wächter: neue Verweise auf games erzwingen eine Entscheidung im Zusammenführen'); + +-- --------------------------------------------------------------------------------------------- +-- Konto löschen nach Freigabe (QUE-04, Klärung 10; Spec 9.5 KTO-05) +-- Anna: freigegebene Meldung (neu angelegter Katalogeintrag), zusammengeführte Meldung, ausgeblendete +-- vorläufige Einträge mit merged_into_id auf Katalogspiele. +-- --------------------------------------------------------------------------------------------- +insert into public.collection_items (id, owner_id, game_id, list, note) values + ('f8000000-0000-0000-0000-0000000000c7', '22222222-2222-2222-2222-222222222222', '7c1d2c3d-0000-4000-8000-000000000002', 'owned', 'Bens Notiz am neuen Spiel'); +create temp table other_before as +select 'game2' as k, row(g.title, g.year, g.description, g.hidden_at, g.merged_into_id, g.visibility, g.manual_fields)::text as v + from public.games g where g.id = '7c1d2c3d-0000-4000-8000-000000000002' +union all select 'game4', row(g.title, g.year, g.hidden_at, g.merged_into_id, g.visibility)::text from public.games g where g.id = '7c1d2c3d-0000-4000-8000-000000000004' +union all select 'ben_c7', row(c.game_id, c.list, c.note)::text from public.collection_items c where c.id = 'f8000000-0000-0000-0000-0000000000c7' +union all select 'ben_c4', row(c.game_id, c.variant_id, c.list, c.note)::text from public.collection_items c where c.id = 'f8000000-0000-0000-0000-0000000000c4' +union all select 'cara_report', row(r.status, r.target_game_id)::text from public.game_reports r where r.id = '7c1d2c3d-0000-4000-8000-000000000007' +union all select 'cara_c5', row(c.game_id, c.list, c.note)::text from public.collection_items c where c.id = 'f8000000-0000-0000-0000-0000000000c5' +union all select 'variants', (select count(*) from public.game_variants where game_id in ('7c1d2c3d-0000-4000-8000-000000000002', '7c1d2c3d-0000-4000-8000-000000000004'))::text; + +select cmp_ok((select count(*) from public.game_reports where owner_id = '11111111-1111-1111-1111-111111111111'), '=', 2::bigint, + 'Vorbedingung: Anna hat zwei bearbeitete Meldungen'); +select cmp_ok((select count(*) from public.games where owner_id = '11111111-1111-1111-1111-111111111111' and merged_into_id is not null), '=', 2::bigint, + 'Vorbedingung: Anna hat zwei ausgeblendete vorläufige Einträge mit merged_into_id'); +select lives_ok($$delete from auth.users where id = '11111111-1111-1111-1111-111111111111'$$, + 'KAT-05 Kriterium 5 / QUE-04: das Konto der Meldenden lässt sich nach der Freigabe löschen'); +select is((select count(*) from public.game_reports where owner_id = '11111111-1111-1111-1111-111111111111'), 0::bigint, + 'QUE-04: die Meldungen sind mit dem Konto gelöscht'); +select is((select count(*) from public.games where owner_id = '11111111-1111-1111-1111-111111111111'), 0::bigint, + 'QUE-04: die vorläufigen Einträge sind mit dem Konto gelöscht'); +select is((select count(*) from public.collection_items where owner_id = '11111111-1111-1111-1111-111111111111'), 0::bigint, + 'QUE-04: Annas Sammlung (auch die umgehängten Einträge) ist gelöscht'); +select results_eq($$select 'game2', row(g.title, g.year, g.description, g.hidden_at, g.merged_into_id, g.visibility, g.manual_fields)::text from public.games g where g.id = '7c1d2c3d-0000-4000-8000-000000000002' + union all select 'game4', row(g.title, g.year, g.hidden_at, g.merged_into_id, g.visibility)::text from public.games g where g.id = '7c1d2c3d-0000-4000-8000-000000000004' + union all select 'ben_c7', row(c.game_id, c.list, c.note)::text from public.collection_items c where c.id = 'f8000000-0000-0000-0000-0000000000c7' + union all select 'ben_c4', row(c.game_id, c.variant_id, c.list, c.note)::text from public.collection_items c where c.id = 'f8000000-0000-0000-0000-0000000000c4' + union all select 'cara_report', row(r.status, r.target_game_id)::text from public.game_reports r where r.id = '7c1d2c3d-0000-4000-8000-000000000007' + union all select 'cara_c5', row(c.game_id, c.list, c.note)::text from public.collection_items c where c.id = 'f8000000-0000-0000-0000-0000000000c5' + union all select 'variants', (select count(*) from public.game_variants where game_id in ('7c1d2c3d-0000-4000-8000-000000000002', '7c1d2c3d-0000-4000-8000-000000000004'))::text$$, + $$select k, v from other_before$$, + 'QUE-04: Katalogspiele (neu angelegt und vorhanden), ihre Varianten und die Daten und Meldungen anderer Nutzer bleiben unverändert'); +select is((select count(*) from public.game_reports where owner_id = '33333333-3333-3333-3333-333333333333'), 3::bigint, + 'Caras Meldungen (zwei aus dem Runbook, eine Import-Meldung) sind nach Annas Löschung vorhanden'); + +select * from finish(); +rollback;