Shuffle/e2e/tests/api/kat01-suche.spec.ts
cschlaefke cd96d91eae
All checks were successful
CI / flutter (pull_request) Successful in 1m29s
CI / web-build (pull_request) Successful in 1m1s
CI / supabase (pull_request) Successful in 8s
CI / supabase-tests (pull_request) Successful in 33s
CI / e2e (pull_request) Successful in 3m33s
test(catalog): Datensaetze trennen, Assertions und Latenzmessung schaerfen
Jede Suche enthaelt das Kunstwort des eigenen Datensets (Kollision web/api
behoben); Kachelzaehlung ohne first(), konkrete Trefferzahlen; screenText
wartet auf die Live-Region statt sie zu entfernen; Latenzmessung ueberspringt
bereits sichtbare Treffer, Filtersuchen ergaenzt; Ladeskripte mit Exit-Code
und TCP-Schutz; Doku aktualisiert.

Refs #3

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 21:32:29 +01:00

378 lines
17 KiB
TypeScript

/**
* 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<T> {
status: number;
body: T;
}
/** POST /rest/v1/rpc/search_catalog_games, Standard: Rolle anon (apikey = Anon-Key). */
async function rpc(
params: Record<string, unknown>,
auth: { apikey?: string | null; token?: string } = {},
): Promise<Res<any>> {
const apikey = auth.apikey === undefined ? env.anonKey : auth.apikey;
const headers: Record<string, string> = { '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<string, unknown>, auth?: Parameters<typeof rpc>[1]): Promise<Row[]> {
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®', 'wlpd cafe grosse', 'WLPD gross', 'wlpd 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', 'wlpd 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', 'wlpd 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 [
['wlpd 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: 'wlpd 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: 'wlpd nebelkamm' }))).toEqual([ids.blau]);
// Wort aus Titel UND Wort aus Verlag (UND-verknuepft).
expect(idSet(await search({ p_query: 'wlpd rotkehlchen quarzfeld' }))).toEqual([ids.rot]);
expect(await search({ p_query: 'wlpd 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 UND Filter: der Begriff grenzt innerhalb des Genres ein ...
expect(idSet(await search({ p_query: 'wlpd duo', p_genre_ids: [ids.genreA] }))).toEqual([ids.duo]);
// ... und ein Begriff, der dem Filter widerspricht, ergibt nichts (Massenspiele haben das Genre nicht).
expect(await search({ p_query: 'wlpd massenspiel', p_genre_ids: [ids.genreA] })).toEqual([]);
});
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<any>, 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: 'wlpd eingeschleust' })).toEqual([]);
});
});
// Nach dem Lauf bleibt nichts zurueck: Das Aufraeumen wird selbst geprueft. Dieser zweite afterAll-Hook
// laeuft NACH dem ersten (Hooks derselben Datei laufen in Definitionsreihenfolge).
test.afterAll(async () => {
const left = await withDb(async (c) => {
const like = `${ds.prefix}-%`;
const q = async (table: string) =>
Number((await c.query(`select count(*) from public.${table} where id::text like $1`, [like])).rows[0].count);
return {
games: await q('games'),
publishers: await q('publishers'),
genres: await q('genres'),
mechanics: await q('mechanics'),
};
});
expect(left).toEqual({ games: 0, publishers: 0, genres: 0, mechanics: 0 });
});