test(catalog): Playwright-E2E fuer die Katalogsuche (web und api)

Web: Suche per Schaltflaeche, Relevanz, alternative Titel, Verlage, Filter,
Nachladen, leere Zustaende, URL/Reload/Zurueck, Offline. API: RPC als anon
ueber Kong inkl. Fehlerfaellen. Eigene Datensets je Spec-Datei.

Refs #3

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
cschlaefke 2026-11-04 19:18:57 +01:00
parent 694a66689d
commit b9c41bee64
5 changed files with 914 additions and 35 deletions

178
e2e/support/catalog.ts Normal file
View file

@ -0,0 +1,178 @@
/**
* Testdaten fuer die Katalogsuche (KAT-01, Issue #3, PR 3c). NUR lokal/CI (`withDb`, als `postgres`).
*
* Jede Spec-Datei bekommt ein EIGENES Datenset (eigener UUID-Praefix, eigenes Kunstwort), weil die
* Dateien unter `fullyParallel` gleichzeitig laufen: Raeumt eine Datei auf, darf sie keine Zeilen der
* anderen loeschen. Das Kunstwort steht in allen Titeln, Verlagen und Begriffen; Suchen mit dem
* Kunstwort treffen deshalb nie Startbestandsspiele, nie die Fixtures von pgTAP (`qxz`) und nie das
* Datenset der anderen Datei. Ohne Suchbegriff wird nur ueber die eigenen Genres/Mechaniken gefiltert.
*
* Aufraeumen ueber den UUID-Praefix (Spiele, Verlage, Genres, Mechaniken); Zuordnungen und
* alternative Titel haengen per `on delete cascade` an den Spielen. `seed` loescht zuerst, ist also
* nach einem abgebrochenen Lauf idempotent.
*/
import type { Client } from 'pg';
import { withDb } from './db';
export interface CatalogDatasetOptions {
/** 8 Hex-Zeichen, UUID-Praefix aller Zeilen des Sets. */
prefix: string;
/** Kunstwort (nur Kleinbuchstaben a-z), steht in jedem Titel. */
word: string;
}
const uuid = (prefix: string, n: number) => `${prefix}-0000-4000-8000-${n.toString(16).padStart(12, '0')}`;
const cap = (w: string) => w.charAt(0).toUpperCase() + w.slice(1);
/** Anzahl der Massenspiele (Seitengroesse 30 + 6, KAT-01 Kriterium 5). */
export const MASS_COUNT = 36;
export function catalogDataset({ prefix, word }: CatalogDatasetOptions) {
const W = cap(word);
const id = (n: number) => uuid(prefix, n);
const mass = Array.from({ length: MASS_COUNT }, (_, i) => {
const nn = String(i + 1).padStart(2, '0');
return { id: id(1000 + i + 1), title: `${W} Massenspiel ${nn}` };
});
const ids = {
cafe: id(1),
wald: id(2),
rot: id(3),
blau: id(4),
familie: id(5),
experte: id(6),
duo: id(7),
relExact: id(10),
relBegins: id(11),
relWord: id(12),
relContains: id(13),
relPublisher: id(14),
hidden: id(20),
merged: id(21),
genreA: id(101),
genreB: id(102),
mechanicM1: id(111),
mechanicM2: id(112),
pubQuarz: id(201),
pubNebel: id(202),
pubRelevanz: id(203),
};
const t = {
cafe: `${W} Café Größe: Duel®`,
wald: `${W} Schattenwald`,
waldAltDe: `${W} Dunkelforst`,
waldAltEn: `${W} Shadowwood`,
rot: `${W} Rotkehlchen`,
blau: `${W} Blaumeise`,
familie: `${W} Familienfest`,
experte: `${W} Expertenrunde`,
duo: `${W} Duo`,
relExact: `${W} Relevanz`,
relBegins: `${W} Relevanz Deluxe`,
relWord: `Die ${W} Relevanz`,
relContains: `X${W} Relevanzen`,
relPublisher: `${W} Fremdtitel`,
hidden: `${W} Verborgen`,
merged: `${W} Zusammengefuehrt`,
genreA: `${W} Genre A`,
genreB: `${W} Genre B`,
mechanicM1: `${W} Mechanik 1`,
mechanicM2: `${W} Mechanik 2`,
pubQuarz: `Quarzfeld ${W} Presse`,
pubNebel: `Nebelkamm ${W} Verlag`,
pubRelevanz: `Relevanz Verlag ${W}`,
};
return { word, W, prefix, ids, titles: t, mass };
}
export type CatalogDataset = ReturnType<typeof catalogDataset>;
/** Loescht alle Zeilen des Sets (anhand des UUID-Praefixes). */
export async function cleanupCatalog(c: Client, ds: Pick<CatalogDataset, 'prefix'>): Promise<void> {
const like = `${ds.prefix}-%`;
await c.query(`delete from public.games where id::text like $1`, [like]);
await c.query(`delete from public.publishers where id::text like $1`, [like]);
await c.query(`delete from public.genres where id::text like $1`, [like]);
await c.query(`delete from public.mechanics where id::text like $1`, [like]);
}
/** Legt das Datenset an (nach Aufraeumen). */
export async function seedCatalog(ds: CatalogDataset): Promise<void> {
const { ids: i, titles: t } = ds;
await withDb(async (c) => {
await cleanupCatalog(c, ds);
await c.query(
`insert into public.genres (id, name_de, name_en) values ($1, $2, $2), ($3, $4, $4)`,
[i.genreA, t.genreA, i.genreB, t.genreB],
);
await c.query(
`insert into public.mechanics (id, name_de, name_en) values ($1, $2, $2), ($3, $4, $4)`,
[i.mechanicM1, t.mechanicM1, i.mechanicM2, t.mechanicM2],
);
await c.query(
`insert into public.publishers (id, name) values ($1, $2), ($3, $4), ($5, $6)`,
[i.pubQuarz, t.pubQuarz, i.pubNebel, t.pubNebel, i.pubRelevanz, t.pubRelevanz],
);
// (id, title, year, min_p, max_p, min_min, max_min, min_age)
const games: [string, string, number | null, number | null, number | null, number | null, number | null, number | null][] = [
[i.cafe, t.cafe, 2021, 2, 2, 20, 30, 12],
[i.wald, t.wald, 2019, 2, 4, 45, 60, 10],
[i.rot, t.rot, 2018, 2, 4, 30, 45, 8],
[i.blau, t.blau, 2022, 2, 5, 30, 45, 8],
[i.familie, t.familie, 2017, 3, 8, 20, 45, 6],
[i.experte, t.experte, 2016, 1, 2, 120, 180, 14],
[i.duo, t.duo, 2015, 2, 2, 30, 30, 8],
[i.relExact, t.relExact, 2000, 2, 4, 30, 60, 10],
[i.relBegins, t.relBegins, 2000, 2, 4, 30, 60, 10],
[i.relWord, t.relWord, 2000, 2, 4, 30, 60, 10],
[i.relContains, t.relContains, 2000, 2, 4, 30, 60, 10],
[i.relPublisher, t.relPublisher, 2000, 2, 4, 30, 60, 10],
...ds.mass.map(
(m) => [m.id, m.title, 2020, 2, 4, 30, 60, 10] as [string, string, number, number, number, number, number, number],
),
];
for (const g of games) {
await c.query(
`insert into public.games (id, title, visibility, year, min_players, max_players, min_minutes, max_minutes, min_age)
values ($1, $2, 'catalog', $3, $4, $5, $6, $7, $8)`,
g,
);
}
// Ausgeblendet und zusammengefuehrt: duerfen nie erscheinen (KAT-05 Kriterium 4).
await c.query(
`insert into public.games (id, title, visibility, hidden_at) values ($1, $2, 'catalog', now())`,
[i.hidden, t.hidden],
);
await c.query(
`insert into public.games (id, title, visibility, hidden_at, merged_into_id) values ($1, $2, 'catalog', now(), $3)`,
[i.merged, t.merged, i.relExact],
);
await c.query(
`insert into public.game_alt_titles (game_id, locale, title) values ($1, 'de', $2), ($1, 'en', $3)`,
[i.wald, t.waldAltDe, t.waldAltEn],
);
// Der alternative Titel des ausgeblendeten Spiels darf es nicht auffindbar machen.
await c.query(`insert into public.game_alt_titles (game_id, locale, title) values ($1, 'de', $2)`, [
i.hidden,
`${ds.W} Verstecktername`,
]);
await c.query(
`insert into public.game_publishers (game_id, publisher_id) values ($1, $2), ($3, $2), ($3, $4), ($5, $6)`,
[i.rot, i.pubQuarz, i.blau, i.pubNebel, i.relPublisher, i.pubRelevanz],
);
await c.query(
`insert into public.game_genres (game_id, genre_id) values ($1, $2), ($3, $2), ($4, $2), ($4, $5)`,
[i.familie, i.genreA, i.duo, i.experte, i.genreB],
);
await c.query(
`insert into public.game_mechanics (game_id, mechanic_id) values ($1, $3), ($2, $3)`,
[i.experte, i.duo, i.mechanicM1],
);
});
}
export async function cleanupCatalogDataset(ds: CatalogDataset): Promise<void> {
await withDb((c) => cleanupCatalog(c, ds));
}

44
e2e/support/flutter.ts Normal file
View file

@ -0,0 +1,44 @@
/**
* Flutter-Web-Helfer ohne Abhaengigkeit von `auth-config.ts` (Projekte `web` und `perf`).
*
* Flutter zeichnet auf ein Canvas; Playwright sieht Elemente erst, wenn der Semantics-Baum
* eingeschaltet ist (Platzhalter `flt-semantics-placeholder`). `perf` darf gegen eine fremde
* Umgebung laufen und importiert deshalb nichts, was lokale Auth-Konstanten voraussetzt.
*/
import { expect, type Locator, type Page } from '@playwright/test';
/**
* Sichtbarer Text im Semantics-Baum. Flutter legt Texte teils als Textknoten, teils als
* `aria-label` ab (z. B. der Inhalt der CheckEmail-Seite).
*/
export function textOf(page: Page, text: string | RegExp): Locator {
return page.getByText(text).or(page.getByLabel(text)).first();
}
/** Schaltet den Semantics-Baum ein (einmal je Seitenladen). */
export async function enableSemantics(page: Page): Promise<void> {
const placeholder = page.locator('flt-semantics-placeholder');
await placeholder.waitFor({ state: 'attached' });
await placeholder.evaluate((el: HTMLElement) => el.click());
await page.locator('flt-semantics').first().waitFor({ state: 'attached' });
}
/** Navigiert und schaltet Semantics ein. */
export async function open(page: Page, path: string): Promise<void> {
await page.goto(path);
await enableSemantics(page);
}
/**
* Fuellt ein Flutter-Textfeld. Das Semantics-Textfeld legt das echte <input> erst beim Fokus an;
* `fill` allein geht dann gelegentlich verloren. Deshalb klicken, fuellen und den Wert pruefen
* (mit Wiederholung).
*/
export async function fillField(field: Locator, value: string): Promise<void> {
await expect(async () => {
await field.click();
await field.fill('');
await field.pressSequentially(value);
await expect(field).toHaveValue(value, { timeout: 1_000 });
}).toPass({ timeout: 15_000 });
}

View file

@ -7,6 +7,7 @@
*/
import { expect, type BrowserContext, type Locator, type Page } from '@playwright/test';
import { env, TERMS_VERSION } from './auth-config';
import { enableSemantics, fillField, open, textOf } from './flutter';
export { TERMS_VERSION };
@ -28,41 +29,7 @@ export const t = {
home: 'Welcome to Shuffle. The board game catalog is coming soon.',
} as const;
/**
* Sichtbarer Text im Semantics-Baum. Flutter legt Texte teils als Textknoten, teils als
* `aria-label` ab (z. B. der Inhalt der CheckEmail-Seite).
*/
export function textOf(page: Page, text: string | RegExp): Locator {
return page.getByText(text).or(page.getByLabel(text)).first();
}
/** Schaltet den Semantics-Baum ein (einmal je Seitenladen). */
export async function enableSemantics(page: Page): Promise<void> {
const placeholder = page.locator('flt-semantics-placeholder');
await placeholder.waitFor({ state: 'attached' });
await placeholder.evaluate((el: HTMLElement) => el.click());
await page.locator('flt-semantics').first().waitFor({ state: 'attached' });
}
/** Navigiert und schaltet Semantics ein. */
export async function open(page: Page, path: string): Promise<void> {
await page.goto(path);
await enableSemantics(page);
}
/**
* Fuellt ein Flutter-Textfeld. Das Semantics-Textfeld legt das echte <input> erst beim Fokus an;
* `fill` allein geht dann gelegentlich verloren. Deshalb klicken, fuellen und den Wert pruefen
* (mit Wiederholung).
*/
export async function fillField(field: Locator, value: string): Promise<void> {
await expect(async () => {
await field.click();
await field.fill('');
await field.pressSequentially(value);
await expect(field).toHaveValue(value, { timeout: 1_000 });
}).toPass({ timeout: 15_000 });
}
export { textOf, enableSemantics, open, fillField };
export async function fillRegistration(
page: Page,

View file

@ -0,0 +1,367 @@
/**
* 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®', '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<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: '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);
});

View file

@ -0,0 +1,323 @@
/**
* KAT-01 Spiele suchen (Issue #3, Spec docs/specs/3-katalog-suche.md § 7.2), Projekt `web`.
*
* Alle Tests laufen OHNE Anmeldung (Sicherheit Krit. 2) gegen die gebaute Web-App und den lokalen
* Stack. Testdaten: eigenes Datenset mit Kunstwort "zqvk" (e2e/support/catalog.ts), Anlage im
* `beforeAll`, Aufraeumen im `afterAll`; die Datei laeuft seriell, die Daten werden gemeinsam
* genutzt. Texte stammen aus app_en.arb (Browser-Sprache en-US).
*
* Wartet nie auf feste Zeiten: Das Ende einer Suche ist an der erwarteten Trefferzahl UND einem
* erwarteten Treffer erkennbar (waehrend einer neuen Suche bleiben die alten Treffer sichtbar).
* Nicht abgedeckt (Widget-Tests in 3b bzw. manuell): Weitere laden per Tastatur (Auto-Nachladen
* ueberholt die Schaltflaeche), Bildschirmleser-Ansage, Textskalierung 200 %.
*/
import { expect, test, type Page } from '@playwright/test';
import { catalogDataset, cleanupCatalogDataset, MASS_COUNT, seedCatalog } from '../../support/catalog';
import { enableSemantics, fillField, open } from '../../support/flutter';
const ds = catalogDataset({ prefix: 'e2ec0001', word: 'zqvk' });
const { ids, titles: t } = ds;
test.describe.configure({ mode: 'serial' });
test.beforeAll(async () => {
await seedCatalog(ds);
});
test.afterAll(async () => {
await cleanupCatalogDataset(ds);
});
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const searchField = (page: Page) => page.getByRole('textbox', { name: /Search games/ });
/**
* Trefferkachel: Im Semantics-Baum ein Textknoten "Titel, Jahr · Verlage, Angaben, auch: ...".
* (Die Kacheln liegen als Kinder EINES Listen-Containers, ein Rollen-Locator je Kachel gibt es nicht.)
* Die Liste ist virtualisiert: nur sichtbare und knapp daneben liegende Kacheln sind im Baum.
*/
const tile = (page: Page, title: string) => page.getByText(new RegExp(`^${escapeRe(title)}(,|$)`)).first();
const count = (page: Page, n: number) => page.getByText(n === 1 ? '1 game' : `${n} games`, { exact: true });
/** Oeffnet die Suche ueber die Schaltflaeche auf der Startseite (ohne Anmeldung). */
async function openSearchViaButton(page: Page): Promise<void> {
await open(page, '/');
await page.getByRole('button', { name: 'Search games' }).click();
await expect(page).toHaveURL(/\/catalog/);
await expect(searchField(page)).toBeVisible();
}
/**
* Oeffnet die Suche wie ein Nutzer (Startseite, Schaltflaeche) und wartet, bis die erste Suche
* (ganzer Katalog) abgeschlossen ist. Ein direkt geladenes /catalog nimmt Eingaben ueber den
* Semantics-Baum nicht verlaesslich an (Feld bleibt leer, Autofokus mal gesetzt, mal nicht);
* deshalb tippen die Tests nur nach diesem Einstieg. Deep Links werden ohne Tippen geprueft.
*/
async function openCatalog(page: Page): Promise<void> {
await openSearchViaButton(page);
await expect(textCount(page)).toBeVisible();
}
/** Tippt einen Suchbegriff (ersetzt den alten); die Entprellung (300 ms) wird ueber das Erwartete abgewartet. */
async function search(page: Page, text: string): Promise<void> {
await fillField(searchField(page), text);
}
/** Auswahlfelder der Filter in fester Reihenfolge; ihr Name ist "Any" oder der gewaehlte Wert. */
const dropdownIndex = { players: 0, duration: 1, age: 2 } as const;
async function choose(page: Page, field: keyof typeof dropdownIndex, option: string): Promise<void> {
await page
.getByRole('button', { name: /^(Any|\d+ players?|Up to \d+ min|Suitable from age \d+)$/ })
.nth(dropdownIndex[field])
.click();
await page.getByRole('menuitem', { name: option, exact: true }).click();
}
/**
* Hinweistext im Semantics-Baum. Nur `span`: Dieselben Texte stehen zusaetzlich in der
* Live-Region `flt-announcement-polite` (Ansage), die nach dem Verschwinden noch kurz bleibt.
*/
const message = (page: Page, text: string) => page.locator('span', { hasText: text });
const chip = (page: Page, name: string) => page.getByRole('checkbox', { name, exact: true });
test.describe('KAT-01 Kriterium 1: Titelsuche (web)', () => {
test('KAT-01 Kriterium 1: Suche per Schaltfläche „Search games“, Teil eines Titels in anderer Schreibweise findet das Spiel', async ({ page }) => {
await openSearchViaButton(page);
// Gross/Klein, Bindestrich statt Leerzeichen, ohne Akzente und Umlaute, ohne ®.
await search(page, 'ZQVK CAFE-GROSSE duel');
await expect(tile(page, t.cafe)).toBeVisible();
await expect(count(page, 1)).toBeVisible();
// Nur ein Teil des Titels.
await search(page, 'dunkel');
await expect(tile(page, t.wald)).toBeVisible();
await expect(count(page, 1)).toBeVisible();
await expect(tile(page, t.cafe)).toHaveCount(0);
});
test('KAT-01 Kriterium 1: Treffer sind nach Relevanz sortiert (genau, beginnt mit, Wortanfang, enthält, nur über Verlag)', async ({ page }) => {
await openCatalog(page);
await search(page, 'zqvk relevanz');
await expect(count(page, 5)).toBeVisible();
await expect(tile(page, t.relPublisher)).toBeVisible();
// Textknoten der Kacheln in Dokumentreihenfolge (= Listenreihenfolge).
const shown = (await page.getByText(/^(Die |X)?Zqvk (Relevanz|Fremdtitel)/).allTextContents()).map(
(label) => label.split(',')[0],
);
expect(shown, 'Reihenfolge nach Relevanz').toEqual([t.relExact, t.relBegins, t.relWord, t.relContains, t.relPublisher]);
});
test('KAT-01 Kriterium 1 / KAT-05 Kriterium 4: ausgeblendete und zusammengeführte Spiele erscheinen nicht, auch nicht über alternative Titel', async ({ page }) => {
await openCatalog(page);
await search(page, 'zqvk verborgen');
await expect(page.getByText(/^No games found for/)).toBeVisible();
await search(page, 'verstecktername');
await expect(page.getByText(/^No games found for/)).toBeVisible();
await search(page, 'zqvk zusammengefuehrt');
await expect(page.getByText(/^No games found for/)).toBeVisible();
});
});
test.describe('KAT-01 Kriterium 2: alternative Titel (web)', () => {
test('KAT-01 Kriterium 2: deutscher und englischer alternativer Titel finden dasselbe Spiel, die Kachel nennt den Treffer-Titel', async ({ page }) => {
await openCatalog(page);
await search(page, 'dunkelforst');
await expect(tile(page, t.wald)).toBeVisible();
await expect(tile(page, t.wald)).toHaveText(new RegExp(`also: ${escapeRe(t.waldAltDe)}`));
await search(page, 'shadowwood');
await expect(tile(page, t.wald)).toHaveText(new RegExp(`also: ${escapeRe(t.waldAltEn)}`));
await expect(count(page, 1)).toBeVisible();
});
});
test.describe('KAT-01 Kriterium 3: Verlagssuche (web)', () => {
test('KAT-01 Kriterium 3: Verlagsname findet dessen Spiele; ein Spiel mit zwei Verlagen nennt beide', async ({ page }) => {
await openCatalog(page);
await search(page, 'quarzfeld');
await expect(count(page, 2)).toBeVisible();
await expect(tile(page, t.rot)).toBeVisible();
await expect(tile(page, t.blau)).toBeVisible();
// Sortiert nach normalisiertem Namen: "nebelkamm ..." vor "quarzfeld ...".
await expect(tile(page, t.blau)).toHaveText(
new RegExp(`${escapeRe(t.pubNebel)}, ${escapeRe(t.pubQuarz)}`),
);
});
test('KAT-01 Kriterium 3: der zweite Verlag eines Spiels findet es ebenfalls', async ({ page }) => {
await openCatalog(page);
await search(page, 'nebelkamm');
await expect(count(page, 1)).toBeVisible();
await expect(tile(page, t.blau)).toBeVisible();
});
});
test.describe('KAT-01 Kriterium 4 und 8: Filter (web)', () => {
test('KAT-01 Kriterium 4: Filter Genre und Spielerzahl mit Suchbegriff zeigen nur passende Spiele; Zurücksetzen hebt sie auf', async ({ page }) => {
await openCatalog(page);
await search(page, 'zqvk familienfest');
await expect(count(page, 1)).toBeVisible();
await search(page, 'zqvk');
// Das Genre A trägt Familienfest, Expertenrunde und Duo; "zqvk" trifft zusätzlich alle anderen.
await chip(page, t.genreA).click();
await expect(count(page, 3)).toBeVisible();
await expect(tile(page, t.familie)).toBeVisible();
await expect(tile(page, t.experte)).toBeVisible();
await expect(tile(page, t.duo)).toBeVisible();
// Spielerzahl 5: nur das Familienfest (3 bis 8).
await choose(page, 'players', '5 players');
await expect(count(page, 1)).toBeVisible();
await expect(tile(page, t.familie)).toBeVisible();
await expect(tile(page, t.duo)).toHaveCount(0);
await page.getByRole('button', { name: 'Reset filters' }).click();
await expect(textCount(page)).not.toHaveText('1 game');
// Der Suchbegriff bleibt erhalten (URL; das Semantics-Eingabefeld zeigt nach einem Auswahlmenue leer).
await expect(page).toHaveURL(/q=zqvk(&|$)/);
});
test('KAT-01 Kriterium 4: Spieldauer und Alter schließen Spiele aus, die nicht passen', async ({ page }) => {
await open(page, `/catalog?genre=${ids.genreA}`);
await expect(count(page, 3)).toBeVisible();
// Bis 45 Minuten: Familienfest (max. 45) und Duo (30), nicht Expertenrunde (180).
await choose(page, 'duration', 'Up to 45 min');
await expect(count(page, 2)).toBeVisible();
await expect(tile(page, t.experte)).toHaveCount(0);
// Geeignet ab 6 Jahren: nur Familienfest (Duo ab 8).
await choose(page, 'age', 'Suitable from age 6');
await expect(count(page, 1)).toBeVisible();
await expect(tile(page, t.familie)).toBeVisible();
});
test('KAT-01 Kriterium 8: nur Filter ohne Suchbegriff, mehrere Filter sind UND-verknüpft', async ({ page }) => {
await openCatalog(page);
await expect(searchField(page)).toHaveValue('');
// Genre A allein: drei Spiele des Datensets (eigene Genres, Startbestand trifft nie).
await chip(page, t.genreA).click();
await expect(count(page, 3)).toBeVisible();
// Genre A UND Mechanik 1: Expertenrunde und Duo.
await chip(page, t.mechanicM1).click();
await expect(count(page, 2)).toBeVisible();
await expect(tile(page, t.familie)).toHaveCount(0);
// Genre A UND Genre B: nur Expertenrunde (alle gewählten Genres, Spec-F1).
await chip(page, t.mechanicM1).click();
await chip(page, t.genreB).click();
await expect(count(page, 1)).toBeVisible();
await expect(tile(page, t.experte)).toBeVisible();
});
test('KAT-01 Kriterium 8 / Plattformen: im mittleren Layout öffnet „Filters“ ein Sheet, „Done“ schließt es', async ({ page }) => {
await page.setViewportSize({ width: 800, height: 900 });
await openCatalog(page);
await page.getByRole('button', { name: /^Filters/ }).click();
// Hinter dem Sheet ist die Liste fuer Bildschirmleser verborgen; geprueft wird nach „Done“.
await chip(page, t.genreB).click();
await page.getByRole('button', { name: 'Done' }).click();
await expect(count(page, 1)).toBeVisible();
await expect(tile(page, t.experte)).toBeVisible();
await expect(page.getByRole('button', { name: 'Filters (1)' })).toBeVisible();
});
});
test.describe('KAT-01 Kriterium 5: Nachladen (web)', () => {
test('KAT-01 Kriterium 5: Scrollen bis zum Ende lädt die zweite Seite (p_offset 30) nach', async ({ page }) => {
await openCatalog(page);
await search(page, 'zqvk massenspiel');
await expect(count(page, MASS_COUNT)).toBeVisible();
const last = ds.mass[MASS_COUNT - 1].title;
await expect(tile(page, ds.mass[0].title)).toBeVisible();
await expect(tile(page, last)).toHaveCount(0);
const secondPage = page.waitForRequest(
(r) => r.url().includes('/rest/v1/rpc/search_catalog_games') && /"p_offset"\s*:\s*30\b/.test(r.postData() ?? ''),
);
await page.mouse.move(640, 450);
await expect(async () => {
await page.mouse.wheel(0, 1500);
await expect(tile(page, last)).toBeVisible({ timeout: 1_000 });
}).toPass({ timeout: 20_000 });
await secondPage;
// Die Trefferzahl bleibt, kein Spiel doppelt: Kachel Nr. 36 genau einmal.
await expect(tile(page, last)).toHaveCount(1);
await expect(count(page, MASS_COUNT)).toBeVisible();
});
});
test.describe('KAT-01 Kriterium 6: keine Treffer (web)', () => {
test('KAT-01 Kriterium 6: Suche ohne Treffer zeigt den Hinweis mit dem Suchbegriff', async ({ page }) => {
await openCatalog(page);
await search(page, 'zqvkgibtesnicht');
await expect(message(page, 'No games found for “zqvkgibtesnicht”.')).toBeVisible();
await expect(message(page, 'Check the spelling or remove filters.')).toBeVisible();
// Die Aktion „Report a missing game“ wird mit #13 (KAT-04) freigeschaltet (Spec-F4, E11).
});
test('KAT-01 Kriterium 8: Filter ohne passendes Spiel zeigen den Filter-Leerzustand; „Reset filters“ stellt die Liste her', async ({ page }) => {
await open(page, `/catalog?genre=${ids.genreA},${ids.genreB}`);
await expect(count(page, 1)).toBeVisible();
await choose(page, 'players', '12 players');
await expect(message(page, 'No games match your filters.')).toBeVisible();
await page.getByRole('button', { name: 'Reset filters' }).first().click();
await expect(message(page, 'No games match your filters.')).toHaveCount(0);
await expect(count(page, 1)).toHaveCount(0);
});
});
test.describe('KAT-01: URL-Zustand, Neuladen, Zurück (web)', () => {
test('KAT-01: Suchbegriff steht in der URL, Zurück führt in einem Schritt zur Startseite, Neuladen stellt den Stand her', async ({ page }) => {
await openSearchViaButton(page);
await search(page, 'zqvk rotkehlchen');
await expect(tile(page, t.rot)).toBeVisible();
await expect(page).toHaveURL(/\/catalog\?.*q=zqvk\+rotkehlchen|q=zqvk%20rotkehlchen/);
// Ersetzt, nicht angehängt: ein Schritt zurück ist die Startseite.
await page.reload();
await enableSemantics(page);
// Das Semantics-Eingabefeld spiegelt den Anfangswert nicht (Wert "" ohne Fokus); der
// wiederhergestellte Stand zeigt sich an der Trefferliste und bleibt in der URL.
await expect(page).toHaveURL(/q=zqvk(\+|%20)rotkehlchen/);
await expect(tile(page, t.rot)).toBeVisible();
await expect(count(page, 1)).toBeVisible();
await page.goBack();
await expect(page).toHaveURL(/^http:\/\/localhost:3000\/$/);
});
test('KAT-01 Kriterium 4: Filter stehen in der URL und werden beim Öffnen übernommen (Deep Link)', async ({ page }) => {
await open(page, `/catalog?genre=${ids.genreA}&players=5`);
await expect(tile(page, t.familie)).toBeVisible();
await expect(count(page, 1)).toBeVisible();
await expect(page.getByRole('button', { name: '5 players', exact: true })).toBeVisible();
// Eine Änderung schreibt sich in die URL zurück.
await chip(page, t.mechanicM1).click();
await expect(page).toHaveURL(new RegExp(`mechanic=${ids.mechanicM1}`));
});
test('KAT-01: ungültige oder unbekannte URL-Parameter werden still ignoriert', async ({ page }) => {
await open(page, '/catalog?q=zqvk%20massenspiel&players=abc&maxMinutes=7&age=x&genre=kein-uuid&mechanic=,,');
await expect(count(page, MASS_COUNT)).toBeVisible();
});
});
test.describe('KAT-01 Zustände Kriterium 1 und 2: Fehler und Netz (web)', () => {
test('KAT-01 Zustände: ohne Verbindung erscheint der Offline-Hinweis, „Try again“ liefert nach der Rückkehr die Treffer', async ({ page, context }) => {
await openCatalog(page);
await search(page, 'zqvk rotkehlchen');
await expect(tile(page, t.rot)).toBeVisible();
await context.setOffline(true);
await search(page, 'zqvk duo');
await expect(message(page, 'No connection. You need internet for the search.')).toBeVisible();
// Suchbegriff bleibt erhalten.
await expect(searchField(page)).toHaveValue('zqvk duo');
await context.setOffline(false);
await page.getByRole('button', { name: 'Try again' }).click();
await expect(tile(page, t.duo)).toBeVisible();
await expect(message(page, 'No connection. You need internet for the search.')).toHaveCount(0);
});
});
/** Trefferzahl-Text (egal welche Zahl), fuer „nicht mehr 1 Treffer“-Pruefungen. */
function textCount(page: Page) {
return page.getByText(/^\d+ games?$|^No games$/).first();
}