feat(catalog): Fehlendes Spiel melden – Backend (KAT-04, 13a) #67

Open
cschlaefke wants to merge 6 commits from feature/13a-fehlendes-spiel-backend into main
3 changed files with 1087 additions and 0 deletions
Showing only changes of commit 7186cddac5 - Show all commits

View file

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

View file

@ -12,6 +12,12 @@ insert into auth.users (id, email, raw_user_meta_data) values
('22222222-2222-2222-2222-222222222222', 'ben@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Ben"}'),
('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)
-- ---------------------------------------------------------------------------------------------

View file

@ -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;