All checks were successful
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>
378 lines
17 KiB
TypeScript
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 });
|
|
});
|