-- 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, -- Ergebnis nach admin_resolve_report (merged/rejected, rejected nur ohne Ziel), Check game_reports_rejected_no_target, 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(190); 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"}'), ('a1300000-0000-4000-8000-0000000000e5', 'kat04-t1@example.test', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"T1"}'), ('a1300000-0000-4000-8000-0000000000e6', 'kat04-t2@example.test', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"T2"}'); -- --------------------------------------------------------------------------------------------- -- 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.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$$, 'KAT-04: keine der neuen Funktionen ist security definer (Entscheidung Chris 2026-11-04: kein adopt_report_target)'); select is((select count(*) from pg_proc p join pg_namespace n on n.oid = p.pronamespace where n.nspname = 'public' and p.proname = 'adopt_report_target'), 0::bigint, 'KAT-04 Kriterium 5: es gibt keine Funktion adopt_report_target'); select is_empty($$select f.sig from fn f join pg_proc p on p.oid = f.sig::regprocedure where p.proconfig is null or not (p.proconfig @> array['search_path=""'])$$, 'KAT-04: alle neuen Funktionen haben search_path = "" als Funktionseinstellung'); -- --------------------------------------------------------------------------------------------- -- 2. Normalisierung (Krit. 2, Klärung 15) -- --------------------------------------------------------------------------------------------- select is(public.strip_leading_article('die siedler von catan'), 'siedler von catan', 'KAT-04 Kriterium 2: führendes "die" entfällt'); select is(public.strip_leading_article('the crew'), 'crew', 'KAT-04 Kriterium 2: führendes "the" entfällt'); select is(public.strip_leading_article('a game of thrones'), 'game of thrones', 'KAT-04 Kriterium 2: führendes "a" entfällt'); select is(public.strip_leading_article('an apple'), 'apple', 'KAT-04 Kriterium 2: führendes "an" entfällt'); select is(public.strip_leading_article('einem haus'), 'haus', 'KAT-04 Kriterium 2: auch "einem" entfällt'); select is(public.strip_leading_article('das'), 'das', 'KAT-04 Kriterium 2: ein Titel nur aus dem Artikel bleibt unverändert'); select is(public.strip_leading_article('theater der welt'), 'theater der welt', 'KAT-04 Kriterium 2: kein Artikel, wenn das Wort nur damit beginnt'); select is(public.strip_leading_article('herr der ringe'), 'herr der ringe', 'KAT-04 Kriterium 2: Artikel mitten im Titel bleiben'); select is(public.strip_leading_article('der die das'), 'die das', 'KAT-04 Kriterium 2: nur einmal ein Artikel'); select is(public.strip_leading_article(null), null, 'KAT-04: strip_leading_article(null) ist null'); select is(public.normalize_game_title('Die Siedler von Catan'), public.normalize_game_title('siedler von catan'), 'KAT-04 Kriterium 2: "Die Siedler von Catan" und "siedler von catan" ergeben denselben Wert'); select is(public.normalize_game_title('CATAN!'), public.normalize_game_title('Catan'), 'KAT-04 Kriterium 2: Großbuchstaben und Sonderzeichen sind egal'); select is(public.normalize_game_title('The Crew'), public.normalize_game_title('Crew'), 'KAT-04 Kriterium 2: "The Crew" = "Crew"'); select is(public.normalize_game_title('Das'), 'das', 'KAT-04 Kriterium 2: ein Titel nur aus einem Artikel bleibt nicht leer'); select is(public.normalize_game_title('The'), 'the', 'KAT-04 Kriterium 2: auch "The" allein bleibt nicht leer'); select is(public.normalize_game_title('Mölkky'), public.normalize_game_title('Molkky'), 'KAT-04 Kriterium 2: Umlaute werden über search_normalize gefaltet'); select is(public.normalize_game_title('日本語'), '日本語', 'KAT-04 Kriterium 2: nicht-lateinischer Titel fällt auf lower(btrim) zurück'); select is(public.normalize_game_title(' !!! '), '!!!', 'KAT-04 Kriterium 2: Titel nur aus Sonderzeichen fällt auf lower(btrim) zurück'); select is(public.normalize_game_title(null), null, 'KAT-04: normalize_game_title(null) ist null'); select is(public.report_day('2026-01-15 23:30:00+00'), '2026-01-16'::date, 'KAT-04 Kriterium 7: 23:30 UTC im Winter ist schon der nächste Berliner Tag'); select is(public.report_day('2026-01-15 22:59:00+00'), '2026-01-15'::date, 'KAT-04 Kriterium 7: 22:59 UTC im Winter ist noch derselbe Tag'); select is(public.report_day('2026-07-15 21:59:00+00'), '2026-07-15'::date, 'KAT-04 Kriterium 7: 21:59 UTC im Sommer ist noch derselbe Tag'); select is(public.report_day('2026-07-15 22:00:00+00'), '2026-07-16'::date, 'KAT-04 Kriterium 7: 22:00 UTC im Sommer ist der nächste Tag'); -- --------------------------------------------------------------------------------------------- -- 3. Rechte, Policies, Trigger auf game_reports (Spec 3.2) -- --------------------------------------------------------------------------------------------- select is((select array_agg(a.attname::text order by a.attname) from pg_attribute a where a.attrelid = 'public.game_reports'::regclass and a.attnum > 0 and not a.attisdropped and has_column_privilege('authenticated', 'public.game_reports', a.attname, 'insert')), array['link', 'owner_id', 'provisional_game_id', 'publisher_name', 'title', 'year'], 'Sicherheit Kriterium 1: authenticated darf beim Anlegen genau owner_id, title, publisher_name, year, link, provisional_game_id setzen'); select is((select array_agg(a.attname::text order by a.attname) from pg_attribute a where a.attrelid = 'public.game_reports'::regclass and a.attnum > 0 and not a.attisdropped and has_column_privilege('authenticated', 'public.game_reports', a.attname, 'update')), array['result_seen_at'], 'KAT-04 Kriterium 8: authenticated darf genau result_seen_at ändern'); select is_empty($$select a.attname, p from pg_attribute a, unnest(array['select', 'insert', 'update', 'references']) p where a.attrelid = 'public.game_reports'::regclass and a.attnum > 0 and not a.attisdropped and has_column_privilege('anon', 'public.game_reports', a.attname, p)$$, 'Sicherheit Kriterium 2: anon hat auf keine Spalte von game_reports ein Recht'); select is_empty($$select p from unnest(array['delete', 'truncate']) p where has_table_privilege('authenticated', 'public.game_reports', p)$$, 'Sicherheit Kriterium 1: authenticated darf Meldungen nicht löschen'); select is((select array_agg(policyname::text order by policyname) from pg_policies where tablename = 'game_reports' and cmd in ('INSERT', 'UPDATE')), array['game_reports_insert_own', 'game_reports_update_seen'], 'KAT-04: genau eine Insert- und eine Update-Policy'); select ok((select pg_get_triggerdef(t.oid) ~ 'BEFORE INSERT ON public.game_reports FOR EACH ROW WHEN \(\(?new.origin = ''user''::text\)?\)' from pg_trigger t where t.tgname = 'game_reports_daily_limit' and t.tgrelid = 'public.game_reports'::regclass), 'KAT-04 Kriterium 7: der Limit-Trigger läuft vor dem Insert und nur für origin = user'); -- --------------------------------------------------------------------------------------------- -- 4. Katalog-Fixtures (postgres) -- --------------------------------------------------------------------------------------------- insert into public.games (id, title, year, visibility) values ('f13c0000-0000-4000-8000-000000000001', 'Quorblaxwitz Towers', 2020, 'catalog'), ('f13c0000-0000-4000-8000-000000000002', 'Die Zyxqwvland Saga', 2018, 'catalog'), ('f13c0000-0000-4000-8000-000000000003', 'Sortzyxq Reihenfolge', 2000, 'catalog'), ('f13c0000-0000-4000-8000-000000000004', 'Sortzyxq Reihenfolg', 2000, 'catalog'), ('f13c0000-0000-4000-8000-000000000005', 'Sortzyxq Reihenfol', 2000, 'catalog'), ('f13c0000-0000-4000-8000-00000000000d', 'Verstecktzyxq Spiel', 2015, 'catalog'), ('f13c0000-0000-4000-8000-00000000000e', 'Verstecktzyxq Spiel', 2015, 'catalog'), ('f13c0000-0000-4000-8000-00000000000f', 'Verstecktzyxq Spiel', 2015, 'catalog'), ('f13c0000-0000-4000-8000-000000000010', 'Jahrlosxyz Spiel', null, 'catalog'), ('f13c0000-0000-4000-8000-000000000011', '日本語ゲーム', 2010, 'catalog'), ('f13c0000-0000-4000-8000-000000000012', 'Das', 2010, 'catalog'), ('f13c0000-0000-4000-8000-000000000013', 'Adoptzyxq Ziel', 2021, 'catalog'), ('f13c0000-0000-4000-8000-000000000014', 'Adoptzyxq Verstecktes Ziel', 2021, 'catalog'), ('f13c0000-0000-4000-8000-000000000015', 'Adoptzyxq Zusammengefuehrtes Ziel', 2021, 'catalog'), ('f13c0000-0000-4000-8000-000000000017', repeat('x', 200), 2001, 'catalog'); insert into public.games (id, title, year, visibility) select ('f13c0000-0000-4000-8000-' || lpad(to_hex(5 + i), 12, '0'))::uuid, 'Limitzyxq Spiel', 2005, 'catalog' from generate_series(1, 7) i; update public.games set hidden_at = now() where id in ('f13c0000-0000-4000-8000-00000000000e', 'f13c0000-0000-4000-8000-000000000014'); update public.games set hidden_at = now(), merged_into_id = 'f13c0000-0000-4000-8000-00000000000d' where id in ('f13c0000-0000-4000-8000-00000000000f'); update public.games set hidden_at = now(), merged_into_id = 'f13c0000-0000-4000-8000-000000000013' where id = 'f13c0000-0000-4000-8000-000000000015'; insert into public.game_alt_titles (game_id, title, locale) values ('f13c0000-0000-4000-8000-000000000002', 'The Zyxqwvland Chronicle', 'en'); insert into public.publishers (id, name) values ('f13c0000-0000-4000-8000-0000000000a1', 'Zyxq Verlag B'), ('f13c0000-0000-4000-8000-0000000000a2', 'Zyxq Verlag A'); insert into public.game_publishers (game_id, publisher_id) values ('f13c0000-0000-4000-8000-000000000001', 'f13c0000-0000-4000-8000-0000000000a1'), ('f13c0000-0000-4000-8000-000000000001', 'f13c0000-0000-4000-8000-0000000000a2'); insert into public.game_variants (id, game_id, name) values ('f13c0000-0000-4000-8000-0000000000b1', 'f13c0000-0000-4000-8000-000000000013', 'Standard'); -- private Spiele mit dem Titel des sichtbaren Katalogspiels: Bens und Annas eigener vorläufiger Eintrag insert into public.games (id, title, year, visibility, owner_id) values ('f13a0000-0000-4000-8000-000000000001', 'Verstecktzyxq Spiel', 2015, 'private', 'a1300000-0000-4000-8000-0000000000b2'), ('f13a0000-0000-4000-8000-000000000002', 'Verstecktzyxq Spiel', 2015, 'private', 'a1300000-0000-4000-8000-0000000000a1'); -- Schwellen vorab nachrechnen, damit die Trigramm-Fälle wirklich an der Grenze von 0,8 liegen select cmp_ok(extensions.similarity('quorblaxwitz towers', 'quorblaxwitz towe'), '>=', 0.8::real, 'Vorbedingung: "towe" liegt knapp über 0,8'); select cmp_ok(extensions.similarity('quorblaxwitz towers', 'quorblaxwitz tow'), '<', 0.8::real, 'Vorbedingung: "tow" liegt unter 0,8'); select cmp_ok(extensions.similarity('quorblaxwitz towers', 'quorblaxwitz tow'), '>=', 0.7::real, 'Vorbedingung: "tow" liegt über 0,7 (fängt die Schwelle 0,7)'); -- --------------------------------------------------------------------------------------------- -- 5. find_similar_catalog_games (Krit. 2) -- --------------------------------------------------------------------------------------------- select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000b2","role":"authenticated"}', true); set local role authenticated; select results_eq($$select game_id, title, matched_title, year, publishers, match_kind, similarity from public.find_similar_catalog_games('Quorblaxwitz Towers', 2020)$$, $$values ('f13c0000-0000-4000-8000-000000000001'::uuid, 'Quorblaxwitz Towers', 'Quorblaxwitz Towers', 2020::smallint, array['Zyxq Verlag A', 'Zyxq Verlag B'], 'same_title', 1.0::real)$$, 'KAT-04 Kriterium 2: gleicher Titel liefert Treffer mit Haupttitel, Jahr, sortierten Verlagen, same_title und Ähnlichkeit 1'); select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Towers', 2030)), 1::bigint, 'KAT-04 Kriterium 2: gleicher normalisierter Titel gilt unabhängig vom Jahr'); select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Towers', null)), 1::bigint, 'KAT-04 Kriterium 2: gleicher Titel ohne Jahr trifft'); select is((select count(*) from public.find_similar_catalog_games('The QUORBLAXWITZ-TOWERS!', 1999)), 1::bigint, 'KAT-04 Kriterium 2: Artikel, Großbuchstaben und Sonderzeichen werden ignoriert'); select is((select count(*) from public.find_similar_catalog_games(' Quorblaxwitz Towers ', 2020)), 1::bigint, 'KAT-04 Kriterium 2: Leerraum am Rand ändert nichts'); select results_eq($$select match_kind, similarity > 0.8 and similarity < 1 from public.find_similar_catalog_games('Quorblaxwitz Towe', 2021)$$, $$values ('similar_title', true)$$, 'KAT-04 Kriterium 2: Ähnlichkeit >= 0,8 bei Jahr + 1 trifft als similar_title mit echter Ähnlichkeit'); select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Towe', 2019)), 1::bigint, 'KAT-04 Kriterium 2: Ähnlichkeit >= 0,8 bei Jahr - 1 trifft'); select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Towe', 2020)), 1::bigint, 'KAT-04 Kriterium 2: Ähnlichkeit >= 0,8 bei gleichem Jahr trifft'); select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Towe', 2022)), 0::bigint, 'KAT-04 Kriterium 2: Ähnlichkeit >= 0,8 bei Jahr + 2 trifft nicht'); select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Towe', 2018)), 0::bigint, 'KAT-04 Kriterium 2: Ähnlichkeit >= 0,8 bei Jahr - 2 trifft nicht'); select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Towe', null)), 1::bigint, 'KAT-04 Kriterium 2 (Spec-F2): ohne Jahr gilt die Trigramm-Regel ohne Jahresbedingung'); select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Towe', 2147483647)), 1::bigint, 'KAT-04 Kriterium 2: ein Jahr weit außerhalb 1800-2100 gilt wie ein fehlendes Jahr (kein Überlauf)'); select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Towe', -2147483648)), 1::bigint, 'KAT-04 Kriterium 2: ein stark negatives Jahr gilt wie ein fehlendes Jahr (kein 22003)'); select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Towe', 1700)), 1::bigint, 'KAT-04 Kriterium 2: Jahr 1700 gilt wie ein fehlendes Jahr'); select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Tow', 2020)), 0::bigint, 'KAT-04 Kriterium 2: Ähnlichkeit < 0,8 trifft nicht, auch bei gleichem Jahr'); select is((select count(*) from public.find_similar_catalog_games('Quorblaxwitz Tow', null)), 0::bigint, 'KAT-04 Kriterium 2: Ähnlichkeit < 0,8 trifft auch ohne Jahr nicht'); select is((select count(*) from public.find_similar_catalog_games('Jahrlosxyz Spie', 2020)), 1::bigint, 'KAT-04 Kriterium 2 (Spec-F2): fehlt das Jahr am Katalogspiel, gilt die Trigramm-Regel ohne Jahresbedingung'); select is((select count(*) from public.find_similar_catalog_games('Jahrlosxyz Spi', 2020)), 0::bigint, 'KAT-04 Kriterium 2: auch ohne Katalogjahr zählt die Schwelle 0,8'); -- alternative Titel und Haupttitel mit Artikel select results_eq($$select game_id, matched_title, match_kind from public.find_similar_catalog_games('Zyxqwvland Chronicle', 1900)$$, $$values ('f13c0000-0000-4000-8000-000000000002'::uuid, 'The Zyxqwvland Chronicle', 'same_title')$$, 'KAT-04 Kriterium 2: Treffer über title_search des alternativen Titels (matched_title), Artikel am alternativen Titel ignoriert'); select results_eq($$select title, matched_title, match_kind from public.find_similar_catalog_games('zyxqwvland saga', 1900)$$, $$values ('Die Zyxqwvland Saga', 'Die Zyxqwvland Saga', 'same_title')$$, 'KAT-04 Kriterium 2: Treffer über den Haupttitel (games.title_search), Artikel am Haupttitel ignoriert'); select results_eq($$select matched_title, match_kind from public.find_similar_catalog_games('Zyxqwvland Chronicl', 2019)$$, $$values ('The Zyxqwvland Chronicle', 'similar_title')$$, 'KAT-04 Kriterium 2: Trigramm-Treffer über den alternativen Titel (Jahr + 1 zum Spiel)'); select is((select count(*) from public.find_similar_catalog_games('Zyxqwvland Chronicl', 2021)), 0::bigint, 'KAT-04 Kriterium 2: Trigramm-Treffer über den alternativen Titel gilt das Jahr des Spiels (± 2 trifft nicht)'); -- Sonderfälle der Normalisierung select is((select count(*) from public.find_similar_catalog_games('日本語ゲーム', null)), 1::bigint, 'KAT-04 Kriterium 2: Titel ohne normalisierbare Zeichen trifft über den Rückfall lower(btrim)'); select is((select count(*) from public.find_similar_catalog_games('das', null)), 1::bigint, 'KAT-04 Kriterium 2: ein Titel nur aus einem Artikel trifft das gleichnamige Spiel'); -- nicht sichtbare Spiele (hidden, merged, privat von Ben und Anna) treffen nicht; das sichtbare Spiel schon select results_eq($$select game_id from public.find_similar_catalog_games('Verstecktzyxq Spiel', 2015)$$, $$values ('f13c0000-0000-4000-8000-00000000000d'::uuid)$$, 'KAT-04 Kriterium 2: nur das sichtbare Katalogspiel trifft, nicht ausgeblendete, zusammengeführte oder private Spiele (auch nicht Bens und das eigene)'); -- Eingabegrenzen, Reihenfolge, Höchstzahl select is((select count(*) from public.find_similar_catalog_games(repeat('x', 200), null)), 1::bigint, 'KAT-04 Kriterium 2: Titel mit genau 200 Zeichen ist erlaubt und trifft das gleichlange Katalogspiel'); select is((select count(*) from public.find_similar_catalog_games(repeat('x', 201), null)), 0::bigint, 'KAT-04 Kriterium 2: Titel über 200 Zeichen liefert eine leere Menge (obwohl dieselben Trigramme ein Katalogspiel träfen)'); select is((select count(*) from public.find_similar_catalog_games(' ', null)), 0::bigint, 'KAT-04 Kriterium 2: leerer Titel liefert eine leere Menge'); select is((select count(*) from public.find_similar_catalog_games(null, null)), 0::bigint, 'KAT-04 Kriterium 2: Titel null liefert eine leere Menge'); select is((select count(*) from public.find_similar_catalog_games('!!!', null)), 0::bigint, 'KAT-04 Kriterium 2: Titel nur aus Sonderzeichen liefert keinen Treffer'); select is((select count(*) from public.find_similar_catalog_games('Limitzyxq Spiel', 2005)), 5::bigint, 'KAT-04 Kriterium 2: höchstens 5 Treffer'); select results_eq($$select game_id, match_kind from public.find_similar_catalog_games('Sortzyxq Reihenfolge', 2000)$$, $$values ('f13c0000-0000-4000-8000-000000000003'::uuid, 'same_title'), ('f13c0000-0000-4000-8000-000000000004'::uuid, 'similar_title'), ('f13c0000-0000-4000-8000-000000000005'::uuid, 'similar_title')$$, 'KAT-04 Kriterium 2: same_title zuerst, danach nach Ähnlichkeit absteigend'); reset role; -- --------------------------------------------------------------------------------------------- -- 6. report_missing_game (Krit. 1, 2, 3, 7, 8) -- --------------------------------------------------------------------------------------------- -- nicht angemeldet select set_config('request.jwt.claims', '{"role":"authenticated"}', true); set local role authenticated; select throws_ok($$select public.report_missing_game('Zyxqtest Ohne Anmeldung')$$, 'P0001', 'not_authenticated', 'Sicherheit Kriterium 2: ohne Nutzerkennung (auth.uid() null) wird nichts gemeldet'); reset role; select set_config('request.jwt.claims', '{"role":"anon"}', true); set local role anon; select throws_ok($$select public.report_missing_game('Zyxqtest Anon')$$, '42501', null, 'Sicherheit Kriterium 2: anon darf report_missing_game nicht aufrufen'); select throws_ok($$select * from public.find_similar_catalog_games('Zyxqtest Anon')$$, '42501', null, 'Sicherheit Kriterium 2: anon darf find_similar_catalog_games nicht aufrufen'); select throws_ok($$select public.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'); create temp table res_tab as select public.report_missing_game(E'\tZyxqtest Tab\n') as j; grant all on res_tab to public; select is((select title from public.games where id = (select (j->>'provisional_game_id')::uuid from res_tab)), 'Zyxqtest Tab', 'KAT-04 Kriterium 1: Tab und Zeilenumbruch am Rand des Titels werden getrimmt'); -- Erfolg mit Liste create temp table res2 as select public.report_missing_game('Zyxqtest Zwei', 'Zyxq Eigenverlag', null, null, 'wishlist') as j; grant all on res2 to public; select results_eq($$select c.owner_id, c.game_id = (res2.j->>'provisional_game_id')::uuid, c.list, c.variant_id, c.id = (res2.j->>'collection_item_id')::uuid from public.collection_items c, res2 where c.id = (res2.j->>'collection_item_id')::uuid$$, $$values ('a1300000-0000-4000-8000-0000000000a1'::uuid, true, 'wishlist', null::uuid, true)$$, 'KAT-04 Kriterium 3: p_list legt den Sammlungseintrag auf den vorläufigen Eintrag an, ohne Edition'); select is((select publisher_name from public.game_reports where id = (select (j->>'report_id')::uuid from res2)), 'Zyxq Eigenverlag', 'KAT-04 Kriterium 1: der Verlag wird als Freitext gespeichert'); create temp table res_own as select public.report_missing_game('Zyxqtest Besitz', null, null, null, 'owned') as j; grant all on res_own to public; select is((select list from public.collection_items where id = (select (j->>'collection_item_id')::uuid from res_own)), 'owned', 'KAT-04 Kriterium 3: Liste owned'); select lives_ok($$select public.report_missing_game('Zyxqtest Einkauf', null, null, null, 'shopping')$$, 'KAT-04 Kriterium 3: Liste shopping'); reset role; -- Isolation: Ben sieht nichts von Anna select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000b2","role":"authenticated"}', true); set local role authenticated; select is((select count(*) from public.game_reports where title like 'Zyxqtest%'), 0::bigint, 'Sicherheit Kriterium 1: Ben sieht Annas Meldungen nicht'); select is((select count(*) from public.games where title like 'Zyxqtest%'), 0::bigint, 'Sicherheit Kriterium 3: Ben sieht Annas vorläufige Einträge nicht'); select is((select count(*) from public.collection_items where game_id in (select (j->>'provisional_game_id')::uuid from res1 union all select (j->>'provisional_game_id')::uuid from res2)), 0::bigint, 'Sicherheit Kriterium 3: Ben sieht Annas Listeneinträge nicht'); reset role; -- Ähnlichkeitsprüfung beim Speichern (Krit. 2) select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000c3","role":"authenticated"}', true); set local role authenticated; select throws_ok($$select public.report_missing_game('Die Quorblaxwitz Towers', null, 2020)$$, 'P0001', 'report_similar_exists', 'KAT-04 Kriterium 2: ein sehr ähnlicher Katalogeintrag ohne Bestätigung wird nicht gespeichert'); select throws_ok($$select public.report_missing_game('Quorblaxwitz Towers', null, 2020, null, null, null)$$, 'P0001', 'report_similar_exists', 'KAT-04 Kriterium 2: p_confirmed_distinct = null gilt als nicht bestätigt'); select is((select count(*) from public.game_reports) + (select count(*) from public.games where owner_id = 'a1300000-0000-4000-8000-0000000000c3'), 0::bigint, 'KAT-04 Kriterium 2: ohne Bestätigung bleibt nichts zurück'); select throws_ok($$select public.report_missing_game('Quorblaxwitz Towe', null, 2021)$$, 'P0001', 'report_similar_exists', 'KAT-04 Kriterium 2: die RPC prüft die Trigramm-Regel mit dem gemeldeten Jahr (± 1 trifft)'); select lives_ok($$select public.report_missing_game('Quorblaxwitz Towe', null, 2022)$$, 'KAT-04 Kriterium 2: die RPC prüft die Trigramm-Regel mit dem gemeldeten Jahr (± 2 trifft nicht, ohne Bestätigung gespeichert)'); select lives_ok($$select public.report_missing_game('Die Quorblaxwitz Towers', null, 2020, null, null, true)$$, 'KAT-04 Kriterium 2: mit ausdrücklicher Bestätigung wird gespeichert'); select is((select count(*) from public.game_reports where title in ('Die Quorblaxwitz Towers', 'Quorblaxwitz Towe')), 2::bigint, 'KAT-04 Kriterium 2: die bestätigte und die nicht ähnliche Meldung sind gespeichert'); reset role; -- 23505 am eindeutigen Index auf provisional_game_id: über die RPC nicht erreichbar (jeder Aufruf legt ein frisches Spiel an). -- Ein Hilfstrigger lenkt den Insert in game_reports auf ein schon verwendetes Spiel um. create function pg_temp.reuse_prov() returns trigger language plpgsql as $$ begin new.provisional_game_id := (select r.provisional_game_id from public.game_reports r where r.title = 'Die Quorblaxwitz Towers' and r.owner_id = new.owner_id); return new; end $$; create trigger zz_reuse_prov before insert on public.game_reports for each row execute function pg_temp.reuse_prov(); select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000c3","role":"authenticated"}', true); set local role authenticated; select throws_ok($$select public.report_missing_game('Zyxqtest Doppelt belegt')$$, 'P0001', 'report_provisional_in_use', 'KAT-04 Kriterium 3: ein schon verwendeter vorläufiger Eintrag wird als report_provisional_in_use gemeldet'); select is((select count(*) from public.games where title = 'Zyxqtest Doppelt belegt'), 0::bigint, 'KAT-04 Kriterium 3: der Konflikt lässt kein vorläufiges Spiel zurück'); reset role; drop trigger zz_reuse_prov on public.game_reports; -- Rollback bei Fehler im Listeneintrag: ein Hilfstrigger lässt den Insert in collection_items scheitern create function pg_temp.fail_ci() returns trigger language plpgsql as $$ begin raise exception 'boom' using errcode = 'P0001'; end $$; create trigger fail_ci before insert on public.collection_items for each row execute function pg_temp.fail_ci(); select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000c3","role":"authenticated"}', true); set local role authenticated; select throws_ok($$select public.report_missing_game('Zyxqtest Rollback', null, null, null, 'owned')$$, 'P0001', 'boom', 'Vorbedingung: der Listeneintrag scheitert'); select is((select count(*) from public.game_reports where title = 'Zyxqtest Rollback') + (select count(*) from public.games where title = 'Zyxqtest Rollback'), 0::bigint, 'KAT-04 Kriterium 3: scheitert der Listeneintrag, bleiben weder Meldung noch vorläufiges Spiel zurück'); reset role; drop trigger fail_ci on public.collection_items; -- --------------------------------------------------------------------------------------------- -- 7. Tageslimit (Krit. 7) -- --------------------------------------------------------------------------------------------- select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000e1","role":"authenticated"}', true); set local role authenticated; select lives_ok($$select public.report_missing_game('Tageslimit Meldung ' || i) from generate_series(1, 10) i$$, 'KAT-04 Kriterium 7: 10 Meldungen über report_missing_game sind erlaubt'); select throws_ok($$select public.report_missing_game('Tageslimit Meldung 11')$$, 'P0001', 'report_daily_limit', 'KAT-04 Kriterium 7: die 11. Meldung scheitert am Tageslimit'); select throws_ok($$select public.report_missing_game('Quorblaxwitz Towers', null, 2020)$$, 'P0001', 'report_daily_limit', 'KAT-04 Kriterium 7: das Limit wird vor der Ähnlichkeitsprüfung gemeldet'); select is((select count(*) from public.game_reports), 10::bigint, 'KAT-04 Kriterium 7: es bleiben genau 10 Meldungen'); select is((select count(*) from public.games where owner_id = 'a1300000-0000-4000-8000-0000000000e1'), 10::bigint, 'KAT-04 Kriterium 7: kein vorläufiges Spiel der abgelehnten Meldung bleibt übrig'); reset role; -- Meldungen von gestern zählen nicht insert into public.games (id, title, visibility, owner_id) select ('f13b0000-0000-4000-8000-' || lpad(to_hex(i), 12, '0'))::uuid, 'Gestern ' || i, 'private', 'a1300000-0000-4000-8000-0000000000e4' from generate_series(1, 13) i; insert into public.game_reports (owner_id, title, provisional_game_id, created_at) select 'a1300000-0000-4000-8000-0000000000e4', 'Gestern ' || i, ('f13b0000-0000-4000-8000-' || lpad(to_hex(i), 12, '0'))::uuid, -- created_at ist nach dem Anlegen unveränderlich: feste Zeitstempel gleich beim Einfügen (Nr. 1 und 2 an der Berliner Tagesgrenze) case i when 1 then '2026-01-15 22:59:00+00'::timestamptz when 2 then '2026-01-15 23:30:00+00'::timestamptz else '2026-01-01 12:00:00+00'::timestamptz end from generate_series(1, 10) i; select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000e4","role":"authenticated"}', true); set local role authenticated; select lives_ok($$select public.report_missing_game('Heute nach zehn Meldungen von gestern')$$, 'KAT-04 Kriterium 7: Meldungen von gestern zählen nicht'); reset role; -- Grenze des Berliner Tages mit festem Zeitstempel: 23:30 UTC im Winter (00:30 am Folgetag in Berlin) zählt nicht zu 22:59 UTC desselben Tages select is((select count(*) from public.game_reports where owner_id = 'a1300000-0000-4000-8000-0000000000e4' and public.report_day(created_at) = '2026-01-15'), 1::bigint, 'KAT-04 Kriterium 7: der Berliner Kalendertag trennt 22:59 und 23:30 UTC im Winter'); -- Tagesgrenze: Meldungen kurz nach Mitternacht Berlin zählen als heute (auch wenn das UTC-Datum ein anderes ist). -- Uhrzeitabhängig: Läuft der Test zwischen Mitternacht Berlin und Mitternacht UTC, hat created_at dasselbe UTC-Datum wie now(); -- die Tests bestehen dann trotzdem, erkennen aber die Mutante created_at::date = now()::date nicht. insert into public.games (id, title, visibility, owner_id) select ('f13b0000-0000-4000-8000-' || lpad(to_hex(200 + i), 12, '0'))::uuid, 'Mitternacht ' || i, 'private', case when i <= 10 then 'a1300000-0000-4000-8000-0000000000e5'::uuid else 'a1300000-0000-4000-8000-0000000000e6'::uuid end from generate_series(1, 22) i; insert into public.game_reports (owner_id, title, provisional_game_id, created_at) select case when i <= 10 then 'a1300000-0000-4000-8000-0000000000e5'::uuid else 'a1300000-0000-4000-8000-0000000000e6'::uuid end, 'Mitternacht ' || i, ('f13b0000-0000-4000-8000-' || lpad(to_hex(200 + i), 12, '0'))::uuid, (date_trunc('day', now() at time zone 'Europe/Berlin') at time zone 'Europe/Berlin') + interval '1 minute' from generate_series(1, 20) i; select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000e5","role":"authenticated"}', true); set local role authenticated; select throws_ok($$select public.report_missing_game('Mitternacht Vorprüfung')$$, 'P0001', 'report_daily_limit', 'KAT-04 Kriterium 7: 10 Meldungen kurz nach Mitternacht Berlin zählen für heute (Vorprüfung der RPC)'); select throws_ok($$select public.report_missing_game('Quorblaxwitz Towers', null, 2020)$$, 'P0001', 'report_daily_limit', 'KAT-04 Kriterium 7: die Vorprüfung zählt Meldungen nach Mitternacht Berlin auch vor der Ähnlichkeitsprüfung'); reset role; select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000e6","role":"authenticated"}', true); set local role authenticated; select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000e6', 'Mitternacht 21', 'f13b0000-0000-4000-8000-0000000000dd')$$, 'P0001', 'report_daily_limit', 'KAT-04 Kriterium 7: 10 Meldungen kurz nach Mitternacht Berlin zählen für heute (Trigger)'); reset role; -- Direkter Insert als authenticated: Trigger zählt, report_day ist für authenticated ausführbar insert into public.games (id, title, visibility, owner_id) select ('f13b0000-0000-4000-8000-' || lpad(to_hex(100 + i), 12, '0'))::uuid, 'Direkt ' || i, 'private', 'a1300000-0000-4000-8000-0000000000e2' from generate_series(1, 11) i; select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000e2","role":"authenticated"}', true); set local role authenticated; select lives_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) select 'a1300000-0000-4000-8000-0000000000e2', 'Direkt ' || i, ('f13b0000-0000-4000-8000-' || lpad(to_hex(100 + i), 12, '0'))::uuid from generate_series(1, 10) i$$, 'KAT-04 Kriterium 7: 10 direkte Inserts als authenticated sind erlaubt (der Trigger darf report_day ausführen)'); select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000e2', 'Direkt 11', 'f13b0000-0000-4000-8000-00000000006f')$$, 'P0001', 'report_daily_limit', 'KAT-04 Kriterium 7: auch der direkte 11. Insert scheitert am Limit'); reset role; -- Import-Meldungen zählen nicht und werden nicht begrenzt (N12) insert into public.game_reports (owner_id, origin, title, bgg_id) select 'a1300000-0000-4000-8000-0000000000e3', 'import', 'Import ' || i, 9000 + i from generate_series(1, 12) i; select is((select count(*) from public.game_reports where owner_id = 'a1300000-0000-4000-8000-0000000000e3' and origin = 'import'), 12::bigint, 'KAT-04 Kriterium 7 (N12): mehr als 10 Import-Meldungen werden nicht begrenzt'); select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000e3","role":"authenticated"}', true); set local role authenticated; select lives_ok($$select public.report_missing_game('Zyxqtest nach Import ' || i) from generate_series(1, 10) i$$, 'KAT-04 Kriterium 7 (N12): Import-Meldungen zählen nicht auf das Limit des Nutzers'); select throws_ok($$select public.report_missing_game('Zyxqtest nach Import 11')$$, 'P0001', 'report_daily_limit', 'KAT-04 Kriterium 7: danach greift das Limit für Nutzer-Meldungen'); reset role; -- Meldungen von Nutzer B zählen nicht für Nutzer A: Anna hat oben nur wenige, Ben (e1 hat 10) ändert nichts select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000a1","role":"authenticated"}', true); set local role authenticated; select lives_ok($$select public.report_missing_game('Zyxqtest Anna nach Limit anderer')$$, 'KAT-04 Kriterium 7: die Meldungen anderer Nutzer zählen nicht für Anna'); reset role; -- --------------------------------------------------------------------------------------------- -- 8. Direkter Zugriff auf game_reports: Policies und Spaltenrechte (Krit. 8, Sicherheit 1 und 3) -- --------------------------------------------------------------------------------------------- insert into public.games (id, title, visibility, owner_id, hidden_at, merged_into_id) values ('f13a0000-0000-4000-8000-000000000011', 'Direkt ok', 'private', 'a1300000-0000-4000-8000-0000000000c3', null, null), ('f13a0000-0000-4000-8000-000000000012', 'Direkt versteckt', 'private', 'a1300000-0000-4000-8000-0000000000c3', now(), null), ('f13a0000-0000-4000-8000-000000000013', 'Direkt zusammengeführt', 'private', 'a1300000-0000-4000-8000-0000000000c3', now(), 'f13c0000-0000-4000-8000-00000000000d'), ('f13a0000-0000-4000-8000-000000000014', 'Bens Privatspiel', 'private', 'a1300000-0000-4000-8000-0000000000b2', null, null), ('f13a0000-0000-4000-8000-000000000015', 'Direkt ok 2', 'private', 'a1300000-0000-4000-8000-0000000000c3', null, null); -- Hilfsfunktion (läuft mit den Rechten des Aufrufers): result_seen_at setzen und die Zahl der geänderten Zeilen liefern. -- (Ein datenändernder WITH-Block ist in einer Unterabfrage nicht erlaubt.) create function pg_temp.mark_seen_all(p_at timestamptz) returns bigint language plpgsql as $$ declare n bigint; begin update public.game_reports set result_seen_at = p_at; -- bewusst ohne WHERE: nur die Update-Policy begrenzt die Zeilen get diagnostics n = row_count; return n; end $$; create function pg_temp.mark_seen(p_id uuid, p_at timestamptz) returns bigint language plpgsql as $$ declare n bigint; begin update public.game_reports set result_seen_at = p_at where p_id is null or id = p_id; get diagnostics n = row_count; return n; end $$; select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000c3","role":"authenticated"}', true); set local role authenticated; select lives_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000c3', 'Direkt ok', 'f13a0000-0000-4000-8000-000000000011')$$, 'Sicherheit Kriterium 1: Cara legt direkt eine Meldung zu ihrem eigenen privaten Spiel an (Positivfall)'); select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000b2', 'Im Namen von Ben', 'f13a0000-0000-4000-8000-000000000014')$$, '42501', null, 'Sicherheit Kriterium 1: Cara kann keine Meldung für Ben anlegen'); select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000b2', 'Fremder Besitzer, eigenes Spiel', 'f13a0000-0000-4000-8000-000000000015')$$, '42501', null, 'Sicherheit Kriterium 1: owner_id eines anderen Nutzers wird abgelehnt, auch wenn das vorläufige Spiel Cara gehört'); select throws_ok($$insert into public.game_reports (owner_id, title) values ('a1300000-0000-4000-8000-0000000000c3', 'Ohne vorläufigen Eintrag')$$, '42501', null, 'Sicherheit Kriterium 1: eine Meldung ohne vorläufigen Eintrag wird abgelehnt'); select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000c3', 'Bens Spiel', 'f13a0000-0000-4000-8000-000000000014')$$, '42501', null, 'Sicherheit Kriterium 1: ein fremdes privates Spiel als vorläufiger Eintrag wird abgelehnt'); select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000c3', 'Katalogspiel', 'f13c0000-0000-4000-8000-000000000001')$$, '42501', null, 'Sicherheit Kriterium 1: ein Katalogspiel als vorläufiger Eintrag wird abgelehnt'); select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000c3', 'Versteckt', 'f13a0000-0000-4000-8000-000000000012')$$, '42501', null, 'Sicherheit Kriterium 1: ein ausgeblendetes Spiel als vorläufiger Eintrag wird abgelehnt'); select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000c3', 'Zusammengeführt', 'f13a0000-0000-4000-8000-000000000013')$$, '42501', null, 'Sicherheit Kriterium 1: ein zusammengeführtes Spiel als vorläufiger Eintrag wird abgelehnt'); select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000c3', 'Doppelt', 'f13a0000-0000-4000-8000-000000000011')$$, '23505', null, 'Sicherheit Kriterium 1: eine zweite Meldung zum selben vorläufigen Eintrag scheitert am eindeutigen Index'); select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id, status) values ('a1300000-0000-4000-8000-0000000000c3', 'S', 'f13a0000-0000-4000-8000-000000000015', 'approved')$$, '42501', null, 'KAT-04 Kriterium 8: status lässt sich beim Anlegen nicht setzen'); select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id, origin) values ('a1300000-0000-4000-8000-0000000000c3', 'S', 'f13a0000-0000-4000-8000-000000000015', 'import')$$, '42501', null, 'KAT-04 Kriterium 7: origin lässt sich beim Anlegen nicht setzen (Import-Meldungen umgehen das Limit nicht)'); select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id, created_at) values ('a1300000-0000-4000-8000-0000000000c3', 'S', 'f13a0000-0000-4000-8000-000000000015', '2026-01-01')$$, '42501', null, 'KAT-04 Kriterium 7: created_at lässt sich beim Anlegen nicht setzen'); select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id, target_game_id) values ('a1300000-0000-4000-8000-0000000000c3', 'S', 'f13a0000-0000-4000-8000-000000000015', 'f13c0000-0000-4000-8000-000000000001')$$, '42501', null, 'KAT-04 Kriterium 8: target_game_id lässt sich beim Anlegen nicht setzen'); select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id, resolved_at) values ('a1300000-0000-4000-8000-0000000000c3', 'S', 'f13a0000-0000-4000-8000-000000000015', now())$$, '42501', null, 'KAT-04 Kriterium 8: resolved_at lässt sich beim Anlegen nicht setzen'); select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id, bgg_id) values ('a1300000-0000-4000-8000-0000000000c3', 'S', 'f13a0000-0000-4000-8000-000000000015', 1)$$, '42501', null, 'KAT-04: bgg_id lässt sich beim Anlegen nicht setzen'); select throws_ok($$insert into public.game_reports (id, owner_id, title, provisional_game_id) values (gen_random_uuid(), 'a1300000-0000-4000-8000-0000000000c3', 'S', 'f13a0000-0000-4000-8000-000000000015')$$, '42501', null, 'KAT-04: id lässt sich beim Anlegen nicht setzen'); select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000c3', '', 'f13a0000-0000-4000-8000-000000000015')$$, '23514', null, 'KAT-04 Kriterium 1: ein leerer Titel scheitert am Tabellen-Check'); select throws_ok($$insert into public.game_reports (owner_id, title, year, provisional_game_id) values ('a1300000-0000-4000-8000-0000000000c3', 'S', 1700, 'f13a0000-0000-4000-8000-000000000015')$$, '23514', null, 'KAT-04 Kriterium 1: ein Jahr außerhalb 1800-2100 scheitert am Tabellen-Check'); select throws_ok($$update public.game_reports set status = 'approved' where owner_id = 'a1300000-0000-4000-8000-0000000000c3'$$, '42501', null, 'KAT-04 Kriterium 8: status lässt sich nicht ändern'); select throws_ok($$update public.game_reports set title = 'Umbenannt' where owner_id = 'a1300000-0000-4000-8000-0000000000c3'$$, '42501', null, 'KAT-04: title lässt sich nicht ändern'); select throws_ok($$update public.game_reports set target_game_id = 'f13c0000-0000-4000-8000-000000000001' where owner_id = 'a1300000-0000-4000-8000-0000000000c3'$$, '42501', null, 'KAT-04 Kriterium 8: target_game_id lässt sich nicht ändern'); select throws_ok($$update public.game_reports set resolved_at = now() where owner_id = 'a1300000-0000-4000-8000-0000000000c3'$$, '42501', null, 'KAT-04 Kriterium 8: resolved_at lässt sich nicht ändern'); select throws_ok($$delete from public.game_reports where owner_id = 'a1300000-0000-4000-8000-0000000000c3'$$, '42501', null, 'Sicherheit Kriterium 1: eigene Meldungen lassen sich nicht löschen'); -- result_seen_at bei offener Meldung: 0 Zeilen select is(pg_temp.mark_seen(null, now()), 0::bigint, 'KAT-04 Kriterium 8: result_seen_at an einer offenen Meldung ändert keine Zeile'); reset role; select throws_ok($$update public.game_reports set result_seen_at = now() where owner_id = 'a1300000-0000-4000-8000-0000000000c3'$$, '23514', null, 'KAT-04 Kriterium 8: auch postgres kann result_seen_at bei einer offenen Meldung nicht setzen (Check)'); -- --------------------------------------------------------------------------------------------- -- 9. Ergebnis nach admin_resolve_report (Krit. 4 und 6), result_seen_at -- --------------------------------------------------------------------------------------------- select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000a1","role":"authenticated"}', true); set local role authenticated; create temp table res_ap as select public.report_missing_game('Freigabezyxq Neu', 'Freigabe Verlag', 2023, null, 'owned') as j; grant all on res_ap to public; create temp table res_rej as select public.report_missing_game('Ablehnzyxq Spiel', null, null, null, 'wishlist') as j; grant all on res_rej to public; update public.collection_items set note = 'Annas Notiz', acquired_on = '2025-05-05' where id = (select (j->>'collection_item_id')::uuid from res_ap); reset role; insert into public.game_variants (id, game_id, name) values ('f13a0000-0000-4000-8000-0000000000b1', (select (j->>'provisional_game_id')::uuid from res_ap), 'Eigene Edition'); update public.collection_items set variant_id = 'f13a0000-0000-4000-8000-0000000000b1' where id = (select (j->>'collection_item_id')::uuid from res_ap); insert into public.games (id, title, year, visibility) values ('f13c0000-0000-4000-8000-000000000016', 'Freigabezyxq Neu', 2023, 'catalog'); select lives_ok($$select public.admin_resolve_report((select (j->>'report_id')::uuid from res_ap), 'approved', 'f13c0000-0000-4000-8000-000000000016')$$, 'Vorbedingung: der Admin gibt die Meldung frei'); select lives_ok($$select public.admin_resolve_report((select (j->>'report_id')::uuid from res_rej), 'rejected')$$, 'Vorbedingung: der Admin lehnt die zweite Meldung ohne Ziel ab'); select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000a1","role":"authenticated"}', true); set local role authenticated; select results_eq($$select game_id, list, note, acquired_on, variant_id from public.collection_items where id = (select (j->>'collection_item_id')::uuid from res_ap)$$, $$values ('f13c0000-0000-4000-8000-000000000016'::uuid, 'owned', 'Annas Notiz', '2025-05-05'::date, null::uuid)$$, 'KAT-04 Kriterium 4: der Katalogeintrag ersetzt den vorläufigen; Liste, Notiz und Kaufdatum bleiben, die Edition ohne gleichnamige Variante ist leer'); select results_eq($$select r.status, r.target_game_id, r.editions_cleared, r.result_seen_at from public.game_reports r where r.id = (select (j->>'report_id')::uuid from res_ap)$$, $$values ('approved', 'f13c0000-0000-4000-8000-000000000016'::uuid, 1, null::timestamptz)$$, 'KAT-04 Kriterium 4: Meldung freigegeben, editions_cleared zählt die geleerte Edition, Hinweis noch ungesehen'); select is((select count(*) from public.game_variants where id = 'f13a0000-0000-4000-8000-0000000000b1'), 1::bigint, 'KAT-04 Kriterium 4: Annas private Variante bleibt für sie lesbar'); select results_eq($$select g.visibility, g.merged_into_id, g.hidden_at is not null from public.games g where g.id = (select (j->>'provisional_game_id')::uuid from res_ap)$$, $$values ('private', 'f13c0000-0000-4000-8000-000000000016'::uuid, true)$$, 'KAT-04 Kriterium 4: der vorläufige Eintrag ist ausgeblendet und zeigt auf den Katalogeintrag, Anna liest ihn weiter'); select is(pg_temp.mark_seen((select (j->>'report_id')::uuid from res_ap), '2026-11-04 10:00:00+00'), 1::bigint, 'KAT-04 Kriterium 4: Anna kann den Hinweis an der freigegebenen Meldung wegklicken (result_seen_at)'); select is((select result_seen_at from public.game_reports where id = (select (j->>'report_id')::uuid from res_ap)), '2026-11-04 10:00:00+00'::timestamptz, 'KAT-04 Kriterium 4: result_seen_at ist gesetzt'); -- Krit. 6 select results_eq($$select r.status, r.target_game_id, r.provisional_game_id is not null from public.game_reports r where r.id = (select (j->>'report_id')::uuid from res_rej)$$, $$values ('rejected', null::uuid, true)$$, 'KAT-04 Kriterium 6: abgelehnt ohne Ziel, der vorläufige Eintrag ist noch verknüpft'); select results_eq($$select g.visibility, g.merged_into_id, g.hidden_at from public.games g where g.id = (select (j->>'provisional_game_id')::uuid from res_rej)$$, $$values ('private', null::uuid, null::timestamptz)$$, 'KAT-04 Kriterium 6: der vorläufige Eintrag bleibt privat, sichtbar und lesbar'); select is((select count(*) from public.collection_items where id = (select (j->>'collection_item_id')::uuid from res_rej) and game_id = (select (j->>'provisional_game_id')::uuid from res_rej)), 1::bigint, 'KAT-04 Kriterium 6: der Listeneintrag bleibt am vorläufigen Eintrag'); select is(pg_temp.mark_seen((select (j->>'report_id')::uuid from res_rej), now()), 1::bigint, 'KAT-04 Kriterium 6: auch an der abgelehnten Meldung lässt sich der Hinweis wegklicken'); reset role; -- Fremder Nutzer kann nichts wegklicken select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000b2","role":"authenticated"}', true); set local role authenticated; select is(pg_temp.mark_seen((select (j->>'report_id')::uuid from res_ap), now()), 0::bigint, 'Sicherheit Kriterium 1: Ben kann bei fremden Meldungen nichts wegklicken (0 Zeilen)'); select is(pg_temp.mark_seen_all(now()), 0::bigint, 'Sicherheit Kriterium 1: auch ein Update ohne WHERE trifft bei Ben keine fremde Meldung (Update-Policy)'); reset role; -- --------------------------------------------------------------------------------------------- -- 10. Verweis auf vorhandenen Eintrag = merged, rejected nur ohne Ziel (Krit. 5; Entscheidung Chris 2026-11-04) -- --------------------------------------------------------------------------------------------- select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000a1","role":"authenticated"}', true); set local role authenticated; create temp table res_mg as select public.report_missing_game('Verweiszyxq Spiel', null, null, null, 'wishlist') as j; grant all on res_mg to public; reset role; select throws_ok($$select public.admin_resolve_report((select (j->>'report_id')::uuid from res_mg), 'rejected', 'f13c0000-0000-4000-8000-000000000013')$$, 'P0001', 'report_reject_has_target', 'KAT-04 Kriterium 5: rejected mit Ziel wird abgewiesen'); select is((select status from public.game_reports where id = (select (j->>'report_id')::uuid from res_mg)), 'open', 'KAT-04 Kriterium 5: die abgewiesene Meldung bleibt offen'); select throws_ok($$update public.game_reports set status = 'rejected', resolved_at = now(), target_game_id = 'f13c0000-0000-4000-8000-000000000013' where id = (select (j->>'report_id')::uuid from res_mg)$$, '23514', 'new row for relation "game_reports" violates check constraint "game_reports_rejected_no_target"', 'KAT-04 Kriterium 5: auch die Tabelle erlaubt rejected mit Ziel nicht (Check game_reports_rejected_no_target)'); select lives_ok($$select public.admin_resolve_report((select (j->>'report_id')::uuid from res_mg), 'merged', 'f13c0000-0000-4000-8000-000000000013')$$, 'KAT-04 Kriterium 5: mit Verweis auf einen vorhandenen Eintrag wird die Meldung mit merged bearbeitet'); select set_config('request.jwt.claims', '{"sub":"a1300000-0000-4000-8000-0000000000a1","role":"authenticated"}', true); set local role authenticated; select results_eq($$select c.game_id, c.list from public.collection_items c where c.id = (select (j->>'collection_item_id')::uuid from res_mg)$$, $$values ('f13c0000-0000-4000-8000-000000000013'::uuid, 'wishlist')$$, 'KAT-04 Kriterium 5: der Listeneintrag zeigt direkt auf den vorhandenen Eintrag, ohne Aktion des Nutzers'); select results_eq($$select 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_mg)$$, $$values ('f13c0000-0000-4000-8000-000000000013'::uuid, true)$$, 'KAT-04 Kriterium 5: der vorläufige Eintrag verweist auf den richtigen Eintrag und bleibt für Anna lesbar'); select is(pg_temp.mark_seen((select (j->>'report_id')::uuid from res_mg), now()), 1::bigint, 'KAT-04 Kriterium 5: Anna kann den Hinweis zur zusammengeführten Meldung wegklicken'); reset role; select throws_ok($$insert into public.game_reports (owner_id, title, status, resolved_at, target_game_id) values ('a1300000-0000-4000-8000-0000000000a1', 'X', 'rejected', now(), 'f13c0000-0000-4000-8000-000000000013')$$, '23514', 'new row for relation "game_reports" violates check constraint "game_reports_rejected_no_target"', 'KAT-04 Kriterium 5: Check game_reports_rejected_no_target greift auch beim Anlegen als postgres'); select lives_ok($$insert into public.game_reports (owner_id, title, status, resolved_at, created_at) values ('a1300000-0000-4000-8000-0000000000a1', 'Abgelehnt ohne Ziel', 'rejected', now(), '2026-02-01')$$, 'KAT-04 Kriterium 6: rejected ohne Ziel ist zulässig'); -- --------------------------------------------------------------------------------------------- -- 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;