/** * KAT-01 Spiele suchen (Issue #3, Spec docs/specs/3-katalog-suche.md § 4.2/§ 7), Projekt `api`: * die RPC `search_catalog_games` als `anon` ueber Kong (`POST /rest/v1/rpc/...`), wie die App sie * aufruft, inklusive Fehlerfaellen. * * Testdaten: eigenes Datenset mit Kunstwort "wlpd" (e2e/support/catalog.ts), Anlage im `beforeAll`, * Aufraeumen im `afterAll`; die Datei laeuft seriell. Die Gegenstuecke in der Datenbank * (supabase/tests/database/09_catalog_search.test.sql) pruefen dieselben Regeln ohne HTTP; hier * zaehlt, dass die Regeln ueber Gateway, PostgREST und Rollenrechte ankommen. * Drosselung: Kong hat in der lokalen Konfiguration kein Ratenlimit fuer die RPC (Spec 4.2 * Sicherheit, Entscheidung D1 vor dem Go-live); die Tests rufen nur wenige Dutzend Mal auf. */ import { expect, test } from '@playwright/test'; import { env } from '../../support/auth-config'; import { catalogDataset, cleanupCatalog, MASS_COUNT, seedCatalog } from '../../support/catalog'; import { withDb } from '../../support/db'; import { GoTrueClient, randomEmail, randomPassword } from '../../support/gotrue'; const ds = catalogDataset({ prefix: 'e2ec0002', word: 'wlpd' }); const { ids, titles: t } = ds; /** Privates Spiel eines Nutzers (darf nie in der Katalogsuche erscheinen). */ const privateGameId = `${ds.prefix}-0000-4000-8000-0000000000f1`; const privateTitle = `${ds.W} Privatspiel`; let ownerId = ''; let ownerToken = ''; test.describe.configure({ mode: 'serial' }); test.beforeAll(async () => { await seedCatalog(ds); const email = randomEmail('kat01'); const password = randomPassword(); const gotrue = new GoTrueClient(); const created = await gotrue.createConfirmedUser(email, password); expect(created.status).toBe(200); ownerId = created.body.id; const login = await gotrue.signIn(email, password); expect(login.status).toBe(200); ownerToken = login.body.access_token; await withDb(async (c) => { await c.query(`insert into public.games (id, title, visibility, owner_id) values ($1, $2, 'private', $3)`, [ privateGameId, privateTitle, ownerId, ]); await c.query(`insert into public.game_alt_titles (game_id, locale, title) values ($1, 'de', $2)`, [ privateGameId, `${ds.W} Geheimname`, ]); }); }); test.afterAll(async () => { await withDb(async (c) => { await cleanupCatalog(c, ds); if (ownerId) await c.query('delete from auth.users where id = $1', [ownerId]); }); }); interface Row { id: string; title: string; year: number | null; min_players: number | null; max_players: number | null; min_minutes: number | null; max_minutes: number | null; min_age: number | null; image_path: string | null; publisher_names: string[]; matched_alt_title: string | null; match_rank: number | null; total_count: number; } interface Res { status: number; body: T; } /** POST /rest/v1/rpc/search_catalog_games, Standard: Rolle anon (apikey = Anon-Key). */ async function rpc( params: Record, auth: { apikey?: string | null; token?: string } = {}, ): Promise> { const apikey = auth.apikey === undefined ? env.anonKey : auth.apikey; const headers: Record = { 'content-type': 'application/json' }; if (apikey) headers.apikey = apikey; const bearer = auth.token ?? apikey; if (bearer) headers.authorization = `Bearer ${bearer}`; const res = await fetch(`${env.supabaseUrl}/rest/v1/rpc/search_catalog_games`, { method: 'POST', headers, body: JSON.stringify(params), }); const text = await res.text(); return { status: res.status, body: text ? JSON.parse(text) : null }; } async function search(params: Record, auth?: Parameters[1]): Promise { const res = await rpc(params, auth); expect(res.status, JSON.stringify(res.body)).toBe(200); return res.body as Row[]; } const titlesOf = (rows: Row[]) => rows.map((r) => r.title); const idSet = (rows: Row[]) => rows.map((r) => r.id).sort(); test.describe('KAT-01 Kriterium 1: Titelsuche als anon (api)', () => { test('KAT-01 Sicherheit Kriterium 2: anon ruft die RPC ohne Anmeldung auf, die Zeilen haben den Vertrag aus Spec 4.2', async () => { const rows = await search({ p_query: 'wlpd duo' }); expect(rows).toHaveLength(1); expect(Object.keys(rows[0]).sort()).toEqual( [ 'id', 'image_path', 'matched_alt_title', 'match_rank', 'max_minutes', 'max_players', 'min_age', 'min_minutes', 'min_players', 'publisher_names', 'title', 'total_count', 'year', ].sort(), ); expect(rows[0]).toMatchObject({ id: ids.duo, title: t.duo, year: 2015, min_players: 2, max_players: 2, min_minutes: 30, max_minutes: 30, min_age: 8, image_path: null, publisher_names: [], matched_alt_title: null, match_rank: 0, total_count: 1, }); }); test('KAT-01 Kriterium 1: Teil eines Titels, Groß-/Kleinschreibung, Sonderzeichen und Akzente sind egal', async () => { for (const q of ['WLPD CAFE-GROSSE duel', 'wlpd café größe: duel®', 'cafe grosse', 'gross', 'DUEL']) { const rows = await search({ p_query: q }); expect(idSet(rows), q).toContain(ids.cafe); } // Ohne Leerzeichen ("aeons end"-Regel): der Titel ohne Leerzeichen trifft. expect(idSet(await search({ p_query: 'wlpdcafegrossduel' }))).toEqual([]); expect(idSet(await search({ p_query: 'wlpdcafegrosseduel' }))).toContain(ids.cafe); }); test('KAT-01 Kriterium 1: Relevanzstufen 0 bis 4 in dieser Reihenfolge', async () => { const rows = await search({ p_query: 'wlpd relevanz' }); expect(titlesOf(rows)).toEqual([t.relExact, t.relBegins, t.relWord, t.relContains, t.relPublisher]); expect(rows.map((r) => r.match_rank)).toEqual([0, 1, 2, 3, 4]); expect(rows.every((r) => r.total_count === 5)).toBe(true); }); test('KAT-01 Kriterium 1: nur Sonderzeichen ergibt keine Treffer; unter 2 Zeichen gilt als kein Suchbegriff', async () => { const none = await rpc({ p_query: '!!!', p_limit: 5 }); expect(none.status).toBe(200); expect(none.body).toEqual([]); // "a" (1 Zeichen) = kein Suchbegriff: ganzer Katalog, match_rank null, alphabetisch. const all = await search({ p_query: 'a', p_limit: 5 }); expect(all).toHaveLength(5); expect(all.every((r) => r.match_rank === null)).toBe(true); expect(all[0].total_count).toBeGreaterThanOrEqual(ds.mass.length + 12) // 36 Massenspiele + 7 Einzelspiele + 5 Relevanzspiele; }); test('KAT-05 Kriterium 4: ausgeblendete und zusammengeführte Spiele erscheinen nie, auch nicht über alternative Titel', async () => { for (const q of ['wlpd verborgen', 'verstecktername', 'wlpd zusammengefuehrt']) { expect(await search({ p_query: q }), q).toEqual([]); } }); test('KAT-01 Sicherheit Kriterium 2: private Spiele erscheinen nie, auch nicht für ihren Besitzer, auch nicht über alternative Titel', async () => { for (const q of ['wlpd privatspiel', 'geheimname']) { expect(await search({ p_query: q }), `anon ${q}`).toEqual([]); expect(await search({ p_query: q }, { token: ownerToken }), `Besitzer ${q}`).toEqual([]); } // Das private Spiel ist auch ueber die Tabelle der alternativen Titel fuer anon unsichtbar (RLS). const res = await fetch( `${env.supabaseUrl}/rest/v1/game_alt_titles?select=title&title=ilike.*${ds.word}*`, { headers: { apikey: env.anonKey, authorization: `Bearer ${env.anonKey}` } }, ); expect(res.status).toBe(200); const visible = ((await res.json()) as { title: string }[]).map((r) => r.title); expect(visible).toEqual(expect.arrayContaining([t.waldAltDe, t.waldAltEn])); expect(visible).not.toContain(`${ds.W} Geheimname`); }); }); test.describe('KAT-01 Kriterium 2 und 3: alternative Titel und Verlage (api)', () => { test('KAT-01 Kriterium 2: deutscher und englischer alternativer Titel finden dasselbe Spiel; matched_alt_title nennt den Treffer', async () => { for (const [q, alt] of [ ['dunkelforst', t.waldAltDe], ['wlpd shadowwood', t.waldAltEn], ] as const) { const rows = await search({ p_query: q }); expect(titlesOf(rows), q).toEqual([t.wald]); expect(rows[0].matched_alt_title, q).toBe(alt); } // Der Haupttitel selbst trifft ohne matched_alt_title. const main = await search({ p_query: 'wlpd schattenwald' }); expect(main).toHaveLength(1); expect(main[0].matched_alt_title).toBeNull(); }); test('KAT-01 Kriterium 3: Verlag findet seine Spiele; publisher_names ist sortiert und vollständig', async () => { const rows = await search({ p_query: 'quarzfeld' }); expect(idSet(rows)).toEqual([ids.rot, ids.blau].sort()); const blau = rows.find((r) => r.id === ids.blau)!; expect(blau.publisher_names).toEqual([t.pubNebel, t.pubQuarz]); expect(rows.find((r) => r.id === ids.rot)!.publisher_names).toEqual([t.pubQuarz]); // Der zweite Verlag findet das Spiel ebenfalls, aber nicht das Spiel des ersten allein. expect(idSet(await search({ p_query: 'nebelkamm' }))).toEqual([ids.blau]); // Wort aus Titel UND Wort aus Verlag (UND-verknuepft). expect(idSet(await search({ p_query: 'rotkehlchen quarzfeld' }))).toEqual([ids.rot]); expect(await search({ p_query: 'rotkehlchen nebelkamm' })).toEqual([]); }); }); test.describe('KAT-01 Kriterium 4 und 8: Filter (api)', () => { const genreA = [ids.familie, ids.experte, ids.duo].sort(); test('KAT-01 Kriterium 8: nur Filter ohne Suchbegriff, alphabetisch, match_rank null', async () => { const rows = await search({ p_genre_ids: [ids.genreA] }); expect(idSet(rows)).toEqual(genreA); expect(titlesOf(rows)).toEqual([t.duo, t.experte, t.familie]); expect(rows.every((r) => r.match_rank === null && r.total_count === 3)).toBe(true); expect(idSet(await search({ p_mechanic_ids: [ids.mechanicM1] }))).toEqual([ids.experte, ids.duo].sort()); }); test('KAT-01 Kriterium 4: mehrere Genres bzw. Filter sind UND-verknüpft', async () => { expect(idSet(await search({ p_genre_ids: [ids.genreA, ids.genreB] }))).toEqual([ids.experte]); expect(idSet(await search({ p_genre_ids: [ids.genreA], p_mechanic_ids: [ids.mechanicM1] }))).toEqual( [ids.experte, ids.duo].sort(), ); // Doppelte IDs zaehlen einfach. expect(idSet(await search({ p_genre_ids: [ids.genreA, ids.genreA] }))).toEqual(genreA); // Unbekannte Genre-ID: keine Treffer. expect(await search({ p_genre_ids: [ids.genreA, '00000000-0000-4000-8000-00000000dead'] })).toEqual([]); // Suchbegriff plus Filter. expect(idSet(await search({ p_query: 'wlpd', p_genre_ids: [ids.genreB] }))).toEqual([ids.experte]); }); test('KAT-01 Kriterium 4: Spielerzahl, Spieldauer und Mindestalter', async () => { const g = { p_genre_ids: [ids.genreA] }; expect(idSet(await search({ ...g, p_players: 5 }))).toEqual([ids.familie]); expect(idSet(await search({ ...g, p_players: 1 }))).toEqual([ids.experte]); expect(idSet(await search({ ...g, p_max_minutes: 45 }))).toEqual([ids.familie, ids.duo].sort()); expect(idSet(await search({ ...g, p_age: 6 }))).toEqual([ids.familie]); expect(idSet(await search({ ...g, p_age: 8 }))).toEqual([ids.familie, ids.duo].sort()); expect(await search({ ...g, p_players: 12 })).toEqual([]); // Alle Filter zusammen. expect(idSet(await search({ ...g, p_players: 2, p_max_minutes: 30, p_age: 8 }))).toEqual([ids.duo]); }); }); test.describe('KAT-01 Kriterium 5: Nachladen (api)', () => { test('KAT-01 Kriterium 5: zwei Seiten ergeben die Gesamtmenge ohne Überschneidung, total_count bleibt gleich, Reihenfolge stabil', async () => { const q = { p_query: 'wlpd massenspiel' }; const first = await search({ ...q, p_limit: 30, p_offset: 0 }); const second = await search({ ...q, p_limit: 30, p_offset: 30 }); expect(first).toHaveLength(30); expect(second).toHaveLength(MASS_COUNT - 30); expect([...first, ...second].every((r) => r.total_count === MASS_COUNT)).toBe(true); const all = [...first, ...second]; expect(new Set(all.map((r) => r.id)).size).toBe(MASS_COUNT); expect(titlesOf(all)).toEqual(ds.mass.map((m) => m.title)); // Wiederholung liefert dieselbe Reihenfolge. expect(titlesOf(await search({ ...q, p_limit: 30, p_offset: 0 }))).toEqual(titlesOf(first)); // Standard-Seitengroesse 30. expect(await search(q)).toHaveLength(30); // Hinter dem Ende: leer. expect(await search({ ...q, p_offset: 60 })).toEqual([]); }); }); test.describe('KAT-01 Eingabegrenzen: Fehlerfälle über Kong (api)', () => { const expectP0001 = (res: Res, message: string) => { expect(res.status, JSON.stringify(res.body)).toBe(400); expect(res.body.code).toBe('P0001'); expect(res.body.message).toBe(message); }; test('KAT-01: Suchbegriff über 100 Zeichen oder über 10 Wörter → search_query_too_long', async () => { expectP0001(await rpc({ p_query: 'a'.repeat(101) }), 'search_query_too_long'); expectP0001(await rpc({ p_query: 'aa bb cc dd ee ff gg hh ii jj kk' }), 'search_query_too_long'); // Genau an der Grenze: 100 Zeichen und 10 Woerter sind zulaessig. expect((await rpc({ p_query: 'a'.repeat(100) })).status).toBe(200); expect((await rpc({ p_query: 'aa bb cc dd ee ff gg hh ii jj' })).status).toBe(200); }); test('KAT-01: Filter außerhalb des Bereichs → search_filter_invalid', async () => { const many = Array.from({ length: 21 }, (_, i) => `00000000-0000-4000-8000-${String(i).padStart(12, '0')}`); for (const params of [ { p_players: 0 }, { p_players: 100 }, { p_max_minutes: 0 }, { p_max_minutes: 1441 }, { p_age: -1 }, { p_age: 100 }, { p_genre_ids: many }, { p_mechanic_ids: many }, ]) { expectP0001(await rpc(params), 'search_filter_invalid'); } }); test('KAT-01: Seitengröße oder Versatz außerhalb des Bereichs → search_paging_invalid', async () => { for (const params of [{ p_limit: 0 }, { p_limit: 51 }, { p_offset: -1 }, { p_offset: 10_001 }]) { expectP0001(await rpc(params), 'search_paging_invalid'); } expect((await rpc({ p_limit: 50, p_offset: 10_000 })).status).toBe(200); }); test('KAT-01: smallint außerhalb des Typbereichs scheitert mit 22003, eine falsche UUID mit 22P02 (kein P0001)', async () => { const overflow = await rpc({ p_players: 40_000 }); expect(overflow.status).toBe(400); expect(overflow.body.code).toBe('22003'); const badUuid = await rpc({ p_genre_ids: ['kein-uuid'] }); expect(badUuid.status).toBe(400); expect(badUuid.body.code).toBe('22P02'); }); test('KAT-01 Sicherheit Kriterium 2: service_role darf die RPC nicht ausführen (42501), authenticated darf', async () => { // (Ob Kong Aufrufe ganz ohne apikey ablehnt, ist Sache der Gateway-Konfiguration: die lokale CLI laesst sie als anon zu.) expect(await search({ p_query: 'wlpd duo' }, { token: ownerToken })).toHaveLength(1); const service = await rpc({ p_query: 'wlpd duo' }, { apikey: env.serviceRoleKey }); expect([401, 403]).toContain(service.status); expect(service.body.code).toBe('42501'); }); test('KAT-01 Sicherheit Kriterium 2: anon und authenticated können alternative Titel nicht schreiben', async () => { for (const bearer of [env.anonKey, ownerToken]) { const res = await fetch(`${env.supabaseUrl}/rest/v1/game_alt_titles`, { method: 'POST', headers: { apikey: env.anonKey, authorization: `Bearer ${bearer}`, 'content-type': 'application/json', prefer: 'return=minimal', }, body: JSON.stringify({ game_id: ids.duo, locale: 'de', title: `${ds.W} Eingeschleust` }), }); expect([401, 403]).toContain(res.status); expect((await res.json()).code).toBe('42501'); } expect(await search({ p_query: 'eingeschleust' })).toEqual([]); }); }); // Nach dem Lauf bleibt nichts zurueck: Das Aufraeumen wird selbst geprueft. test.afterAll(async () => { const left = await withDb(async (c) => Number((await c.query(`select count(*) from public.games where id::text like $1`, [`${ds.prefix}-%`])).rows[0].count), ); expect(left).toBe(0); });