From 7186cddac50b39b4bf19d419ea3ad2898e2ca0dc Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Wed, 4 Nov 2026 19:11:20 +0100 Subject: [PATCH] =?UTF-8?q?feat(db):=20Fehlendes=20Spiel=20melden=20?= =?UTF-8?q?=E2=80=93=20Migration,=20RPCs,=20RLS=20und=20pgTAP-Tests=20(KAT?= =?UTF-8?q?-04,=2013a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #13 Co-Authored-By: Claude Sonnet 5.5 --- .../20261104100000_game_reports_user.sql | 394 ++++++++++ .../tests/database/08_game_reports.test.sql | 6 + .../database/10_game_reports_user.test.sql | 687 ++++++++++++++++++ 3 files changed, 1087 insertions(+) create mode 100644 supabase/migrations/20261104100000_game_reports_user.sql create mode 100644 supabase/tests/database/10_game_reports_user.test.sql diff --git a/supabase/migrations/20261104100000_game_reports_user.sql b/supabase/migrations/20261104100000_game_reports_user.sql new file mode 100644 index 0000000..700a631 --- /dev/null +++ b/supabase/migrations/20261104100000_game_reports_user.sql @@ -0,0 +1,394 @@ +-- KAT-04 / PR 13a: Fehlendes Spiel melden (Spec docs/specs/13-fehlendes-spiel-melden.md, Abschnitte 3 und 4). +-- Nur additiv. Enthält: +-- strip_leading_article, normalize_game_title Titel-Normalisierung über search_normalize (Spec 3.3) +-- report_day Berliner Kalendertag (Tageslimit, Krit. 7) +-- game_reports.result_seen_at Ergebnishinweis weggeklickt (Spec-F5) +-- Spaltenrechte und Policies auf game_reports Anlegen eigener Meldungen, Wegklicken des Hinweises +-- game_reports_daily_limit Trigger: höchstens 10 Nutzer-Meldungen je Berliner Kalendertag +-- find_similar_catalog_games RPC: sehr ähnliche Katalogspiele (Krit. 2) +-- report_missing_game RPC: Meldung + vorläufiger Eintrag + optional Listeneintrag +-- adopt_report_target RPC (security definer): Wechsel auf den Zieleintrag (Krit. 5) +-- Rechte-Regel (Spec #2 3.2): jede Funktion zuerst allen entziehen, danach nur die Grants aus Spec 4.1. +-- editions_cleared und admin_resolve_report stammen aus 2c (kein create or replace hier). + +-- --------------------------------------------------------------------------------------------- +-- 1. Normalisierung (Spec 3.3). search_normalize (3a) entfernt nie Artikel; das tun nur diese beiden. +-- --------------------------------------------------------------------------------------------- +-- Erwartet bereits normalisierten Text (Kleinbuchstaben, ein Leerzeichen als Trenner). Entfernt +-- einmal ein führendes Artikelwort samt Leerzeichen; ein Titel, der nur aus dem Artikel besteht +-- ("das"), hat kein folgendes Leerzeichen und bleibt unverändert. +create function public.strip_leading_article(t text) +returns text +language sql +immutable +parallel safe +set search_path = '' +as $$ + select regexp_replace(t, + '^(?:der|die|das|den|dem|des|ein|eine|einen|einem|einer|eines|the|a|an) ', '') +$$; + +create function public.normalize_game_title(t text) +returns text +language sql +immutable +parallel safe +set search_path = '' +as $$ + select coalesce( + nullif(public.strip_leading_article(public.search_normalize(t)), ''), + nullif(public.search_normalize(t), ''), + lower(btrim(t))) +$$; + +-- Berliner Kalendertag eines Zeitpunkts. Eigene Funktion, damit pgTAP die Tagesgrenze mit festen +-- Zeitstempeln prüfen kann. Rein rechnend; authenticated braucht das Recht, weil der Trigger +-- game_reports_daily_limit als security invoker mit der Rolle des Einfügenden läuft. +create function public.report_day(p_at timestamptz) +returns date +language sql +immutable +set search_path = '' +as $$ + select (p_at at time zone 'Europe/Berlin')::date +$$; + +revoke execute on function public.strip_leading_article(text) from public, anon, authenticated, service_role; +revoke execute on function public.normalize_game_title(text) from public, anon, authenticated, service_role; +revoke execute on function public.report_day(timestamptz) from public, anon, authenticated, service_role; +grant execute on function public.strip_leading_article(text) to authenticated; +grant execute on function public.normalize_game_title(text) to authenticated; +grant execute on function public.report_day(timestamptz) to authenticated; + +-- --------------------------------------------------------------------------------------------- +-- 2. game_reports: neue Spalte, Spaltenrechte, Policies (Spec 3.2) +-- --------------------------------------------------------------------------------------------- +alter table public.game_reports + add column result_seen_at timestamptz, + add constraint game_reports_result_seen_check check (result_seen_at is null or status <> 'open'); + +-- Bewusst gegenüber 2c erweitert: Nutzer legen eigene Meldungen an und ändern an eigenen, bereits +-- bearbeiteten Meldungen genau eine Spalte. Nicht schreibbar: id, origin, status, bgg_id, +-- target_game_id, resolved_at, editions_cleared, created_at, updated_at (42501); die Datenbank +-- setzt origin = 'user', status = 'open' und created_at = now() selbst. Kein delete-Recht. +grant insert (owner_id, title, publisher_name, year, link, provisional_game_id) + on public.game_reports to authenticated; +grant update (result_seen_at) on public.game_reports to authenticated; + +create policy game_reports_insert_own on public.game_reports + for insert to authenticated + with check ( + owner_id = (select auth.uid()) + and origin = 'user' + and status = 'open' + and target_game_id is null + and provisional_game_id is not null + and exists ( + select 1 from public.games g + where g.id = provisional_game_id + and g.visibility = 'private' + and g.owner_id = (select auth.uid()) + and g.hidden_at is null + and g.merged_into_id is null + ) + ); + +create policy game_reports_update_seen on public.game_reports + for update to authenticated + using (owner_id = (select auth.uid()) and status <> 'open') + with check (owner_id = (select auth.uid()) and status <> 'open'); + +-- --------------------------------------------------------------------------------------------- +-- 3. Tageslimit (Krit. 7, Klärung 27, N12) +-- Setzt READ COMMITTED voraus (Standard bei PostgREST): Die Zählung nach der Sperre sieht die +-- inzwischen bestätigten Meldungen der anderen Transaktion. Die Zahl 10 steht nur hier und in +-- report_missing_game (Vorprüfung); eine Änderung braucht eine neue Migration. +-- security invoker: Der Nutzer sieht per RLS alle eigenen Meldungen; Studio (postgres) umgeht RLS. +-- --------------------------------------------------------------------------------------------- +create function public.game_reports_daily_limit() +returns trigger +language plpgsql +set search_path = '' +as $$ +begin + perform pg_advisory_xact_lock(hashtextextended('game_reports_limit:' || new.owner_id::text, 0)); + if (select count(*) from public.game_reports r + where r.owner_id = new.owner_id and r.origin = 'user' + and public.report_day(r.created_at) = public.report_day(now())) >= 10 then + raise exception 'report_daily_limit' using errcode = 'P0001'; + end if; + return new; +end; +$$; + +-- Import-Meldungen (SAM-08) werden weder gezählt noch begrenzt (N12). +create trigger game_reports_daily_limit before insert on public.game_reports + for each row when (new.origin = 'user') execute function public.game_reports_daily_limit(); + +revoke execute on function public.game_reports_daily_limit() from public, anon, authenticated, service_role; + +-- --------------------------------------------------------------------------------------------- +-- 4. RPC find_similar_catalog_games (Krit. 2, Klärung 15; Spec 3.3 und 4.3) +-- security invoker: es gelten die Policies des Aufrufers; gelesen wird über catalog_games (nur +-- sichtbare Katalogspiele). Kandidatentitel: Haupttitel (games.title_search) und alle alternativen +-- Titel (game_alt_titles.title_search). Regel A: gleicher Schlüssel, jedes Jahr. Regel B: +-- Trigramm-Ähnlichkeit >= 0.8 und Jahr +-1 (fehlt ein Jahr, ohne Jahresbedingung, Spec-F2). +-- extensions.similarity ausdrücklich, nicht der Operator % (der nutzt pg_trgm.similarity_threshold). +-- --------------------------------------------------------------------------------------------- +create function public.find_similar_catalog_games(p_title text, p_year integer default null) +returns table ( + game_id uuid, + title text, + matched_title text, + year smallint, + publishers text[], + match_kind text, + similarity real +) +language sql +stable +set search_path = '' +as $$ + with q as ( + select public.normalize_game_title(btrim(p_title)) as nk + where p_title is not null + and char_length(btrim(p_title)) between 1 and 200 + ), + cand as ( + select c.id as game_id, c.title as game_title, c.year as game_year, + k.cand_title, k.cand_key, k.ord + from public.catalog_games c + join public.games g on g.id = c.id + cross join lateral ( + select g.title as cand_title, + coalesce(nullif(public.strip_leading_article(g.title_search), ''), g.title_search, lower(btrim(g.title))) as cand_key, + 0 as ord + union all + select a.title, + coalesce(nullif(public.strip_leading_article(a.title_search), ''), a.title_search), + 1 + from public.game_alt_titles a + where a.game_id = g.id + ) k + ), + scored as ( + select cd.*, (cd.cand_key = q.nk) as is_same, + extensions.similarity(cd.cand_key, q.nk) as sim + from cand cd cross join q + where q.nk <> '' + ), + hit as ( + select distinct on (s.game_id) + s.game_id, s.game_title, s.game_year, s.cand_title, s.is_same, s.sim + from scored s + where s.is_same + or (s.sim >= 0.8 + and (p_year is null or s.game_year is null or abs(s.game_year - p_year) <= 1)) + order by s.game_id, s.is_same desc, s.sim desc, s.ord, s.cand_title + ) + select h.game_id, + h.game_title, + h.cand_title, + h.game_year, + coalesce((select array_agg(p.name order by p.name_search, p.name) + from public.game_publishers gp + join public.publishers p on p.id = gp.publisher_id + where gp.game_id = h.game_id), '{}'::text[]), + case when h.is_same then 'same_title' else 'similar_title' end, + case when h.is_same then 1.0::real else h.sim end + from hit h + order by h.is_same desc, h.sim desc, h.game_title, h.game_id + limit 5 +$$; + +revoke execute on function public.find_similar_catalog_games(text, integer) from public, anon, authenticated, service_role; +grant execute on function public.find_similar_catalog_games(text, integer) to authenticated; + +-- --------------------------------------------------------------------------------------------- +-- 5. RPC report_missing_game (Krit. 1, 2, 3, 7, 8; Spec 4.2) +-- security invoker: RLS, Spaltenrechte und Trigger gelten weiter. Eine Transaktion: jeder Fehler +-- rollt alles zurück (kein verwaistes vorläufiges Spiel). p_year ist integer und wird selbst +-- geprüft (smallint würde bei großen Werten schon beim Aufruf mit 22003 scheitern). +-- --------------------------------------------------------------------------------------------- +create function public.report_missing_game( + p_title text, + p_publisher_name text default null, + p_year integer default null, + p_link text default null, + p_list text default null, + p_confirmed_distinct boolean default false +) +returns jsonb +language plpgsql +set search_path = '' +as $$ +declare + v_uid uuid := auth.uid(); + v_title text; + v_publisher text; + v_link text; + v_game_id uuid; + v_report_id uuid; + v_item_id uuid; + v_constraint text; +begin + if v_uid is null then + raise exception 'not_authenticated' using errcode = 'P0001'; + end if; + + v_title := nullif(btrim(p_title, E' \t\r\n'), ''); + v_publisher := nullif(btrim(p_publisher_name, E' \t\r\n'), ''); + v_link := nullif(btrim(p_link, E' \t\r\n'), ''); + + if v_title is null then + raise exception 'report_title_required' using errcode = 'P0001'; + end if; + if char_length(v_title) > 200 then + raise exception 'report_title_too_long' using errcode = 'P0001'; + end if; + if char_length(v_publisher) > 200 then + raise exception 'report_publisher_too_long' using errcode = 'P0001'; + end if; + if p_year is not null and p_year not between 1800 and 2100 then + raise exception 'report_year_invalid' using errcode = 'P0001'; + end if; + if v_link is not null and (char_length(v_link) > 500 or v_link !~ '^https?://') then + raise exception 'report_link_invalid' using errcode = 'P0001'; + end if; + if p_list is not null and p_list not in ('owned', 'wishlist', 'shopping') then + raise exception 'report_list_invalid' using errcode = 'P0001'; + end if; + + -- Vorprüfung vor der Ähnlichkeit: Der Nutzer soll nicht erst Kandidaten bestätigen und dann am + -- Limit scheitern. Maßgeblich bleibt der Trigger (Sperre). + if (select count(*) from public.game_reports r + where r.owner_id = v_uid and r.origin = 'user' + and public.report_day(r.created_at) = public.report_day(now())) >= 10 then + raise exception 'report_daily_limit' using errcode = 'P0001'; + end if; + + if not coalesce(p_confirmed_distinct, false) + and exists (select 1 from public.find_similar_catalog_games(v_title, p_year)) then + raise exception 'report_similar_exists' using errcode = 'P0001'; + end if; + + insert into public.games (owner_id, title, year, visibility) + values (v_uid, v_title, p_year, 'private') + returning id into v_game_id; + + begin + insert into public.game_reports (owner_id, title, publisher_name, year, link, provisional_game_id) + values (v_uid, v_title, v_publisher, p_year, v_link, v_game_id) + returning id into v_report_id; + exception when unique_violation then + get stacked diagnostics v_constraint = constraint_name; + if v_constraint = 'game_reports_provisional_game_id_key' then + raise exception 'report_provisional_in_use' using errcode = 'P0001'; + end if; + raise; + end; + + if p_list is not null then + insert into public.collection_items (owner_id, game_id, list) + values (v_uid, v_game_id, p_list) + returning id into v_item_id; + end if; + + return jsonb_build_object( + 'report_id', v_report_id, + 'provisional_game_id', v_game_id, + 'collection_item_id', v_item_id); +end; +$$; + +revoke execute on function public.report_missing_game(text, text, integer, text, text, boolean) + from public, anon, authenticated, service_role; +grant execute on function public.report_missing_game(text, text, integer, text, text, boolean) to authenticated; + +-- --------------------------------------------------------------------------------------------- +-- 6. RPC adopt_report_target (Krit. 5; Spec 4.4) +-- security definer, Eigentümer postgres. Begründung: Nutzer dürfen merged_into_id und hidden_at nicht +-- schreiben und repoint_game_references nicht ausführen; der Wechsel auf den Zieleintrag +-- ("abgelehnt mit Verweis") entscheidet aber der Nutzer selbst (Spec #2 Spec-F3). +-- Sicherheit: search_path leer (alles schema-qualifiziert); der Aufrufer wird nur über auth.uid() +-- bestimmt (nie über current_user); Meldung und vorläufiger Eintrag müssen dem Aufrufer gehören; +-- Ziel muss ein sichtbares Katalogspiel sein; hängen fremde Verweise am vorläufigen Eintrag, wird +-- abgebrochen. Innerhalb der Funktion ist current_user = postgres: Sie darf deshalb KEINE +-- admin_*-Funktion aufrufen (deren Rollenprüfung würde bestehen), weder jetzt noch später. +-- Sperrreihenfolge wie admin_resolve_report: erst die Meldung, dann beide Spiele nach id. +-- result_seen_at bleibt unverändert, damit der Hinweis am Zieleintrag erscheint, bis "Verstanden". +-- --------------------------------------------------------------------------------------------- +create function public.adopt_report_target(p_report uuid) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_uid uuid := auth.uid(); + rep public.game_reports; + prov public.games; + result jsonb; +begin + if v_uid is null then + raise exception 'not_authenticated' using errcode = 'P0001'; + end if; + + -- Meldung des Aufrufers sperren; fremde und unbekannte Meldungen liefern denselben Fehler. + select * into rep from public.game_reports r + where r.id = p_report and r.owner_id = v_uid + for update; + if not found then + raise exception 'report_not_found' using errcode = 'P0001'; + end if; + if rep.status <> 'rejected' or rep.target_game_id is null then + raise exception 'report_not_adoptable' using errcode = 'P0001'; + end if; + if rep.provisional_game_id is null then + raise exception 'report_no_provisional' using errcode = 'P0001'; + end if; + + perform 1 from public.games g + where g.id in (rep.provisional_game_id, rep.target_game_id) + order by g.id for update; + + select * into prov from public.games g where g.id = rep.provisional_game_id; + if not found or prov.visibility <> 'private' or prov.owner_id is distinct from v_uid then + raise exception 'report_not_found' using errcode = 'P0001'; + end if; + if prov.merged_into_id is not null then + raise exception 'report_already_adopted' using errcode = 'P0001'; + end if; + if not exists ( + select 1 from public.games g + where g.id = rep.target_game_id and g.visibility = 'catalog' and g.hidden_at is null and g.merged_into_id is null + ) then + raise exception 'report_target_not_listed' using errcode = 'P0001'; + end if; + + -- Verweise mit fremdem oder fehlendem Eigentümer (null zählt als fremd) brechen ab. + if exists (select 1 from public.collection_items x where x.game_id = prov.id and x.owner_id is distinct from v_uid) + or exists (select 1 from public.favorite_games x where x.game_id = prov.id and x.profile_id is distinct from v_uid) + or exists (select 1 from public.plays x where x.game_id = prov.id and x.owner_id is distinct from v_uid) + or exists (select 1 from public.score_sheets x where x.game_id = prov.id and x.owner_id is distinct from v_uid) then + raise exception 'report_foreign_references' using errcode = 'P0001'; + end if; + + -- privat -> Katalog: nichts wird kopiert; gleichnamige Varianten abgebildet, sonst null (RB1, N15) + result := public.repoint_game_references(prov.id, rep.target_game_id); + + -- merged_into_id verlangt hidden_at (Check), deshalb in einem Statement. + update public.games g + set merged_into_id = rep.target_game_id, hidden_at = coalesce(g.hidden_at, now()) + where g.id = prov.id; + + update public.game_reports r + set editions_cleared = coalesce((result ->> 'variants_cleared')::integer, 0) + where r.id = rep.id; + + return result; +end; +$$; + +revoke execute on function public.adopt_report_target(uuid) from public, anon, authenticated, service_role; +grant execute on function public.adopt_report_target(uuid) to authenticated; diff --git a/supabase/tests/database/08_game_reports.test.sql b/supabase/tests/database/08_game_reports.test.sql index adad298..c20ae7c 100644 --- a/supabase/tests/database/08_game_reports.test.sql +++ b/supabase/tests/database/08_game_reports.test.sql @@ -12,6 +12,12 @@ insert into auth.users (id, email, raw_user_meta_data) values ('22222222-2222-2222-2222-222222222222', 'ben@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Ben"}'), ('33333333-3333-3333-3333-333333333333', 'cara@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Cara"}'); +-- Ab #13 (KAT-04) begrenzt der Trigger game_reports_daily_limit Nutzer-Meldungen auf 10 je Berliner Tag und +-- Nutzer, auch für Einfügungen als postgres. Die Fixtures dieser Datei legen für einzelne Nutzer mehr als 10 +-- Meldungen "von heute" an; das Limit prüft 10_game_reports_user.test.sql. Hier ist der Trigger abgeschaltet +-- (DDL, mit dem Rollback am Ende zurückgenommen). +alter table public.game_reports disable trigger game_reports_daily_limit; + -- --------------------------------------------------------------------------------------------- -- Tabellenrechte (KAT-05 Kriterium 9) -- --------------------------------------------------------------------------------------------- diff --git a/supabase/tests/database/10_game_reports_user.test.sql b/supabase/tests/database/10_game_reports_user.test.sql new file mode 100644 index 0000000..c449ac6 --- /dev/null +++ b/supabase/tests/database/10_game_reports_user.test.sql @@ -0,0 +1,687 @@ +-- KAT-04 (PR 13a): Fehlendes Spiel melden. Normalisierung (strip_leading_article, normalize_game_title), +-- report_day, Rechte/Policies/Trigger auf game_reports, find_similar_catalog_games, report_missing_game, +-- adopt_report_target (security definer), Ergebnis nach admin_resolve_report, Konto-Löschen. +-- Läuft auch mit eingespieltem Startbestand: eigene Fixtures mit festen IDs (Präfix a130/f13) und +-- unwahrscheinlichen Titeln (zyxq...), damit der Katalog nicht hineinspielt. +-- Fixtures legt postgres an; "als Nutzer" läuft wirklich als Rolle authenticated mit gesetztem +-- request.jwt.claims. Regel dieser Datei: Jede Verweigerung steht neben einem Positivfall. +begin; +select plan(198); + +insert into auth.users (id, email, raw_user_meta_data) values + ('a1300000-0000-4000-8000-0000000000a1', 'kat04-a@example.test', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"KA"}'), + ('a1300000-0000-4000-8000-0000000000b2', 'kat04-b@example.test', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"KB"}'), + ('a1300000-0000-4000-8000-0000000000c3', 'kat04-c@example.test', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"KC"}'), + ('a1300000-0000-4000-8000-0000000000d4', 'kat04-d@example.test', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"KD"}'), + ('a1300000-0000-4000-8000-0000000000e1', 'kat04-l1@example.test', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"L1"}'), + ('a1300000-0000-4000-8000-0000000000e2', 'kat04-l2@example.test', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"L2"}'), + ('a1300000-0000-4000-8000-0000000000e3', 'kat04-l3@example.test', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"L3"}'), + ('a1300000-0000-4000-8000-0000000000e4', 'kat04-y@example.test', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"KY"}'); + +-- --------------------------------------------------------------------------------------------- +-- 1. Funktionen: Rechte, Attribute (Spec 4.1) +-- --------------------------------------------------------------------------------------------- +create temp table fn (sig text primary key, names text, granted boolean); +grant all on fn to public; +insert into fn values + ('public.strip_leading_article(text)', 'strip_leading_article', true), + ('public.normalize_game_title(text)', 'normalize_game_title', true), + ('public.report_day(timestamptz)', 'report_day', true), + ('public.find_similar_catalog_games(text, integer)', 'find_similar_catalog_games', true), + ('public.report_missing_game(text, text, integer, text, text, boolean)', 'report_missing_game', true), + ('public.adopt_report_target(uuid)', 'adopt_report_target', true), + ('public.game_reports_daily_limit()', 'game_reports_daily_limit', false); + +select is_empty($$select f.sig, r from fn f, unnest(array['anon', 'service_role']) r + where has_function_privilege(r, f.sig::regprocedure, 'execute')$$, + 'Sicherheit Kriterium 2: anon und service_role dürfen keine der neuen Funktionen ausführen'); +select is_empty($$select f.sig from fn f + where has_function_privilege('authenticated', f.sig::regprocedure, 'execute') <> f.granted$$, + 'KAT-04: authenticated darf genau die RPCs und Hilfsfunktionen ausführen, die Trigger-Funktion nicht'); +select is_empty($$select f.sig from fn f join pg_proc p on p.oid = f.sig::regprocedure + where p.proacl is null or exists (select 1 from aclexplode(p.proacl) a where a.grantee = 0)$$, + 'KAT-04: keine neue Funktion ist für PUBLIC ausführbar (ACL gesetzt, kein Eintrag für grantee 0)'); +select is_empty($$select f.sig from fn f join pg_proc p on p.oid = f.sig::regprocedure + where p.prosecdef <> (f.sig = 'public.adopt_report_target(uuid)')$$, + 'KAT-04: nur adopt_report_target ist security definer'); +select is((select pg_get_userbyid(proowner) from pg_proc where oid = 'public.adopt_report_target(uuid)'::regprocedure), 'postgres'::name, + 'KAT-04 Kriterium 5: adopt_report_target gehört postgres'); +select is_empty($$select f.sig from fn f join pg_proc p on p.oid = f.sig::regprocedure + where p.proconfig is null or not (p.proconfig @> array['search_path=""'])$$, + 'KAT-04: alle neuen Funktionen haben search_path = "" als Funktionseinstellung'); + +-- --------------------------------------------------------------------------------------------- +-- 2. Normalisierung (Krit. 2, Klärung 15) +-- --------------------------------------------------------------------------------------------- +select is(public.strip_leading_article('die siedler von catan'), 'siedler von catan', 'KAT-04 Kriterium 2: führendes "die" entfällt'); +select is(public.strip_leading_article('the crew'), 'crew', 'KAT-04 Kriterium 2: führendes "the" entfällt'); +select is(public.strip_leading_article('a game of thrones'), 'game of thrones', 'KAT-04 Kriterium 2: führendes "a" entfällt'); +select is(public.strip_leading_article('an apple'), 'apple', 'KAT-04 Kriterium 2: führendes "an" entfällt'); +select is(public.strip_leading_article('einem haus'), 'haus', 'KAT-04 Kriterium 2: auch "einem" entfällt'); +select is(public.strip_leading_article('das'), 'das', 'KAT-04 Kriterium 2: ein Titel nur aus dem Artikel bleibt unverändert'); +select is(public.strip_leading_article('theater der welt'), 'theater der welt', 'KAT-04 Kriterium 2: kein Artikel, wenn das Wort nur damit beginnt'); +select is(public.strip_leading_article('herr der ringe'), 'herr der ringe', 'KAT-04 Kriterium 2: Artikel mitten im Titel bleiben'); +select is(public.strip_leading_article('der die das'), 'die das', 'KAT-04 Kriterium 2: nur einmal ein Artikel'); +select is(public.strip_leading_article(null), null, 'KAT-04: strip_leading_article(null) ist null'); + +select is(public.normalize_game_title('Die Siedler von Catan'), public.normalize_game_title('siedler von catan'), + 'KAT-04 Kriterium 2: "Die Siedler von Catan" und "siedler von catan" ergeben denselben Wert'); +select is(public.normalize_game_title('CATAN!'), public.normalize_game_title('Catan'), 'KAT-04 Kriterium 2: Großbuchstaben und Sonderzeichen sind egal'); +select is(public.normalize_game_title('The Crew'), public.normalize_game_title('Crew'), 'KAT-04 Kriterium 2: "The Crew" = "Crew"'); +select is(public.normalize_game_title('Das'), 'das', 'KAT-04 Kriterium 2: ein Titel nur aus einem Artikel bleibt nicht leer'); +select is(public.normalize_game_title('The'), 'the', 'KAT-04 Kriterium 2: auch "The" allein bleibt nicht leer'); +select is(public.normalize_game_title('Mölkky'), public.normalize_game_title('Molkky'), 'KAT-04 Kriterium 2: Umlaute werden über search_normalize gefaltet'); +select is(public.normalize_game_title('日本語'), '日本語', 'KAT-04 Kriterium 2: nicht-lateinischer Titel fällt auf lower(btrim) zurück'); +select is(public.normalize_game_title(' !!! '), '!!!', 'KAT-04 Kriterium 2: Titel nur aus Sonderzeichen fällt auf lower(btrim) zurück'); +select is(public.normalize_game_title(null), null, 'KAT-04: normalize_game_title(null) ist null'); + +select is(public.report_day('2026-01-15 23:30:00+00'), '2026-01-16'::date, 'KAT-04 Kriterium 7: 23:30 UTC im Winter ist schon der nächste Berliner Tag'); +select is(public.report_day('2026-01-15 22:59:00+00'), '2026-01-15'::date, 'KAT-04 Kriterium 7: 22:59 UTC im Winter ist noch derselbe Tag'); +select is(public.report_day('2026-07-15 21:59:00+00'), '2026-07-15'::date, 'KAT-04 Kriterium 7: 21:59 UTC im Sommer ist noch derselbe Tag'); +select is(public.report_day('2026-07-15 22:00:00+00'), '2026-07-16'::date, 'KAT-04 Kriterium 7: 22:00 UTC im Sommer ist der nächste Tag'); + +-- --------------------------------------------------------------------------------------------- +-- 3. Rechte, Policies, Trigger auf game_reports (Spec 3.2) +-- --------------------------------------------------------------------------------------------- +select is((select array_agg(a.attname::text order by a.attname) from pg_attribute a + where a.attrelid = 'public.game_reports'::regclass and a.attnum > 0 and not a.attisdropped + and has_column_privilege('authenticated', 'public.game_reports', a.attname, 'insert')), + array['link', 'owner_id', 'provisional_game_id', 'publisher_name', 'title', 'year'], + 'Sicherheit Kriterium 1: authenticated darf beim Anlegen genau owner_id, title, publisher_name, year, link, provisional_game_id setzen'); +select is((select array_agg(a.attname::text order by a.attname) from pg_attribute a + where a.attrelid = 'public.game_reports'::regclass and a.attnum > 0 and not a.attisdropped + and has_column_privilege('authenticated', 'public.game_reports', a.attname, 'update')), + array['result_seen_at'], + 'KAT-04 Kriterium 8: authenticated darf genau result_seen_at ändern'); +select is_empty($$select a.attname, p from pg_attribute a, unnest(array['select', 'insert', 'update', 'references']) p + where a.attrelid = 'public.game_reports'::regclass and a.attnum > 0 and not a.attisdropped + and has_column_privilege('anon', 'public.game_reports', a.attname, p)$$, + 'Sicherheit Kriterium 2: anon hat auf keine Spalte von game_reports ein Recht'); +select is_empty($$select p from unnest(array['delete', 'truncate']) p where has_table_privilege('authenticated', 'public.game_reports', p)$$, + 'Sicherheit Kriterium 1: authenticated darf Meldungen nicht löschen'); +select is((select array_agg(policyname::text order by policyname) from pg_policies where tablename = 'game_reports' and cmd in ('INSERT', 'UPDATE')), + array['game_reports_insert_own', 'game_reports_update_seen'], 'KAT-04: genau eine Insert- und eine Update-Policy'); +select ok((select pg_get_triggerdef(t.oid) ~ 'BEFORE INSERT ON public.game_reports FOR EACH ROW WHEN \(\(?new.origin = ''user''::text\)?\)' + from pg_trigger t where t.tgname = 'game_reports_daily_limit' and t.tgrelid = 'public.game_reports'::regclass), + 'KAT-04 Kriterium 7: der Limit-Trigger läuft vor dem Insert und nur für origin = user'); + +-- --------------------------------------------------------------------------------------------- +-- 4. Katalog-Fixtures (postgres) +-- --------------------------------------------------------------------------------------------- +insert into public.games (id, title, year, visibility) values + ('f13c0000-0000-4000-8000-000000000001', 'Quorblaxwitz Towers', 2020, 'catalog'), + ('f13c0000-0000-4000-8000-000000000002', 'Die Zyxqwvland Saga', 2018, 'catalog'), + ('f13c0000-0000-4000-8000-000000000003', 'Sortzyxq Reihenfolge', 2000, 'catalog'), + ('f13c0000-0000-4000-8000-000000000004', 'Sortzyxq Reihenfolg', 2000, 'catalog'), + ('f13c0000-0000-4000-8000-000000000005', 'Sortzyxq Reihenfol', 2000, 'catalog'), + ('f13c0000-0000-4000-8000-00000000000d', 'Verstecktzyxq Spiel', 2015, 'catalog'), + ('f13c0000-0000-4000-8000-00000000000e', 'Verstecktzyxq Spiel', 2015, 'catalog'), + ('f13c0000-0000-4000-8000-00000000000f', 'Verstecktzyxq Spiel', 2015, 'catalog'), + ('f13c0000-0000-4000-8000-000000000010', 'Jahrlosxyz Spiel', null, 'catalog'), + ('f13c0000-0000-4000-8000-000000000011', '日本語ゲーム', 2010, 'catalog'), + ('f13c0000-0000-4000-8000-000000000012', 'Das', 2010, 'catalog'), + ('f13c0000-0000-4000-8000-000000000013', 'Adoptzyxq Ziel', 2021, 'catalog'), + ('f13c0000-0000-4000-8000-000000000014', 'Adoptzyxq Verstecktes Ziel', 2021, 'catalog'), + ('f13c0000-0000-4000-8000-000000000015', 'Adoptzyxq Zusammengefuehrtes Ziel', 2021, 'catalog'); +insert into public.games (id, title, year, visibility) + select ('f13c0000-0000-4000-8000-' || lpad(to_hex(5 + i), 12, '0'))::uuid, 'Limitzyxq Spiel', 2005, 'catalog' + from generate_series(1, 7) i; +update public.games set hidden_at = now() where id in ('f13c0000-0000-4000-8000-00000000000e', 'f13c0000-0000-4000-8000-000000000014'); +update public.games set hidden_at = now(), merged_into_id = 'f13c0000-0000-4000-8000-00000000000d' + where id in ('f13c0000-0000-4000-8000-00000000000f'); +update public.games set hidden_at = now(), merged_into_id = 'f13c0000-0000-4000-8000-000000000013' + where id = 'f13c0000-0000-4000-8000-000000000015'; +insert into public.game_alt_titles (game_id, title, locale) values + ('f13c0000-0000-4000-8000-000000000002', 'The Zyxqwvland Chronicle', 'en'); +insert into public.publishers (id, name) values + ('f13c0000-0000-4000-8000-0000000000a1', 'Zyxq Verlag B'), + ('f13c0000-0000-4000-8000-0000000000a2', 'Zyxq Verlag A'); +insert into public.game_publishers (game_id, publisher_id) values + ('f13c0000-0000-4000-8000-000000000001', 'f13c0000-0000-4000-8000-0000000000a1'), + ('f13c0000-0000-4000-8000-000000000001', 'f13c0000-0000-4000-8000-0000000000a2'); +insert into public.game_variants (id, game_id, name) values + ('f13c0000-0000-4000-8000-0000000000b1', 'f13c0000-0000-4000-8000-000000000013', 'Standard'); +-- private Spiele mit dem Titel des sichtbaren Katalogspiels: Bens und Annas eigener vorläufiger Eintrag +insert into public.games (id, title, year, visibility, owner_id) values + ('f13a0000-0000-4000-8000-000000000001', 'Verstecktzyxq Spiel', 2015, 'private', 'a1300000-0000-4000-8000-0000000000b2'), + ('f13a0000-0000-4000-8000-000000000002', 'Verstecktzyxq Spiel', 2015, 'private', 'a1300000-0000-4000-8000-0000000000a1'); + +-- Schwellen vorab nachrechnen, damit die Trigramm-Fälle wirklich an der Grenze von 0,8 liegen +select cmp_ok(extensions.similarity('quorblaxwitz towers', 'quorblaxwitz towe'), '>=', 0.8::real, 'Vorbedingung: "towe" liegt knapp über 0,8'); +select cmp_ok(extensions.similarity('quorblaxwitz towers', 'quorblaxwitz tow'), '<', 0.8::real, 'Vorbedingung: "tow" liegt unter 0,8'); +select cmp_ok(extensions.similarity('quorblaxwitz towers', 'quorblaxwitz tow'), '>=', 0.7::real, 'Vorbedingung: "tow" liegt über 0,7 (fängt die Schwelle 0,7)'); + +-- --------------------------------------------------------------------------------------------- +-- 5. find_similar_catalog_games (Krit. 2) +-- --------------------------------------------------------------------------------------------- +select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000b2","role":"authenticated"}', true); +set local role authenticated; + +select results_eq($$select game_id, title, matched_title, year, publishers, match_kind, similarity + from public.find_similar_catalog_games('Quorblaxwitz Towers', 2020)$$, + $$values ('f13c0000-0000-4000-8000-000000000001'::uuid, 'Quorblaxwitz Towers', 'Quorblaxwitz Towers', 2020::smallint, + array['Zyxq Verlag A', 'Zyxq Verlag B'], 'same_title', 1.0::real)$$, + 'KAT-04 Kriterium 2: gleicher Titel liefert Treffer mit Haupttitel, Jahr, sortierten Verlagen, same_title und Ähnlichkeit 1'); +select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Towers', 2030)), 1::bigint, + 'KAT-04 Kriterium 2: gleicher normalisierter Titel gilt unabhängig vom Jahr'); +select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Towers', null)), 1::bigint, + 'KAT-04 Kriterium 2: gleicher Titel ohne Jahr trifft'); +select is((select count(*) from public.find_similar_catalog_games('The QUORBLAXWITZ-TOWERS!', 1999)), 1::bigint, + 'KAT-04 Kriterium 2: Artikel, Großbuchstaben und Sonderzeichen werden ignoriert'); +select is((select count(*) from public.find_similar_catalog_games(' Quorblaxwitz Towers ', 2020)), 1::bigint, + 'KAT-04 Kriterium 2: Leerraum am Rand ändert nichts'); +select results_eq($$select match_kind, similarity > 0.8 and similarity < 1 from public.find_similar_catalog_games('Quorblaxwitz Towe', 2021)$$, + $$values ('similar_title', true)$$, + 'KAT-04 Kriterium 2: Ähnlichkeit >= 0,8 bei Jahr + 1 trifft als similar_title mit echter Ähnlichkeit'); +select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Towe', 2019)), 1::bigint, + 'KAT-04 Kriterium 2: Ähnlichkeit >= 0,8 bei Jahr - 1 trifft'); +select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Towe', 2020)), 1::bigint, + 'KAT-04 Kriterium 2: Ähnlichkeit >= 0,8 bei gleichem Jahr trifft'); +select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Towe', 2022)), 0::bigint, + 'KAT-04 Kriterium 2: Ähnlichkeit >= 0,8 bei Jahr + 2 trifft nicht'); +select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Towe', 2018)), 0::bigint, + 'KAT-04 Kriterium 2: Ähnlichkeit >= 0,8 bei Jahr - 2 trifft nicht'); +select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Towe', null)), 1::bigint, + 'KAT-04 Kriterium 2 (Spec-F2): ohne Jahr gilt die Trigramm-Regel ohne Jahresbedingung'); +select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Tow', 2020)), 0::bigint, + 'KAT-04 Kriterium 2: Ähnlichkeit < 0,8 trifft nicht, auch bei gleichem Jahr'); +select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Tow', null)), 0::bigint, + 'KAT-04 Kriterium 2: Ähnlichkeit < 0,8 trifft auch ohne Jahr nicht'); +select is((select count(*) from public.find_similar_catalog_games('Jahrlosxyz Spie', 2020)), 1::bigint, + 'KAT-04 Kriterium 2 (Spec-F2): fehlt das Jahr am Katalogspiel, gilt die Trigramm-Regel ohne Jahresbedingung'); +select is((select count(*) from public.find_similar_catalog_games('Jahrlosxyz Spi', 2020)), 0::bigint, + 'KAT-04 Kriterium 2: auch ohne Katalogjahr zählt die Schwelle 0,8'); + +-- alternative Titel und Haupttitel mit Artikel +select results_eq($$select game_id, matched_title, match_kind from public.find_similar_catalog_games('Zyxqwvland Chronicle', 1900)$$, + $$values ('f13c0000-0000-4000-8000-000000000002'::uuid, 'The Zyxqwvland Chronicle', 'same_title')$$, + 'KAT-04 Kriterium 2: Treffer über title_search des alternativen Titels (matched_title), Artikel am alternativen Titel ignoriert'); +select results_eq($$select title, matched_title, match_kind from public.find_similar_catalog_games('zyxqwvland saga', 1900)$$, + $$values ('Die Zyxqwvland Saga', 'Die Zyxqwvland Saga', 'same_title')$$, + 'KAT-04 Kriterium 2: Treffer über den Haupttitel (games.title_search), Artikel am Haupttitel ignoriert'); +select results_eq($$select matched_title, match_kind from public.find_similar_catalog_games('Zyxqwvland Chronicl', 2019)$$, + $$values ('The Zyxqwvland Chronicle', 'similar_title')$$, + 'KAT-04 Kriterium 2: Trigramm-Treffer über den alternativen Titel (Jahr + 1 zum Spiel)'); +select is((select count(*) from public.find_similar_catalog_games('Zyxqwvland Chronicl', 2021)), 0::bigint, + 'KAT-04 Kriterium 2: Trigramm-Treffer über den alternativen Titel gilt das Jahr des Spiels (± 2 trifft nicht)'); + +-- Sonderfälle der Normalisierung +select is((select count(*) from public.find_similar_catalog_games('日本語ゲーム', null)), 1::bigint, + 'KAT-04 Kriterium 2: Titel ohne normalisierbare Zeichen trifft über den Rückfall lower(btrim)'); +select is((select count(*) from public.find_similar_catalog_games('das', null)), 1::bigint, + 'KAT-04 Kriterium 2: ein Titel nur aus einem Artikel trifft das gleichnamige Spiel'); + +-- nicht sichtbare Spiele (hidden, merged, privat von Ben und Anna) treffen nicht; das sichtbare Spiel schon +select results_eq($$select game_id from public.find_similar_catalog_games('Verstecktzyxq Spiel', 2015)$$, + $$values ('f13c0000-0000-4000-8000-00000000000d'::uuid)$$, + 'KAT-04 Kriterium 2: nur das sichtbare Katalogspiel trifft, nicht ausgeblendete, zusammengeführte oder private Spiele (auch nicht Bens und das eigene)'); + +-- Eingabegrenzen, Reihenfolge, Höchstzahl +select is((select count(*) from public.find_similar_catalog_games(repeat('a', 201), null)), 0::bigint, 'KAT-04 Kriterium 2: Titel über 200 Zeichen liefert eine leere Menge'); +select is((select count(*) from public.find_similar_catalog_games(' ', null)), 0::bigint, 'KAT-04 Kriterium 2: leerer Titel liefert eine leere Menge'); +select is((select count(*) from public.find_similar_catalog_games(null, null)), 0::bigint, 'KAT-04 Kriterium 2: Titel null liefert eine leere Menge'); +select is((select count(*) from public.find_similar_catalog_games('!!!', null)), 0::bigint, 'KAT-04 Kriterium 2: Titel nur aus Sonderzeichen liefert keinen Treffer'); +select is((select count(*) from public.find_similar_catalog_games(repeat('a', 200), null)), 0::bigint, 'KAT-04 Kriterium 2: Titel mit genau 200 Zeichen ist erlaubt'); +select is((select count(*) from public.find_similar_catalog_games('Limitzyxq Spiel', 2005)), 5::bigint, + 'KAT-04 Kriterium 2: höchstens 5 Treffer'); +select results_eq($$select game_id, match_kind from public.find_similar_catalog_games('Sortzyxq Reihenfolge', 2000)$$, + $$values ('f13c0000-0000-4000-8000-000000000003'::uuid, 'same_title'), + ('f13c0000-0000-4000-8000-000000000004'::uuid, 'similar_title'), + ('f13c0000-0000-4000-8000-000000000005'::uuid, 'similar_title')$$, + 'KAT-04 Kriterium 2: same_title zuerst, danach nach Ähnlichkeit absteigend'); +reset role; + +-- --------------------------------------------------------------------------------------------- +-- 6. report_missing_game (Krit. 1, 2, 3, 7, 8) +-- --------------------------------------------------------------------------------------------- +-- nicht angemeldet +select set_config('request.jwt.claims', '{"role":"authenticated"}', true); +set local role authenticated; +select throws_ok($$select public.report_missing_game('Zyxqtest Ohne Anmeldung')$$, 'P0001', 'not_authenticated', + 'Sicherheit Kriterium 2: ohne Nutzerkennung (auth.uid() null) wird nichts gemeldet'); +reset role; +select set_config('request.jwt.claims', '{"role":"anon"}', true); +set local role anon; +select throws_ok($$select public.report_missing_game('Zyxqtest Anon')$$, '42501', null, 'Sicherheit Kriterium 2: anon darf report_missing_game nicht aufrufen'); +select throws_ok($$select * from public.find_similar_catalog_games('Zyxqtest Anon')$$, '42501', null, 'Sicherheit Kriterium 2: anon darf find_similar_catalog_games nicht aufrufen'); +select throws_ok($$select public.adopt_report_target('a1300000-0000-4000-8000-0000000000ff')$$, '42501', null, 'Sicherheit Kriterium 2: anon darf adopt_report_target nicht aufrufen'); +select throws_ok($$select public.normalize_game_title('x')$$, '42501', null, 'Sicherheit Kriterium 2: anon darf normalize_game_title nicht aufrufen'); +select throws_ok($$select public.strip_leading_article('x')$$, '42501', null, 'Sicherheit Kriterium 2: anon darf strip_leading_article nicht aufrufen'); +select throws_ok($$select public.report_day(now())$$, '42501', null, 'Sicherheit Kriterium 2: anon darf report_day nicht aufrufen'); +select throws_ok($$select 1 from public.game_reports$$, '42501', null, 'Sicherheit Kriterium 2: anon darf keine Meldungen lesen'); +select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000a1', 'x', null)$$, '42501', null, + 'Sicherheit Kriterium 2: anon darf keine Meldung anlegen'); +select throws_ok($$update public.game_reports set result_seen_at = now()$$, '42501', null, 'Sicherheit Kriterium 2: anon darf keine Meldung ändern'); +reset role; + +select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000a1","role":"authenticated"}', true); +set local role authenticated; + +select results_eq($$select game_id from public.find_similar_catalog_games('Verstecktzyxq Spiel', 2015)$$, + $$values ('f13c0000-0000-4000-8000-00000000000d'::uuid)$$, + 'KAT-04 Kriterium 2 (Spec-F7): auch Annas eigener vorläufiger Eintrag mit demselben Titel trifft nicht'); + +-- Validierung (jeder Fehler mit Code, nichts bleibt zurück) +select throws_ok($$select public.report_missing_game('')$$, 'P0001', 'report_title_required', 'KAT-04 Kriterium 1: leerer Titel'); +select throws_ok($$select public.report_missing_game(' ')$$, 'P0001', 'report_title_required', 'KAT-04 Kriterium 1: Titel nur aus Leerzeichen'); +select throws_ok($$select public.report_missing_game(null)$$, 'P0001', 'report_title_required', 'KAT-04 Kriterium 1: Titel null'); +select throws_ok($$select public.report_missing_game(repeat('t', 201))$$, 'P0001', 'report_title_too_long', 'KAT-04 Kriterium 1: Titel über 200 Zeichen'); +select throws_ok($$select public.report_missing_game('Zyxqtest Verlag', repeat('v', 201))$$, 'P0001', 'report_publisher_too_long', 'KAT-04 Kriterium 1: Verlag über 200 Zeichen'); +select throws_ok($$select public.report_missing_game('Zyxqtest Jahr', null, 1799)$$, 'P0001', 'report_year_invalid', 'KAT-04 Kriterium 1: Jahr 1799'); +select throws_ok($$select public.report_missing_game('Zyxqtest Jahr', null, 2101)$$, 'P0001', 'report_year_invalid', 'KAT-04 Kriterium 1: Jahr 2101'); +select throws_ok($$select public.report_missing_game('Zyxqtest Jahr', null, 99999)$$, 'P0001', 'report_year_invalid', 'KAT-04 Kriterium 1: Jahr 99999 ergibt report_year_invalid, kein 22003'); +select throws_ok($$select public.report_missing_game('Zyxqtest Link', null, null, 'ftp://example.org')$$, 'P0001', 'report_link_invalid', 'KAT-04 Kriterium 1: Link mit anderem Schema'); +select throws_ok($$select public.report_missing_game('Zyxqtest Link', null, null, 'example.org/spiel')$$, 'P0001', 'report_link_invalid', 'KAT-04 Kriterium 1: Link ohne Schema'); +select throws_ok($$select public.report_missing_game('Zyxqtest Link', null, null, 'HTTP://example.org')$$, 'P0001', 'report_link_invalid', 'KAT-04 Kriterium 1: Schema in Großbuchstaben (wie der Tabellen-Check)'); +select throws_ok($$select public.report_missing_game('Zyxqtest Link', null, null, 'https://' || repeat('l', 493))$$, 'P0001', 'report_link_invalid', 'KAT-04 Kriterium 1: Link mit 501 Zeichen'); +select throws_ok($$select public.report_missing_game('Zyxqtest Liste', null, null, null, 'foo')$$, 'P0001', 'report_list_invalid', 'KAT-04 Kriterium 3: unbekannte Liste'); +select throws_ok($$select public.report_missing_game('Zyxqtest Liste', null, null, null, '')$$, 'P0001', 'report_list_invalid', 'KAT-04 Kriterium 3: leere Liste ist keine Liste'); +select is((select count(*) from public.game_reports) + (select count(*) from public.games where owner_id = 'a1300000-0000-4000-8000-0000000000a1' and title like 'Zyxqtest%'), + 0::bigint, 'KAT-04 Kriterium 1: nach allen Validierungsfehlern bleibt nichts zurück'); +select lives_ok($$select public.report_missing_game('Zyxqtest Grenzwerte ' || repeat('t', 180), repeat('v', 200), 1800, 'https://' || repeat('l', 492))$$, + 'KAT-04 Kriterium 1: Grenzwerte (Titel 200, Verlag 200, Jahr 1800, Link 500) sind gültig'); +select lives_ok($$select public.report_missing_game('Zyxqtest Jahr 2100', null, 2100, 'http://example.org')$$, 'KAT-04 Kriterium 1: Jahr 2100 und http-Link sind gültig'); +reset role; +select is((select count(*) from public.game_reports where owner_id = 'a1300000-0000-4000-8000-0000000000a1'), 2::bigint, 'Vorbedingung: Anna hat 2 Meldungen von den Grenzwert-Fällen'); +delete from public.game_reports where owner_id = 'a1300000-0000-4000-8000-0000000000a1'; +delete from public.games where owner_id = 'a1300000-0000-4000-8000-0000000000a1' and title like 'Zyxqtest%'; + +-- Erfolg ohne Liste: Trimmen, Felder, Rückgabe +select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000a1","role":"authenticated"}', true); +set local role authenticated; +create temp table res1 as select public.report_missing_game(' Zyxqtest Eins ', ' ', 2019, ' https://example.org/eins ') as j; +grant all on res1 to public; +select ok((select j ? 'report_id' and j ? 'provisional_game_id' and j ? 'collection_item_id' and j->'collection_item_id' = 'null'::jsonb from res1), + 'KAT-04 Kriterium 1: Rückgabe enthält report_id, provisional_game_id und collection_item_id null'); +select results_eq($$select r.owner_id, r.origin, r.status, r.title, r.publisher_name, r.year, r.link, r.target_game_id, r.resolved_at, r.result_seen_at, + r.provisional_game_id = (res1.j->>'provisional_game_id')::uuid, r.created_at >= now() - interval '1 minute' + from public.game_reports r, res1 where r.id = (res1.j->>'report_id')::uuid$$, + $$values ('a1300000-0000-4000-8000-0000000000a1'::uuid, 'user', 'open', 'Zyxqtest Eins', null::text, 2019::smallint, + 'https://example.org/eins', null::uuid, null::timestamptz, null::timestamptz, true, true)$$, + 'KAT-04 Kriterium 1 und 8: Meldung gespeichert (getrimmt, Leerverlag null), Status offen, Herkunft user, kein Ziel'); +select results_eq($$select g.title, g.year, g.visibility, g.owner_id, g.description, g.image_path, g.bgg_id, g.hidden_at, g.merged_into_id + from public.games g, res1 where g.id = (res1.j->>'provisional_game_id')::uuid$$, + $$values ('Zyxqtest Eins', 2019::smallint, 'private', 'a1300000-0000-4000-8000-0000000000a1'::uuid, null::text, null::text, null::integer, null::timestamptz, null::uuid)$$, + 'KAT-04 Kriterium 3: vorläufiger Eintrag ist privat, gehört Anna und hat nur Titel und Jahr'); +select is((select count(*) from public.collection_items where owner_id = 'a1300000-0000-4000-8000-0000000000a1'), 0::bigint, + 'KAT-04 Kriterium 3: ohne p_list entsteht kein Sammlungseintrag'); + +-- Erfolg mit Liste +create temp table res2 as select public.report_missing_game('Zyxqtest Zwei', 'Zyxq Eigenverlag', null, null, 'wishlist') as j; +grant all on res2 to public; +select results_eq($$select c.owner_id, c.game_id = (res2.j->>'provisional_game_id')::uuid, c.list, c.variant_id, c.id = (res2.j->>'collection_item_id')::uuid + from public.collection_items c, res2 where c.id = (res2.j->>'collection_item_id')::uuid$$, + $$values ('a1300000-0000-4000-8000-0000000000a1'::uuid, true, 'wishlist', null::uuid, true)$$, + 'KAT-04 Kriterium 3: p_list legt den Sammlungseintrag auf den vorläufigen Eintrag an, ohne Edition'); +select is((select publisher_name from public.game_reports where id = (select (j->>'report_id')::uuid from res2)), 'Zyxq Eigenverlag', + 'KAT-04 Kriterium 1: der Verlag wird als Freitext gespeichert'); +create temp table res_own as select public.report_missing_game('Zyxqtest Besitz', null, null, null, 'owned') as j; +grant all on res_own to public; +select is((select list from public.collection_items where id = (select (j->>'collection_item_id')::uuid from res_own)), 'owned', 'KAT-04 Kriterium 3: Liste owned'); +select lives_ok($$select public.report_missing_game('Zyxqtest Einkauf', null, null, null, 'shopping')$$, 'KAT-04 Kriterium 3: Liste shopping'); +reset role; + +-- Isolation: Ben sieht nichts von Anna +select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000b2","role":"authenticated"}', true); +set local role authenticated; +select is((select count(*) from public.game_reports where title like 'Zyxqtest%'), 0::bigint, 'Sicherheit Kriterium 1: Ben sieht Annas Meldungen nicht'); +select is((select count(*) from public.games where title like 'Zyxqtest%'), 0::bigint, 'Sicherheit Kriterium 3: Ben sieht Annas vorläufige Einträge nicht'); +select is((select count(*) from public.collection_items where game_id in (select (j->>'provisional_game_id')::uuid from res1 union all select (j->>'provisional_game_id')::uuid from res2)), 0::bigint, + 'Sicherheit Kriterium 3: Ben sieht Annas Listeneinträge nicht'); +reset role; + +-- Ähnlichkeitsprüfung beim Speichern (Krit. 2) +select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000c3","role":"authenticated"}', true); +set local role authenticated; +select throws_ok($$select public.report_missing_game('Die Quorblaxwitz Towers', null, 2020)$$, 'P0001', 'report_similar_exists', + 'KAT-04 Kriterium 2: ein sehr ähnlicher Katalogeintrag ohne Bestätigung wird nicht gespeichert'); +select throws_ok($$select public.report_missing_game('Quorblaxwitz Towers', null, 2020, null, null, null)$$, 'P0001', 'report_similar_exists', + 'KAT-04 Kriterium 2: p_confirmed_distinct = null gilt als nicht bestätigt'); +select is((select count(*) from public.game_reports) + (select count(*) from public.games where owner_id = 'a1300000-0000-4000-8000-0000000000c3'), 0::bigint, + 'KAT-04 Kriterium 2: ohne Bestätigung bleibt nichts zurück'); +select lives_ok($$select public.report_missing_game('Die Quorblaxwitz Towers', null, 2020, null, null, true)$$, + 'KAT-04 Kriterium 2: mit ausdrücklicher Bestätigung wird gespeichert'); +select is((select count(*) from public.game_reports where title = 'Die Quorblaxwitz Towers'), 1::bigint, 'KAT-04 Kriterium 2: die bestätigte Meldung ist gespeichert'); +reset role; + +-- Rollback bei Fehler im Listeneintrag: ein Hilfstrigger lässt den Insert in collection_items scheitern +create function pg_temp.fail_ci() returns trigger language plpgsql as $$ begin raise exception 'boom' using errcode = 'P0001'; end $$; +create trigger fail_ci before insert on public.collection_items for each row execute function pg_temp.fail_ci(); +select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000c3","role":"authenticated"}', true); +set local role authenticated; +select throws_ok($$select public.report_missing_game('Zyxqtest Rollback', null, null, null, 'owned')$$, 'P0001', 'boom', + 'Vorbedingung: der Listeneintrag scheitert'); +select is((select count(*) from public.game_reports where title = 'Zyxqtest Rollback') + (select count(*) from public.games where title = 'Zyxqtest Rollback'), 0::bigint, + 'KAT-04 Kriterium 3: scheitert der Listeneintrag, bleiben weder Meldung noch vorläufiges Spiel zurück'); +reset role; +drop trigger fail_ci on public.collection_items; + +-- --------------------------------------------------------------------------------------------- +-- 7. Tageslimit (Krit. 7) +-- --------------------------------------------------------------------------------------------- +select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000e1","role":"authenticated"}', true); +set local role authenticated; +select lives_ok($$select public.report_missing_game('Tageslimit Meldung ' || i) from generate_series(1, 10) i$$, + 'KAT-04 Kriterium 7: 10 Meldungen über report_missing_game sind erlaubt'); +select throws_ok($$select public.report_missing_game('Tageslimit Meldung 11')$$, 'P0001', 'report_daily_limit', 'KAT-04 Kriterium 7: die 11. Meldung scheitert am Tageslimit'); +select throws_ok($$select public.report_missing_game('Quorblaxwitz Towers', null, 2020)$$, 'P0001', 'report_daily_limit', + 'KAT-04 Kriterium 7: das Limit wird vor der Ähnlichkeitsprüfung gemeldet'); +select is((select count(*) from public.game_reports), 10::bigint, 'KAT-04 Kriterium 7: es bleiben genau 10 Meldungen'); +select is((select count(*) from public.games where owner_id = 'a1300000-0000-4000-8000-0000000000e1'), 10::bigint, 'KAT-04 Kriterium 7: kein vorläufiges Spiel der abgelehnten Meldung bleibt übrig'); +reset role; + +-- Meldungen von gestern zählen nicht +insert into public.games (id, title, visibility, owner_id) + select ('f13b0000-0000-4000-8000-' || lpad(to_hex(i), 12, '0'))::uuid, 'Gestern ' || i, 'private', 'a1300000-0000-4000-8000-0000000000e4' + from generate_series(1, 13) i; +insert into public.game_reports (owner_id, title, provisional_game_id, created_at) + select 'a1300000-0000-4000-8000-0000000000e4', 'Gestern ' || i, ('f13b0000-0000-4000-8000-' || lpad(to_hex(i), 12, '0'))::uuid, + -- created_at ist nach dem Anlegen unveränderlich: feste Zeitstempel gleich beim Einfügen (Nr. 1 und 2 an der Berliner Tagesgrenze) + case i when 1 then '2026-01-15 22:59:00+00'::timestamptz when 2 then '2026-01-15 23:30:00+00'::timestamptz else '2026-01-01 12:00:00+00'::timestamptz end + from generate_series(1, 10) i; +select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000e4","role":"authenticated"}', true); +set local role authenticated; +select lives_ok($$select public.report_missing_game('Heute nach zehn Meldungen von gestern')$$, 'KAT-04 Kriterium 7: Meldungen von gestern zählen nicht'); +reset role; +-- Grenze des Berliner Tages mit festem Zeitstempel: 23:30 UTC im Winter (00:30 am Folgetag in Berlin) zählt nicht zu 22:59 UTC desselben Tages +select is((select count(*) from public.game_reports where owner_id = 'a1300000-0000-4000-8000-0000000000e4' and public.report_day(created_at) = '2026-01-15'), 1::bigint, + 'KAT-04 Kriterium 7: der Berliner Kalendertag trennt 22:59 und 23:30 UTC im Winter'); + +-- Direkter Insert als authenticated: Trigger zählt, report_day ist für authenticated ausführbar +insert into public.games (id, title, visibility, owner_id) + select ('f13b0000-0000-4000-8000-' || lpad(to_hex(100 + i), 12, '0'))::uuid, 'Direkt ' || i, 'private', 'a1300000-0000-4000-8000-0000000000e2' + from generate_series(1, 11) i; +select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000e2","role":"authenticated"}', true); +set local role authenticated; +select lives_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) + select 'a1300000-0000-4000-8000-0000000000e2', 'Direkt ' || i, ('f13b0000-0000-4000-8000-' || lpad(to_hex(100 + i), 12, '0'))::uuid from generate_series(1, 10) i$$, + 'KAT-04 Kriterium 7: 10 direkte Inserts als authenticated sind erlaubt (der Trigger darf report_day ausführen)'); +select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values + ('a1300000-0000-4000-8000-0000000000e2', 'Direkt 11', 'f13b0000-0000-4000-8000-00000000006f')$$, 'P0001', 'report_daily_limit', + 'KAT-04 Kriterium 7: auch der direkte 11. Insert scheitert am Limit'); +reset role; + +-- Import-Meldungen zählen nicht und werden nicht begrenzt (N12) +insert into public.game_reports (owner_id, origin, title, bgg_id) + select 'a1300000-0000-4000-8000-0000000000e3', 'import', 'Import ' || i, 9000 + i from generate_series(1, 12) i; +select is((select count(*) from public.game_reports where owner_id = 'a1300000-0000-4000-8000-0000000000e3' and origin = 'import'), 12::bigint, + 'KAT-04 Kriterium 7 (N12): mehr als 10 Import-Meldungen werden nicht begrenzt'); +select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000e3","role":"authenticated"}', true); +set local role authenticated; +select lives_ok($$select public.report_missing_game('Zyxqtest nach Import ' || i) from generate_series(1, 10) i$$, + 'KAT-04 Kriterium 7 (N12): Import-Meldungen zählen nicht auf das Limit des Nutzers'); +select throws_ok($$select public.report_missing_game('Zyxqtest nach Import 11')$$, 'P0001', 'report_daily_limit', + 'KAT-04 Kriterium 7: danach greift das Limit für Nutzer-Meldungen'); +reset role; +-- Meldungen von Nutzer B zählen nicht für Nutzer A: Anna hat oben nur wenige, Ben (e1 hat 10) ändert nichts +select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000a1","role":"authenticated"}', true); +set local role authenticated; +select lives_ok($$select public.report_missing_game('Zyxqtest Anna nach Limit anderer')$$, 'KAT-04 Kriterium 7: die Meldungen anderer Nutzer zählen nicht für Anna'); +reset role; + +-- --------------------------------------------------------------------------------------------- +-- 8. Direkter Zugriff auf game_reports: Policies und Spaltenrechte (Krit. 8, Sicherheit 1 und 3) +-- --------------------------------------------------------------------------------------------- +insert into public.games (id, title, visibility, owner_id, hidden_at, merged_into_id) values + ('f13a0000-0000-4000-8000-000000000011', 'Direkt ok', 'private', 'a1300000-0000-4000-8000-0000000000c3', null, null), + ('f13a0000-0000-4000-8000-000000000012', 'Direkt versteckt', 'private', 'a1300000-0000-4000-8000-0000000000c3', now(), null), + ('f13a0000-0000-4000-8000-000000000013', 'Direkt zusammengeführt', 'private', 'a1300000-0000-4000-8000-0000000000c3', now(), 'f13c0000-0000-4000-8000-00000000000d'), + ('f13a0000-0000-4000-8000-000000000014', 'Bens Privatspiel', 'private', 'a1300000-0000-4000-8000-0000000000b2', null, null), + ('f13a0000-0000-4000-8000-000000000015', 'Direkt ok 2', 'private', 'a1300000-0000-4000-8000-0000000000c3', null, null); +-- Hilfsfunktion (läuft mit den Rechten des Aufrufers): result_seen_at setzen und die Zahl der geänderten Zeilen liefern. +-- (Ein datenändernder WITH-Block ist in einer Unterabfrage nicht erlaubt.) +create function pg_temp.mark_seen(p_id uuid, p_at timestamptz) returns bigint language plpgsql as $$ +declare n bigint; +begin + update public.game_reports set result_seen_at = p_at where p_id is null or id = p_id; + get diagnostics n = row_count; + return n; +end $$; +select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000c3","role":"authenticated"}', true); +set local role authenticated; +select lives_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000c3', 'Direkt ok', 'f13a0000-0000-4000-8000-000000000011')$$, + 'Sicherheit Kriterium 1: Cara legt direkt eine Meldung zu ihrem eigenen privaten Spiel an (Positivfall)'); +select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000b2', 'Im Namen von Ben', 'f13a0000-0000-4000-8000-000000000014')$$, '42501', null, + 'Sicherheit Kriterium 1: Cara kann keine Meldung für Ben anlegen'); +select throws_ok($$insert into public.game_reports (owner_id, title) values ('a1300000-0000-4000-8000-0000000000c3', 'Ohne vorläufigen Eintrag')$$, '42501', null, + 'Sicherheit Kriterium 1: eine Meldung ohne vorläufigen Eintrag wird abgelehnt'); +select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000c3', 'Bens Spiel', 'f13a0000-0000-4000-8000-000000000014')$$, '42501', null, + 'Sicherheit Kriterium 1: ein fremdes privates Spiel als vorläufiger Eintrag wird abgelehnt'); +select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000c3', 'Katalogspiel', 'f13c0000-0000-4000-8000-000000000001')$$, '42501', null, + 'Sicherheit Kriterium 1: ein Katalogspiel als vorläufiger Eintrag wird abgelehnt'); +select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000c3', 'Versteckt', 'f13a0000-0000-4000-8000-000000000012')$$, '42501', null, + 'Sicherheit Kriterium 1: ein ausgeblendetes Spiel als vorläufiger Eintrag wird abgelehnt'); +select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000c3', 'Zusammengeführt', 'f13a0000-0000-4000-8000-000000000013')$$, '42501', null, + 'Sicherheit Kriterium 1: ein zusammengeführtes Spiel als vorläufiger Eintrag wird abgelehnt'); +select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000c3', 'Doppelt', 'f13a0000-0000-4000-8000-000000000011')$$, '23505', null, + 'Sicherheit Kriterium 1: eine zweite Meldung zum selben vorläufigen Eintrag scheitert am eindeutigen Index'); +select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id, status) values ('a1300000-0000-4000-8000-0000000000c3', 'S', 'f13a0000-0000-4000-8000-000000000015', 'approved')$$, '42501', null, + 'KAT-04 Kriterium 8: status lässt sich beim Anlegen nicht setzen'); +select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id, origin) values ('a1300000-0000-4000-8000-0000000000c3', 'S', 'f13a0000-0000-4000-8000-000000000015', 'import')$$, '42501', null, + 'KAT-04 Kriterium 7: origin lässt sich beim Anlegen nicht setzen (Import-Meldungen umgehen das Limit nicht)'); +select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id, created_at) values ('a1300000-0000-4000-8000-0000000000c3', 'S', 'f13a0000-0000-4000-8000-000000000015', '2026-01-01')$$, '42501', null, + 'KAT-04 Kriterium 7: created_at lässt sich beim Anlegen nicht setzen'); +select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id, target_game_id) values ('a1300000-0000-4000-8000-0000000000c3', 'S', 'f13a0000-0000-4000-8000-000000000015', 'f13c0000-0000-4000-8000-000000000001')$$, '42501', null, + 'KAT-04 Kriterium 8: target_game_id lässt sich beim Anlegen nicht setzen'); +select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id, resolved_at) values ('a1300000-0000-4000-8000-0000000000c3', 'S', 'f13a0000-0000-4000-8000-000000000015', now())$$, '42501', null, + 'KAT-04 Kriterium 8: resolved_at lässt sich beim Anlegen nicht setzen'); +select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id, bgg_id) values ('a1300000-0000-4000-8000-0000000000c3', 'S', 'f13a0000-0000-4000-8000-000000000015', 1)$$, '42501', null, + 'KAT-04: bgg_id lässt sich beim Anlegen nicht setzen'); +select throws_ok($$insert into public.game_reports (id, owner_id, title, provisional_game_id) values (gen_random_uuid(), 'a1300000-0000-4000-8000-0000000000c3', 'S', 'f13a0000-0000-4000-8000-000000000015')$$, '42501', null, + 'KAT-04: id lässt sich beim Anlegen nicht setzen'); +select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000c3', '', 'f13a0000-0000-4000-8000-000000000015')$$, '23514', null, + 'KAT-04 Kriterium 1: ein leerer Titel scheitert am Tabellen-Check'); +select throws_ok($$insert into public.game_reports (owner_id, title, year, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000c3', 'S', 1700, 'f13a0000-0000-4000-8000-000000000015')$$, '23514', null, + 'KAT-04 Kriterium 1: ein Jahr außerhalb 1800-2100 scheitert am Tabellen-Check'); +select throws_ok($$update public.game_reports set status = 'approved' where owner_id = 'a1300000-0000-4000-8000-0000000000c3'$$, '42501', null, + 'KAT-04 Kriterium 8: status lässt sich nicht ändern'); +select throws_ok($$update public.game_reports set title = 'Umbenannt' where owner_id = 'a1300000-0000-4000-8000-0000000000c3'$$, '42501', null, + 'KAT-04: title lässt sich nicht ändern'); +select throws_ok($$update public.game_reports set target_game_id = 'f13c0000-0000-4000-8000-000000000001' where owner_id = 'a1300000-0000-4000-8000-0000000000c3'$$, '42501', null, + 'KAT-04 Kriterium 8: target_game_id lässt sich nicht ändern'); +select throws_ok($$update public.game_reports set resolved_at = now() where owner_id = 'a1300000-0000-4000-8000-0000000000c3'$$, '42501', null, + 'KAT-04 Kriterium 8: resolved_at lässt sich nicht ändern'); +select throws_ok($$delete from public.game_reports where owner_id = 'a1300000-0000-4000-8000-0000000000c3'$$, '42501', null, + 'Sicherheit Kriterium 1: eigene Meldungen lassen sich nicht löschen'); +-- result_seen_at bei offener Meldung: 0 Zeilen +select is(pg_temp.mark_seen(null, now()), 0::bigint, + 'KAT-04 Kriterium 8: result_seen_at an einer offenen Meldung ändert keine Zeile'); +reset role; +select throws_ok($$update public.game_reports set result_seen_at = now() where owner_id = 'a1300000-0000-4000-8000-0000000000c3'$$, '23514', null, + 'KAT-04 Kriterium 8: auch postgres kann result_seen_at bei einer offenen Meldung nicht setzen (Check)'); + +-- --------------------------------------------------------------------------------------------- +-- 9. Ergebnis nach admin_resolve_report (Krit. 4 und 6), result_seen_at +-- --------------------------------------------------------------------------------------------- +select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000a1","role":"authenticated"}', true); +set local role authenticated; +create temp table res_ap as select public.report_missing_game('Freigabezyxq Neu', 'Freigabe Verlag', 2023, null, 'owned') as j; +grant all on res_ap to public; +create temp table res_rej as select public.report_missing_game('Ablehnzyxq Spiel', null, null, null, 'wishlist') as j; +grant all on res_rej to public; +update public.collection_items set note = 'Annas Notiz', acquired_on = '2025-05-05' + where id = (select (j->>'collection_item_id')::uuid from res_ap); +reset role; +insert into public.game_variants (id, game_id, name) values + ('f13a0000-0000-4000-8000-0000000000b1', (select (j->>'provisional_game_id')::uuid from res_ap), 'Eigene Edition'); +update public.collection_items set variant_id = 'f13a0000-0000-4000-8000-0000000000b1' + where id = (select (j->>'collection_item_id')::uuid from res_ap); +insert into public.games (id, title, year, visibility) values ('f13c0000-0000-4000-8000-000000000016', 'Freigabezyxq Neu', 2023, 'catalog'); +select lives_ok($$select public.admin_resolve_report((select (j->>'report_id')::uuid from res_ap), 'approved', 'f13c0000-0000-4000-8000-000000000016')$$, + 'Vorbedingung: der Admin gibt die Meldung frei'); +select lives_ok($$select public.admin_resolve_report((select (j->>'report_id')::uuid from res_rej), 'rejected')$$, + 'Vorbedingung: der Admin lehnt die zweite Meldung ohne Ziel ab'); + +select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000a1","role":"authenticated"}', true); +set local role authenticated; +select results_eq($$select game_id, list, note, acquired_on, variant_id from public.collection_items where id = (select (j->>'collection_item_id')::uuid from res_ap)$$, + $$values ('f13c0000-0000-4000-8000-000000000016'::uuid, 'owned', 'Annas Notiz', '2025-05-05'::date, null::uuid)$$, + 'KAT-04 Kriterium 4: der Katalogeintrag ersetzt den vorläufigen; Liste, Notiz und Kaufdatum bleiben, die Edition ohne gleichnamige Variante ist leer'); +select results_eq($$select r.status, r.target_game_id, r.editions_cleared, r.result_seen_at from public.game_reports r where r.id = (select (j->>'report_id')::uuid from res_ap)$$, + $$values ('approved', 'f13c0000-0000-4000-8000-000000000016'::uuid, 1, null::timestamptz)$$, + 'KAT-04 Kriterium 4: Meldung freigegeben, editions_cleared zählt die geleerte Edition, Hinweis noch ungesehen'); +select is((select count(*) from public.game_variants where id = 'f13a0000-0000-4000-8000-0000000000b1'), 1::bigint, + 'KAT-04 Kriterium 4: Annas private Variante bleibt für sie lesbar'); +select results_eq($$select g.visibility, g.merged_into_id, g.hidden_at is not null from public.games g where g.id = (select (j->>'provisional_game_id')::uuid from res_ap)$$, + $$values ('private', 'f13c0000-0000-4000-8000-000000000016'::uuid, true)$$, + 'KAT-04 Kriterium 4: der vorläufige Eintrag ist ausgeblendet und zeigt auf den Katalogeintrag, Anna liest ihn weiter'); +select is(pg_temp.mark_seen((select (j->>'report_id')::uuid from res_ap), '2026-11-04 10:00:00+00'), 1::bigint, + 'KAT-04 Kriterium 4: Anna kann den Hinweis an der freigegebenen Meldung wegklicken (result_seen_at)'); +select is((select result_seen_at from public.game_reports where id = (select (j->>'report_id')::uuid from res_ap)), '2026-11-04 10:00:00+00'::timestamptz, + 'KAT-04 Kriterium 4: result_seen_at ist gesetzt'); +-- Krit. 6 +select results_eq($$select r.status, r.target_game_id, r.provisional_game_id is not null from public.game_reports r where r.id = (select (j->>'report_id')::uuid from res_rej)$$, + $$values ('rejected', null::uuid, true)$$, 'KAT-04 Kriterium 6: abgelehnt ohne Ziel, der vorläufige Eintrag ist noch verknüpft'); +select results_eq($$select g.visibility, g.merged_into_id, g.hidden_at from public.games g where g.id = (select (j->>'provisional_game_id')::uuid from res_rej)$$, + $$values ('private', null::uuid, null::timestamptz)$$, 'KAT-04 Kriterium 6: der vorläufige Eintrag bleibt privat, sichtbar und lesbar'); +select is((select count(*) from public.collection_items where id = (select (j->>'collection_item_id')::uuid from res_rej) and game_id = (select (j->>'provisional_game_id')::uuid from res_rej)), 1::bigint, + 'KAT-04 Kriterium 6: der Listeneintrag bleibt am vorläufigen Eintrag'); +select is(pg_temp.mark_seen((select (j->>'report_id')::uuid from res_rej), now()), 1::bigint, + 'KAT-04 Kriterium 6: auch an der abgelehnten Meldung lässt sich der Hinweis wegklicken'); +reset role; +-- Fremder Nutzer kann nichts wegklicken +select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000b2","role":"authenticated"}', true); +set local role authenticated; +select is(pg_temp.mark_seen((select (j->>'report_id')::uuid from res_ap), now()), 0::bigint, + 'Sicherheit Kriterium 1: Ben kann bei fremden Meldungen nichts wegklicken (0 Zeilen)'); +reset role; + +-- --------------------------------------------------------------------------------------------- +-- 10. adopt_report_target (Krit. 5) +-- --------------------------------------------------------------------------------------------- +-- R1: vollständiger Wechsel. Anna hat am vorläufigen Eintrag Sammlung, Favorit, Partien, Wertungsbogen, zwei Varianten. +insert into public.games (id, title, year, visibility, owner_id) values + ('f13a0000-0000-4000-8000-000000000101', 'Adoptzyxq Eigen', 2021, 'private', 'a1300000-0000-4000-8000-0000000000a1'); +insert into public.game_variants (id, game_id, name) values + ('f13a0000-0000-4000-8000-0000000001b1', 'f13a0000-0000-4000-8000-000000000101', ' standard '), + ('f13a0000-0000-4000-8000-0000000001b2', 'f13a0000-0000-4000-8000-000000000101', 'Prototyp'); +insert into public.collection_items (id, owner_id, game_id, variant_id, list, note, acquired_on) values + ('f13a0000-0000-4000-8000-0000000001c1', 'a1300000-0000-4000-8000-0000000000a1', 'f13a0000-0000-4000-8000-000000000101', 'f13a0000-0000-4000-8000-0000000001b2', 'owned', 'Notiz bleibt', '2025-01-02'); +insert into public.favorite_games (id, profile_id, game_id, position) values + ('f13a0000-0000-4000-8000-0000000001d1', 'a1300000-0000-4000-8000-0000000000a1', 'f13a0000-0000-4000-8000-000000000101', 1); +insert into public.plays (id, owner_id, game_id, variant_id, played_at, client_id) values + ('f13a0000-0000-4000-8000-0000000001e1', 'a1300000-0000-4000-8000-0000000000a1', 'f13a0000-0000-4000-8000-000000000101', 'f13a0000-0000-4000-8000-0000000001b1', '2026-02-01', 'f13a0000-0000-4000-8000-0000000001e1'), + ('f13a0000-0000-4000-8000-0000000001e2', 'a1300000-0000-4000-8000-0000000000a1', 'f13a0000-0000-4000-8000-000000000101', 'f13a0000-0000-4000-8000-0000000001b2', '2026-02-02', 'f13a0000-0000-4000-8000-0000000001e2'); +insert into public.score_sheets (id, owner_id, game_id, name) values + ('f13a0000-0000-4000-8000-0000000001f1', 'a1300000-0000-4000-8000-0000000000a1', 'f13a0000-0000-4000-8000-000000000101', 'Bogen'); +insert into public.game_reports (id, owner_id, title, provisional_game_id, status, target_game_id, resolved_at, created_at) values + ('f13a0000-0000-4000-8000-000000000a01', 'a1300000-0000-4000-8000-0000000000a1', 'Adoptzyxq Eigen', 'f13a0000-0000-4000-8000-000000000101', 'rejected', 'f13c0000-0000-4000-8000-000000000013', now(), '2026-02-01'); + +-- Hilfsfälle für die Fehlerpfade +insert into public.games (id, title, visibility, owner_id) values + ('f13a0000-0000-4000-8000-000000000102', 'Adopt offen', 'private', 'a1300000-0000-4000-8000-0000000000a1'), + ('f13a0000-0000-4000-8000-000000000103', 'Adopt ohne Ziel', 'private', 'a1300000-0000-4000-8000-0000000000a1'), + ('f13a0000-0000-4000-8000-000000000104', 'Adopt freigegeben', 'private', 'a1300000-0000-4000-8000-0000000000a1'), + ('f13a0000-0000-4000-8000-000000000105', 'Adopt Ziel versteckt', 'private', 'a1300000-0000-4000-8000-0000000000a1'), + ('f13a0000-0000-4000-8000-000000000106', 'Adopt Ziel zusammengefuehrt', 'private', 'a1300000-0000-4000-8000-0000000000a1'), + ('f13a0000-0000-4000-8000-000000000107', 'Adopt Bens Eintrag', 'private', 'a1300000-0000-4000-8000-0000000000b2'), + ('f13a0000-0000-4000-8000-0000000001a1', 'Adopt fremd Partie', 'private', 'a1300000-0000-4000-8000-0000000000a1'), + ('f13a0000-0000-4000-8000-0000000001a2', 'Adopt Partie ohne Eigentuemer', 'private', 'a1300000-0000-4000-8000-0000000000a1'), + ('f13a0000-0000-4000-8000-0000000001a3', 'Adopt fremd Sammlung', 'private', 'a1300000-0000-4000-8000-0000000000a1'), + ('f13a0000-0000-4000-8000-0000000001a4', 'Adopt fremd Favorit', 'private', 'a1300000-0000-4000-8000-0000000000a1'), + ('f13a0000-0000-4000-8000-0000000001a5', 'Adopt fremd Bogen', 'private', 'a1300000-0000-4000-8000-0000000000a1'); +insert into public.game_reports (id, owner_id, title, provisional_game_id, status, target_game_id, resolved_at, created_at) values + ('f13a0000-0000-4000-8000-000000000a02', 'a1300000-0000-4000-8000-0000000000a1', 'offen', 'f13a0000-0000-4000-8000-000000000102', 'open', null, null, '2026-02-01'), + ('f13a0000-0000-4000-8000-000000000a03', 'a1300000-0000-4000-8000-0000000000a1', 'ohne Ziel', 'f13a0000-0000-4000-8000-000000000103', 'rejected', null, now(), '2026-02-01'), + ('f13a0000-0000-4000-8000-000000000a04', 'a1300000-0000-4000-8000-0000000000a1', 'freigegeben', 'f13a0000-0000-4000-8000-000000000104', 'approved', 'f13c0000-0000-4000-8000-000000000013', now(), '2026-02-01'), + ('f13a0000-0000-4000-8000-000000000a05', 'a1300000-0000-4000-8000-0000000000a1', 'ohne vorläufigen Eintrag', null, 'rejected', 'f13c0000-0000-4000-8000-000000000013', now(), '2026-02-01'), + ('f13a0000-0000-4000-8000-000000000a06', 'a1300000-0000-4000-8000-0000000000a1', 'Ziel versteckt', 'f13a0000-0000-4000-8000-000000000105', 'rejected', 'f13c0000-0000-4000-8000-000000000014', now(), '2026-02-01'), + ('f13a0000-0000-4000-8000-000000000a07', 'a1300000-0000-4000-8000-0000000000a1', 'Ziel zusammengeführt', 'f13a0000-0000-4000-8000-000000000106', 'rejected', 'f13c0000-0000-4000-8000-000000000015', now(), '2026-02-01'), + ('f13a0000-0000-4000-8000-000000000a08', 'a1300000-0000-4000-8000-0000000000a1', 'Eintrag gehört Ben', 'f13a0000-0000-4000-8000-000000000107', 'rejected', 'f13c0000-0000-4000-8000-000000000013', now(), '2026-02-01'), + ('f13a0000-0000-4000-8000-000000000a09', 'a1300000-0000-4000-8000-0000000000a1', 'Eintrag ist Katalog', 'f13c0000-0000-4000-8000-000000000001', 'rejected', 'f13c0000-0000-4000-8000-000000000013', now(), '2026-02-01'), + ('f13a0000-0000-4000-8000-000000000a11', 'a1300000-0000-4000-8000-0000000000a1', 'fremde Partie', 'f13a0000-0000-4000-8000-0000000001a1', 'rejected', 'f13c0000-0000-4000-8000-000000000013', now(), '2026-02-01'), + ('f13a0000-0000-4000-8000-000000000a12', 'a1300000-0000-4000-8000-0000000000a1', 'Partie ohne Eigentümer', 'f13a0000-0000-4000-8000-0000000001a2', 'rejected', 'f13c0000-0000-4000-8000-000000000013', now(), '2026-02-01'), + ('f13a0000-0000-4000-8000-000000000a13', 'a1300000-0000-4000-8000-0000000000a1', 'fremde Sammlung', 'f13a0000-0000-4000-8000-0000000001a3', 'rejected', 'f13c0000-0000-4000-8000-000000000013', now(), '2026-02-01'), + ('f13a0000-0000-4000-8000-000000000a14', 'a1300000-0000-4000-8000-0000000000a1', 'fremder Favorit', 'f13a0000-0000-4000-8000-0000000001a4', 'rejected', 'f13c0000-0000-4000-8000-000000000013', now(), '2026-02-01'), + ('f13a0000-0000-4000-8000-000000000a15', 'a1300000-0000-4000-8000-0000000000a1', 'fremder Bogen', 'f13a0000-0000-4000-8000-0000000001a5', 'rejected', 'f13c0000-0000-4000-8000-000000000013', now(), '2026-02-01'); +insert into public.plays (id, owner_id, game_id, played_at, client_id) values + ('f13a0000-0000-4000-8000-0000000001e3', 'a1300000-0000-4000-8000-0000000000b2', 'f13a0000-0000-4000-8000-0000000001a1', '2026-02-03', 'f13a0000-0000-4000-8000-0000000001e3'), + ('f13a0000-0000-4000-8000-0000000001e4', null, 'f13a0000-0000-4000-8000-0000000001a2', '2026-02-04', 'f13a0000-0000-4000-8000-0000000001e4'); +insert into public.collection_items (owner_id, game_id) values ('a1300000-0000-4000-8000-0000000000b2', 'f13a0000-0000-4000-8000-0000000001a3'); +insert into public.favorite_games (profile_id, game_id, position) values ('a1300000-0000-4000-8000-0000000000b2', 'f13a0000-0000-4000-8000-0000000001a4', 1); +insert into public.score_sheets (owner_id, game_id, name) values ('a1300000-0000-4000-8000-0000000000b2', 'f13a0000-0000-4000-8000-0000000001a5', 'Bogen Ben'); + +-- Fremde und ungültige Aufrufe +select set_config('request.jwt.claims', '{"role":"authenticated"}', true); +set local role authenticated; +select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a01')$$, 'P0001', 'not_authenticated', + 'Sicherheit Kriterium 2: ohne Nutzerkennung (auth.uid() null) wird nichts übernommen'); +reset role; +select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000b2","role":"authenticated"}', true); +set local role authenticated; +select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a01')$$, 'P0001', 'report_not_found', + 'Sicherheit Kriterium 1: Ben kann Annas Meldung nicht übernehmen'); +select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-00000000ffff')$$, 'P0001', 'report_not_found', + 'KAT-04 Kriterium 5: unbekannte Meldung (gleicher Fehler wie fremde)'); +select throws_ok($$select public.adopt_report_target(null)$$, 'P0001', 'report_not_found', 'KAT-04 Kriterium 5: Meldung null'); +reset role; +select is((select merged_into_id from public.games where id = 'f13a0000-0000-4000-8000-000000000101'), null::uuid, + 'Sicherheit Kriterium 1: Bens Versuch hat Annas vorläufigen Eintrag nicht verändert'); +select is((select game_id from public.collection_items where id = 'f13a0000-0000-4000-8000-0000000001c1'), 'f13a0000-0000-4000-8000-000000000101'::uuid, + 'Sicherheit Kriterium 1: Bens Versuch hat Annas Sammlung nicht umgehängt'); + +select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000a1","role":"authenticated"}', true); +set local role authenticated; +select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a02')$$, 'P0001', 'report_not_adoptable', 'KAT-04 Kriterium 5: offene Meldung'); +select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a03')$$, 'P0001', 'report_not_adoptable', 'KAT-04 Kriterium 6: abgelehnt ohne Ziel gibt es nichts zu übernehmen'); +select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a04')$$, 'P0001', 'report_not_adoptable', 'KAT-04 Kriterium 5: freigegebene Meldung'); +select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a05')$$, 'P0001', 'report_no_provisional', 'KAT-04 Kriterium 5: vorläufiger Eintrag fehlt (vom Nutzer gelöscht)'); +select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a06')$$, 'P0001', 'report_target_not_listed', 'KAT-04 Kriterium 5: Ziel ist ausgeblendet'); +select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a07')$$, 'P0001', 'report_target_not_listed', 'KAT-04 Kriterium 5: Ziel ist zusammengeführt'); +select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a08')$$, 'P0001', 'report_not_found', 'Sicherheit Kriterium 3: der vorläufige Eintrag gehört einem anderen Nutzer'); +select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a09')$$, 'P0001', 'report_not_found', 'Sicherheit Kriterium 3: der vorläufige Eintrag ist ein Katalogspiel'); +select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a11')$$, 'P0001', 'report_foreign_references', 'Sicherheit Kriterium 3: Partie eines anderen Eigentümers am vorläufigen Eintrag'); +select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a12')$$, 'P0001', 'report_foreign_references', 'Sicherheit Kriterium 3: Partie ohne Eigentümer (null zählt als fremd)'); +select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a13')$$, 'P0001', 'report_foreign_references', 'Sicherheit Kriterium 3: Sammlungseintrag eines anderen Nutzers'); +select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a14')$$, 'P0001', 'report_foreign_references', 'Sicherheit Kriterium 3: Favorit eines anderen Nutzers'); +select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a15')$$, 'P0001', 'report_foreign_references', 'Sicherheit Kriterium 3: Wertungsbogen eines anderen Nutzers'); +reset role; +select is((select count(*) from public.games where id::text like 'f13a0000-0000-4000-8000-0000000001a%' and merged_into_id is null and hidden_at is null), 5::bigint, + 'Sicherheit Kriterium 3: nach allen Fehlern ist keiner der geprüften vorläufigen Einträge verändert'); +select is((select count(*) from public.plays where id in ('f13a0000-0000-4000-8000-0000000001e3', 'f13a0000-0000-4000-8000-0000000001e4') and game_id::text like 'f13a0000-0000-4000-8000-0000000001a%'), 2::bigint, + 'Sicherheit Kriterium 3: fremde Partien wurden nicht umgehängt'); + +-- Erfolg +select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000a1","role":"authenticated"}', true); +set local role authenticated; +create temp table res_adopt as select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a01') as j; +grant all on res_adopt to public; +select results_eq($$select (j->>'collection_items')::int, (j->>'favorite_games')::int, (j->>'plays')::int, (j->>'score_sheets')::int, + (j->>'variants_copied')::int, (j->>'variants_mapped')::int, (j->>'variants_cleared')::int from res_adopt$$, + $$values (1, 1, 2, 1, 0, 1, 2)$$, + 'KAT-04 Kriterium 5: Rückgabe zählt Sammlung, Favorit, Partien, Wertungsbogen; keine Variante kopiert, eine abgebildet, zwei geleert'); +select results_eq($$select game_id, list, note, acquired_on, variant_id from public.collection_items where id = 'f13a0000-0000-4000-8000-0000000001c1'$$, + $$values ('f13c0000-0000-4000-8000-000000000013'::uuid, 'owned', 'Notiz bleibt', '2025-01-02'::date, null::uuid)$$, + 'KAT-04 Kriterium 5: Sammlungseintrag zeigt aufs Ziel, Liste/Notiz/Kaufdatum bleiben, die nicht passende Edition ist geleert'); +select results_eq($$select id, game_id, variant_id from public.plays where owner_id = 'a1300000-0000-4000-8000-0000000000a1' and id in ('f13a0000-0000-4000-8000-0000000001e1', 'f13a0000-0000-4000-8000-0000000001e2') order by id$$, + $$values ('f13a0000-0000-4000-8000-0000000001e1'::uuid, 'f13c0000-0000-4000-8000-000000000013'::uuid, 'f13c0000-0000-4000-8000-0000000000b1'::uuid), + ('f13a0000-0000-4000-8000-0000000001e2'::uuid, 'f13c0000-0000-4000-8000-000000000013'::uuid, null::uuid)$$, + 'KAT-04 Kriterium 5: Partien zeigen aufs Ziel, die gleichnamige Edition wird abgebildet, die andere geleert'); +select is((select game_id from public.favorite_games where id = 'f13a0000-0000-4000-8000-0000000001d1'), 'f13c0000-0000-4000-8000-000000000013'::uuid, 'KAT-04 Kriterium 5: Favorit zeigt aufs Ziel'); +select is((select game_id from public.score_sheets where id = 'f13a0000-0000-4000-8000-0000000001f1'), 'f13c0000-0000-4000-8000-000000000013'::uuid, 'KAT-04 Kriterium 5: Wertungsbogen zeigt aufs Ziel'); +select results_eq($$select visibility, merged_into_id, hidden_at is not null from public.games where id = 'f13a0000-0000-4000-8000-000000000101'$$, + $$values ('private', 'f13c0000-0000-4000-8000-000000000013'::uuid, true)$$, + 'KAT-04 Kriterium 5: der vorläufige Eintrag ist ausgeblendet und zeigt aufs Ziel, bleibt privat'); +select results_eq($$select status, target_game_id, result_seen_at, editions_cleared from public.game_reports where id = 'f13a0000-0000-4000-8000-000000000a01'$$, + $$values ('rejected', 'f13c0000-0000-4000-8000-000000000013'::uuid, null::timestamptz, 2)$$, + 'KAT-04 Kriterium 5: Status bleibt rejected, result_seen_at bleibt null, editions_cleared ist gesetzt'); +select throws_ok($$select public.adopt_report_target('f13a0000-0000-4000-8000-000000000a01')$$, 'P0001', 'report_already_adopted', 'KAT-04 Kriterium 5: ein zweiter Wechsel scheitert'); +reset role; + +-- --------------------------------------------------------------------------------------------- +-- 11. Datenschutz: Konto löschen (QUE-04, Klärung 10) +-- --------------------------------------------------------------------------------------------- +select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000d4","role":"authenticated"}', true); +set local role authenticated; +select lives_ok($$select public.report_missing_game('Zyxqtest Danas Spiel', null, null, null, 'owned')$$, 'Vorbedingung: Dana meldet ein Spiel'); +reset role; +select is((select count(*) from public.game_reports where owner_id = 'a1300000-0000-4000-8000-0000000000d4'), 1::bigint, 'Vorbedingung: Dana hat eine Meldung'); +select lives_ok($$delete from auth.users where id = 'a1300000-0000-4000-8000-0000000000d4'$$, 'QUE-04: Danas Konto lässt sich löschen'); +select is((select count(*) from public.game_reports where owner_id = 'a1300000-0000-4000-8000-0000000000d4') + + (select count(*) from public.games where owner_id = 'a1300000-0000-4000-8000-0000000000d4') + + (select count(*) from public.collection_items where owner_id = 'a1300000-0000-4000-8000-0000000000d4'), 0::bigint, + 'QUE-04: Meldungen, vorläufige Spiele und Listeneinträge sind mit dem Konto gelöscht'); +select cmp_ok((select count(*) from public.game_reports where owner_id = 'a1300000-0000-4000-8000-0000000000a1'), '>', 0::bigint, 'QUE-04: Anna hat weiterhin ihre Meldungen'); + +select * from finish(); +rollback;