diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 3144e53..2e4004c 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -9,6 +9,8 @@ on: env: SUPABASE_CLI_VERSION: 3.0.0 + # Deno für das Startbestand-Werkzeug (supabase/catalog/, KAT-05); lokal getestet mit 2.9.8. + DENO_VERSION: 2.9.8 # Einzige Stelle für die Flutter-Version (= lokale Version, Dart 3.13.7; muss zu app/pubspec.yaml passen). # Muss ein existierendes Tag in github.com/flutter/flutter sein. Ein Cirrus-Image mit dieser Version gibt es nicht. FLUTTER_VERSION: 3.47.9 @@ -147,6 +149,42 @@ jobs: if [ -n "$changed" ]; then echo "Geänderte bestehende Migrationen: $changed"; exit 1; fi fi + # Startbestand-Werkzeug (KAT-05, Spec 2 D1): Deno-Tests, erzeugte Dateien aktuell, Schlüsselliste wächst nur. + - name: Deno installieren + run: | + # SHA256 fest eingetragen (aus den veröffentlichten .sha256sum-Dateien von v2.9.8); bei neuer DENO_VERSION mit ändern. + if [ "$(uname -m)" = "aarch64" ]; then + target=aarch64-unknown-linux-gnu; sha=d49596c20b9788cf8f5d8ddf644103d4bde56ed239753f0bdb92ba9361d51905 + else + target=x86_64-unknown-linux-gnu; sha=e890975bc2549807bbcc0b584ffafda72cc7c93349dd99c80483dd784f6c4609 + fi + curl -fsSL "https://github.com/denoland/deno/releases/download/v${DENO_VERSION}/deno-${target}.zip" -o /tmp/deno.zip + echo "${sha} /tmp/deno.zip" | sha256sum -c - + unzip -q -o /tmp/deno.zip -d /usr/local/bin + deno --version + - name: Startbestand-Werkzeug testen + run: deno test --allow-read=supabase supabase/catalog/ + - name: Erzeugte Katalogdateien aktuell? + run: | + git config --global --add safe.directory "$GITHUB_WORKSPACE" + deno run --allow-read=supabase --allow-write=supabase supabase/catalog/build_startbestand.ts + if ! git diff --exit-code -- supabase/catalog supabase/tests/support; then + echo "::error::Erzeugte Dateien sind nicht aktuell. Lokal 'deno run --allow-read=supabase --allow-write=supabase supabase/catalog/build_startbestand.ts' ausführen und committen." + exit 1 + fi + untracked=$(git ls-files --others --exclude-standard -- supabase/catalog supabase/tests/support) + if [ -n "$untracked" ]; then echo "Nicht eingecheckte erzeugte Dateien:"; echo "$untracked"; exit 1; fi + - name: startbestand-keys.csv wächst nur + if: github.event_name == 'pull_request' + run: | + # Jede Zeile der Schlüsselliste aus dem Basis-Branch muss unverändert erhalten bleiben (Schlüssel sind unveränderlich). + git fetch origin "${{ github.base_ref }}" --depth=1 + if git cat-file -e "origin/${{ github.base_ref }}:supabase/catalog/startbestand-keys.csv" 2>/dev/null; then + git show "origin/${{ github.base_ref }}:supabase/catalog/startbestand-keys.csv" > /tmp/keys-base.csv + missing=$(grep -vxFf supabase/catalog/startbestand-keys.csv /tmp/keys-base.csv || true) + if [ -n "$missing" ]; then echo "::error::Zeilen aus startbestand-keys.csv fehlen oder wurden geändert:"; echo "$missing"; exit 1; fi + fi + # RLS-/pgTAP-Tests: braucht einen Runner mit Docker-Zugriff (Label "docker-host"). # Der Job-Container hat kein Docker-CLI und keinen Socket (Isolation, Repo ist öffentlich). Stattdessen # wird ein gepinntes Docker-CLI installiert und der dind-Daemon des Runners per TCP angesprochen @@ -180,10 +218,31 @@ jobs: test -f supabase/config.toml || { echo "supabase/config.toml fehlt"; exit 1; } test -d supabase/migrations || { echo "Keine Migrationen – übersprungen"; exit 0; } npx --yes supabase@${{ env.SUPABASE_CLI_VERSION }} start -x studio,imgproxy,edge-runtime,logflare,vector + - name: Katalog-Vokabular eingespielt? (KAT-05) + # Schlägt fehl, wenn der Seed (sql_paths) das Vokabular nicht geladen hat, zum Beispiel wegen eines Pfad-Tippfehlers + # (die CLI überspringt eine fehlende Datei nur mit Warnung). IDs werden aus vocabulary.csv abgeleitet. + run: | + expected=$(awk -F, 'NR>1 && NF>=4 {printf "%s(\x27%s\x27,\x27%s\x27)", sep, $1, $2; sep=","}' supabase/catalog/vocabulary.csv) + test -n "$expected" || { echo "vocabulary.csv ist leer"; exit 1; } + missing=$(docker exec -i supabase_db_shuffle psql -U postgres -d postgres -At -v ON_ERROR_STOP=1 <_id`; er speichert die `id` der Zielzeile. Spaltennamen sind Englisch (Konvention aus CLAUDE.md), die Namen der Statuswerte sind mein Vorschlag. +R1 braucht 20 Tabellen in vier Bereichen (17 aus der Grundplanung, dazu `catalog_sources`, `publishers` und `game_publishers` aus KAT-05; mit `game_reports` in KAT-05 PR 2c werden es 21). Jede Tabelle hat einen eigenen Primärschlüssel `id` (uuid, PK) sowie `created_at` und `updated_at`; diese drei Spalten stehen in den Tabellen unten nicht mehr einzeln. `updated_at` setzt ein Datenbank-Trigger bei jeder Änderung, nie der Client. Ein Fremdschlüssel (FK) steht nur dort, wo zwei Tabellen in Beziehung stehen, und heißt `_id`; er speichert die `id` der Zielzeile. Spaltennamen sind Englisch (Konvention aus CLAUDE.md), die Namen der Statuswerte sind mein Vorschlag. ### Konto und Profil @@ -42,7 +42,9 @@ R1 braucht 17 Tabellen in vier Bereichen. Jede Tabelle hat einen eigenen Primär | Tabelle | Spalten | Zweck | | --- | --- | --- | -| `games` | `owner_id` (FK → `profiles`, nur bei privaten Spielen), `title`, `description`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `bgg_id` (optional, eindeutig), `visibility` (`catalog` oder `private`) | Ein Eintrag je Spiel; private Einträge sind vorläufig und nur für den Ersteller sichtbar (O5) | +| `games` | `owner_id` (FK → `profiles`, nur bei privaten Spielen), `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `bgg_id` (optional, eindeutig), `visibility` (`catalog` oder `private`), `hidden_at`, `merged_into_id` (FK → `games`), `manual_fields`, `catalog_source_id` (FK → `catalog_sources`), `source_ref`, `source_url`, `source_synced_at` | Ein Eintrag je Spiel; private Einträge sind vorläufig und nur für den Ersteller sichtbar (O5). Die letzten sieben Spalten gehören der Katalogpflege (KAT-05), siehe unten | +| `catalog_sources` | `key`, `name`, `attribution_text`, `attribution_url`, `images_allowed`, `texts_allowed`, `terms_url`, `terms_checked_at` | Datenquellen des Katalogs mit Anzeige-Erlaubnis für Bilder und Texte je Quelle (KAT-05); die Migration legt `bgg` ohne Erlaubnis an | +| `publishers`, `game_publishers` | `name` (eindeutig ohne Beachtung der Schreibweise); `game_id`, `publisher_id` | Verlage und ihre Zuordnung zu Spielen (Klärung 4) | | `game_variants` | `game_id` (FK → `games`), `name`, `year`, `language`, `bgg_id` (optional) | Editionen und Varianten eines Spiels (O4) | | `genres`, `mechanics`, `categories` | `name_de`, `name_en` | Einheitliche Begriffe für Filter und Spieldetails | | `game_genres` | `game_id` (FK → `games`), `genre_id` (FK → `genres`) | Zuordnung Spiel zu Genre; Paar eindeutig | @@ -73,7 +75,8 @@ Jede Tabelle hat Row Level Security (RLS) und eine Policy-Test-Suite (pgTAP). De | --- | --- | --- | | `profiles` | Eigenes Profil; fremde nur `display_name`, `avatar_path`, `city` | Nur eigenes | | `favorite_games`, `bgg_links` | Nur eigene | Nur eigene | -| `games`, `game_variants`, Begriffe | Katalog für alle (O6); private Spiele nur der Ersteller | Katalog nur Admin; private Spiele nur der Ersteller | +| `games`, `game_variants`, Begriffe | Katalog für alle (O6); private Spiele nur der Ersteller | Katalog nur Admin (in R1 nur über Studio/SQL, E10); private Spiele nur der Ersteller, und nur die Spalten laut Spaltenrechten | +| `catalog_sources`, `publishers`, `game_publishers`, View `catalog_games` | für alle, auch ohne Anmeldung (`game_publishers` folgt dem Spiel) | Quellen und Verlage nur Admin; `game_publishers` Admin oder Ersteller des privaten Spiels; die View ist nicht beschreibbar | | `collection_items`, `score_sheets`, `score_categories` | Nur eigene | Nur eigene | | `plays` | Ersteller und eingeladene Mitspieler (offen oder bestätigt) | Nur Ersteller | | `play_players`, `play_scores` | Wie die zugehörige Partie | Nur Ersteller der Partie; Mitspieler bestätigt oder lehnt nur den eigenen Eintrag ab | @@ -83,7 +86,7 @@ Dazu kommen diese Regeln: - **Mindestalter 16:** Die Registrierung verlangt eine Bestätigung (`age_confirmed_at`). Mehr prüfen wir nicht. - **Katalog ohne Anmeldung:** Lesbar, nie änderbar. - **Konto löschen (DSGVO):** Löscht Profil, Sammlung, Wertungsbögen und Avatar. Partien bleiben für andere Mitspieler erhalten, der gelöschte Nutzer erscheint dort als „Gelöschter Nutzer“. Entschieden am 2. November 2026. -- **Admin-Rolle:** Nur für Katalogpflege, serverseitig, nie aus dem Client heraus. +- **Admin-Rolle:** Nur für Katalogpflege, serverseitig, nie aus dem Client heraus. In R1 wird sie niemandem vergeben; Chris pflegt über Studio (Datenbankrolle `postgres`, KAT-05 E10). ### Regeln bei der Registrierung (Migration `auth_registration_rules`, KTO-01) @@ -189,9 +192,34 @@ Diese Struktur ist aus den fertigen Migrationen in `supabase/migrations/` ausgel | `image_path` | text | nein | | | | `bgg_id` | integer | nein | | | | `visibility` | text | ja | `'private'` | | +| `description_locale` | text | nein | | `de` oder `en`, nur zusammen mit `description` | +| `hidden_at` | timestamptz | nein | | gesetzt = ausgeblendet | +| `merged_into_id` | uuid | nein | | FK → games (`on delete restrict`), nur auf Katalogspiele; nur mit `hidden_at` | +| `manual_fields` | text[] | ja | `'{}'` | von Hand gepflegte Felder (Trigger), nur bekannte Schlüssel | +| `catalog_source_id` | uuid | nein | | FK → catalog_sources (`on delete restrict`) | +| `source_ref` | text | nein | | Kennung in der Quelle; genau dann gesetzt, wenn `catalog_source_id` gesetzt ist | +| `source_url` | text | nein | | `https://`, nur mit Quelle | +| `source_synced_at` | timestamptz | nein | | letzter Abgleich, nur mit Quelle | | `created_at` | timestamptz | ja | `now()` | | | `updated_at` | timestamptz | ja | `now()` | | +**`catalog_sources`** + +| Spalte | Typ | Pflicht | Standard | Schlüssel | +| --- | --- | --- | --- | --- | +| `id` | uuid | ja | `gen_random_uuid()` | PK | +| `key` | text | ja | | eindeutig, `^[a-z0-9_]{2,30}$` | +| `name` | text | ja | | | +| `attribution_text` | text | nein | | | +| `attribution_url` | text | nein | | `https://` | +| `images_allowed` | boolean | ja | `false` | | +| `texts_allowed` | boolean | ja | `false` | | +| `terms_url` | text | nein | | `https://` | +| `terms_checked_at` | date | nein | | | +| `created_at`, `updated_at` | timestamptz | ja | `now()` | | + +**`publishers`** hat `id`, `name` (1 bis 200 Zeichen, eindeutig ohne Beachtung der Schreibweise), `created_at`, `updated_at`. **`game_publishers`** verbindet wie die anderen Zuordnungen ein Spiel (`game_id`, FK → games, `on delete cascade`) mit einem Verlag (`publisher_id`, FK → publishers, `on delete cascade`); das Paar ist eindeutig. + **`game_variants`** | Spalte | Typ | Pflicht | Standard | Schlüssel | @@ -307,224 +335,17 @@ Diese Struktur ist aus den fertigen Migrationen in `supabase/migrations/` ausgel | `created_at` | timestamptz | ja | `now()` | | | `updated_at` | timestamptz | ja | `now()` | | -Zwei Ansichten (Views) kommen dazu: `public_profiles` zeigt Mitspielern Name, Avatar und freigegebene Stadt, `my_game_stats` liefert die Statistik je Spiel für den angemeldeten Nutzer. +Drei Ansichten (Views) kommen dazu: `public_profiles` zeigt Mitspielern Name, Avatar und freigegebene Stadt, `my_game_stats` liefert die Statistik je Spiel für den angemeldeten Nutzer, `catalog_games` zeigt Suche und Entdecken die sichtbaren Katalogspiele (siehe nächster Abschnitt). +## Katalogpflege (KAT-05, Migration `catalog_foundation`) -## Tabellenstruktur (aus den Migrationen) +Die Pflege läuft in R1 nur über Supabase Studio bzw. `psql` als Datenbankrolle `postgres` (Klärungen 25/44, Ablauf: `docs/katalog-pflege.md`). Spec: `docs/specs/2-katalog-daten.md`. -### Konto und Profil - -**`profiles`** - -| Spalte | Typ | Pflicht | Standard | Schlüssel | -| --- | --- | --- | --- | --- | -| `id` | uuid | ja | | PK, FK → auth.users | -| `display_name` | text | ja | | | -| `bio` | text | nein | | | -| `city` | text | nein | | | -| `city_visible` | boolean | ja | `true` | | -| `avatar_path` | text | nein | | | -| `locale` | text | ja | `'en'::text` | | -| `terms_accepted_at` | timestamptz | ja | | | -| `terms_version` | text | nein (Altdaten) | | | -| `age_confirmed_at` | timestamptz | ja | | | -| `deleted_at` | timestamptz | nein | | | -| `created_at` | timestamptz | ja | `now()` | | -| `updated_at` | timestamptz | ja | `now()` | | - -**`favorite_games`** - -| Spalte | Typ | Pflicht | Standard | Schlüssel | -| --- | --- | --- | --- | --- | -| `id` | uuid | ja | `gen_random_uuid()` | PK | -| `profile_id` | uuid | ja | | FK → profiles | -| `game_id` | uuid | ja | | FK → games | -| `position` | smallint | ja | | | -| `created_at` | timestamptz | ja | `now()` | | -| `updated_at` | timestamptz | ja | `now()` | | - -**`bgg_links`** - -| Spalte | Typ | Pflicht | Standard | Schlüssel | -| --- | --- | --- | --- | --- | -| `id` | uuid | ja | `gen_random_uuid()` | PK | -| `profile_id` | uuid | ja | | FK → profiles | -| `bgg_username` | text | ja | | | -| `last_import_at` | timestamptz | nein | | | -| `created_at` | timestamptz | ja | `now()` | | -| `updated_at` | timestamptz | ja | `now()` | | - -### Katalog - -**`games`** - -| Spalte | Typ | Pflicht | Standard | Schlüssel | -| --- | --- | --- | --- | --- | -| `id` | uuid | ja | `gen_random_uuid()` | PK | -| `owner_id` | uuid | nein | | FK → profiles | -| `title` | text | ja | | | -| `description` | text | nein | | | -| `year` | smallint | nein | | | -| `min_players` | smallint | nein | | | -| `max_players` | smallint | nein | | | -| `min_minutes` | smallint | nein | | | -| `max_minutes` | smallint | nein | | | -| `min_age` | smallint | nein | | | -| `image_path` | text | nein | | | -| `bgg_id` | integer | nein | | | -| `visibility` | text | ja | `'private'::text` | | -| `created_at` | timestamptz | ja | `now()` | | -| `updated_at` | timestamptz | ja | `now()` | | - -**`game_variants`** - -| Spalte | Typ | Pflicht | Standard | Schlüssel | -| --- | --- | --- | --- | --- | -| `id` | uuid | ja | `gen_random_uuid()` | PK | -| `game_id` | uuid | ja | | FK → games | -| `name` | text | ja | | | -| `year` | smallint | nein | | | -| `language` | text | nein | | | -| `bgg_id` | integer | nein | | | -| `created_at` | timestamptz | ja | `now()` | | -| `updated_at` | timestamptz | ja | `now()` | | - -**`genres`** - -| Spalte | Typ | Pflicht | Standard | Schlüssel | -| --- | --- | --- | --- | --- | -| `id` | uuid | ja | `gen_random_uuid()` | PK | -| `name_de` | text | ja | | | -| `name_en` | text | ja | | | -| `created_at` | timestamptz | ja | `now()` | | -| `updated_at` | timestamptz | ja | `now()` | | - -**`mechanics`** - -| Spalte | Typ | Pflicht | Standard | Schlüssel | -| --- | --- | --- | --- | --- | -| `id` | uuid | ja | `gen_random_uuid()` | PK | -| `name_de` | text | ja | | | -| `name_en` | text | ja | | | -| `created_at` | timestamptz | ja | `now()` | | -| `updated_at` | timestamptz | ja | `now()` | | - -**`categories`** - -| Spalte | Typ | Pflicht | Standard | Schlüssel | -| --- | --- | --- | --- | --- | -| `id` | uuid | ja | `gen_random_uuid()` | PK | -| `name_de` | text | ja | | | -| `name_en` | text | ja | | | -| `created_at` | timestamptz | ja | `now()` | | -| `updated_at` | timestamptz | ja | `now()` | | - -**`game_genres`** - -| Spalte | Typ | Pflicht | Standard | Schlüssel | -| --- | --- | --- | --- | --- | -| `id` | uuid | ja | `gen_random_uuid()` | PK | -| `game_id` | uuid | ja | | FK → games | -| `genre_id` | uuid | ja | | FK → genres | -| `created_at` | timestamptz | ja | `now()` | | -| `updated_at` | timestamptz | ja | `now()` | | - -**`game_mechanics`** - -| Spalte | Typ | Pflicht | Standard | Schlüssel | -| --- | --- | --- | --- | --- | -| `id` | uuid | ja | `gen_random_uuid()` | PK | -| `game_id` | uuid | ja | | FK → games | -| `mechanic_id` | uuid | ja | | FK → mechanics | -| `created_at` | timestamptz | ja | `now()` | | -| `updated_at` | timestamptz | ja | `now()` | | - -**`game_categories`** - -| Spalte | Typ | Pflicht | Standard | Schlüssel | -| --- | --- | --- | --- | --- | -| `id` | uuid | ja | `gen_random_uuid()` | PK | -| `game_id` | uuid | ja | | FK → games | -| `category_id` | uuid | ja | | FK → categories | -| `created_at` | timestamptz | ja | `now()` | | -| `updated_at` | timestamptz | ja | `now()` | | -### Sammlung - -**`collection_items`** - -| Spalte | Typ | Pflicht | Standard | Schlüssel | -| --- | --- | --- | --- | --- | -| `id` | uuid | ja | `gen_random_uuid()` | PK | -| `owner_id` | uuid | ja | | FK → profiles | -| `game_id` | uuid | ja | | FK → games | -| `variant_id` | uuid | nein | | FK → game_variants (zusammen mit game_id) | -| `list` | text | ja | `'owned'::text` | | -| `note` | text | nein | | | -| `acquired_on` | date | nein | | | -| `created_at` | timestamptz | ja | `now()` | | -| `updated_at` | timestamptz | ja | `now()` | | - -### Partien und Wertung - -**`score_sheets`** - -| Spalte | Typ | Pflicht | Standard | Schlüssel | -| --- | --- | --- | --- | --- | -| `id` | uuid | ja | `gen_random_uuid()` | PK | -| `owner_id` | uuid | ja | | FK → profiles | -| `game_id` | uuid | nein | | FK → games | -| `name` | text | ja | | | -| `created_at` | timestamptz | ja | `now()` | | -| `updated_at` | timestamptz | ja | `now()` | | - -**`score_categories`** - -| Spalte | Typ | Pflicht | Standard | Schlüssel | -| --- | --- | --- | --- | --- | -| `id` | uuid | ja | `gen_random_uuid()` | PK | -| `sheet_id` | uuid | ja | | FK → score_sheets | -| `name` | text | ja | | | -| `position` | smallint | ja | | | -| `created_at` | timestamptz | ja | `now()` | | -| `updated_at` | timestamptz | ja | `now()` | | - -**`plays`** - -| Spalte | Typ | Pflicht | Standard | Schlüssel | -| --- | --- | --- | --- | --- | -| `id` | uuid | ja | `gen_random_uuid()` | PK | -| `owner_id` | uuid | nein | | FK → profiles | -| `game_id` | uuid | ja | | FK → games | -| `variant_id` | uuid | nein | | FK → game_variants (zusammen mit game_id) | -| `sheet_id` | uuid | nein | | FK → score_sheets | -| `played_at` | timestamptz | ja | | | -| `duration_minutes` | integer | nein | | | -| `note` | text | nein | | | -| `client_id` | uuid | ja | | | -| `created_at` | timestamptz | ja | `now()` | | -| `updated_at` | timestamptz | ja | `now()` | | - -**`play_players`** - -| Spalte | Typ | Pflicht | Standard | Schlüssel | -| --- | --- | --- | --- | --- | -| `id` | uuid | ja | `gen_random_uuid()` | PK | -| `play_id` | uuid | ja | | FK → plays | -| `profile_id` | uuid | nein | | FK → profiles | -| `guest_name` | text | nein | | | -| `link_status` | text | ja | `'none'::text` | | -| `place` | smallint | nein | | | -| `total_score` | numeric | nein | | | -| `created_at` | timestamptz | ja | `now()` | | -| `updated_at` | timestamptz | ja | `now()` | | - -**`play_scores`** - -| Spalte | Typ | Pflicht | Standard | Schlüssel | -| --- | --- | --- | --- | --- | -| `id` | uuid | ja | `gen_random_uuid()` | PK | -| `play_player_id` | uuid | ja | | FK → play_players | -| `category_id` | uuid | ja | | FK → score_categories | -| `value` | numeric | ja | | | -| `created_at` | timestamptz | ja | `now()` | | -| `updated_at` | timestamptz | ja | `now()` | | +- **Ausblenden:** `games.hidden_at` gesetzt = ausgeblendet. Die View `catalog_games` enthält nur Katalogspiele (`visibility = 'catalog'`) ohne `hidden_at` und ohne `merged_into_id`. **Suche und Entdecken lesen nur diese View.** Ein ausgeblendetes Spiel bleibt per ID lesbar (Sammlungen, Partien und Detailseite verweisen darauf). +- **Zusammenführen:** `games.merged_into_id` zeigt auf das Zielspiel (Spalte seit 2a; die Funktion `admin_merge_games` kommt mit PR 2b). Ein Trigger lässt nur Katalogspiele als Ziel zu (Fehler `merged_into_not_catalog`). +- **Manuell gepflegte Felder:** Der Trigger `games_mark_manual_fields` trägt jedes Feld eines Katalogspiels, das ein Mensch setzt oder ändert, in `manual_fields` ein (beim Anlegen alle gesetzten Felder). Der Trigger `game_links_mark_manual` ergänzt `genres`, `mechanics`, `categories` und `publishers`, wenn Zuordnungen angelegt, geändert oder gelöscht werden. Ausgenommen ist nur ein späterer Import: Rolle `catalog_importer` **und** Einstellung `shuffle.catalog_import = 'on'` (der Import setzt sie per `set local`; das ist Konvention, die Rolle ist die Sperre). Private Spiele werden nicht gekennzeichnet. Eine bewusst entfernte Markierung entfernt der Trigger nicht wieder. +- **Anzeige-Erlaubnis:** `catalog_games.description` und `image_path` sind leer, wenn die Quelle (`catalog_sources`) Texte bzw. Bilder nicht erlaubt und das Feld nicht in `manual_fields` steht. Das ist nur die zweite Sicherung: Daten ohne Erlaubnis dürfen gar nicht erst gespeichert werden (Pflicht für den Import). +- **Spaltenrechte auf `games`:** Nutzer dürfen nur `owner_id` (nur beim Anlegen), `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `bgg_id` und `visibility` schreiben, nicht `id`, `hidden_at`, `merged_into_id`, `manual_fields`, `catalog_source_id` und `source_*`. +- **Startbestand:** `supabase/catalog/` (CSV, Werkzeug, erzeugte SQL-Dateien); `supabase db reset` spielt Vokabular und Startbestand ein. Der Startbestand enthält nur Fakten und einen eigenen deutschen Kurztext, kein Cover, keine Quelle, keine BGG-Kennung. +- **Konto löschen:** Neue Nutzerdaten entstehen nicht; die Zuordnungen privater Spiele (`game_publishers` usw.) werden mit dem Spiel gelöscht. +- **API-Änderungen für das Frontend:** neu lesbar für alle: View `catalog_games`, Tabellen `catalog_sources`, `publishers`, `game_publishers`. Beim Anlegen und Ändern eines privaten Spiels dürfen Clients keine `id`, `created_at` und `updated_at` mehr mitsenden (42501; die ID kommt per `returning`); ein `toJson()` eines Modells darf nie ungefiltert an `insert`/`update` gehen (Merkpunkt für #3 und #13). Der `is_admin()`-Zweig der Zuordnungs-Policies (`game_*_write`) greift für Katalogspiele faktisch nicht mehr, weil der Trigger `game_links_mark_manual` `manual_fields` schreiben muss; ein späterer Admin-Pfad (QUE-08) braucht eigene RPCs. Es gibt keine generierten Datenbanktypen im Repo; das Domänenmodell `CatalogGame` legt KAT-01 (#3) auf Basis der Spalten von `catalog_games` an. diff --git a/docs/katalog-pflege.md b/docs/katalog-pflege.md new file mode 100644 index 0000000..ea3d551 --- /dev/null +++ b/docs/katalog-pflege.md @@ -0,0 +1,205 @@ +# Katalog pflegen (Runbook, KAT-05) + +Dieses Dokument beschreibt, wie der Admin (Chris) den Katalog in R1 pflegt: ausschließlich über Supabase Studio bzw. `psql`, es gibt keine Admin-Oberfläche in der App (Klärungen 25/44). Grundlage ist die Spec `docs/specs/2-katalog-daten.md`. + +Stand: PR 2a (Datenmodell, Startbestand, Ausblenden). Die Abschnitte **Zusammenführen** (PR 2b) und **Meldungen bearbeiten** (PR 2c) kommen mit den jeweiligen PRs dazu. + +**Die SQL-Bausteine hier laufen unverändert in den pgTAP-Tests** (`supabase/tests/database/06_catalog_foundation.test.sql`, Kommentare mit Abschnittsverweis). Ändert sich ein Baustein, muss der Test mitgezogen werden, damit das Runbook nicht veraltet. Die Bausteine stehen ohne `begin;`/`commit;`, im Studio bzw. in `psql` wird alles in eine Transaktion gepackt (siehe 1). + +## 1. Zugang + +- Studio läuft als Datenbankrolle `postgres` (bzw. `supabase_admin`) und **umgeht Row Level Security**. Wer Studio erreicht, kann alles lesen und ändern, auch private Nutzerdaten. Mit `select current_user;` im SQL-Editor prüfen, unter welcher Rolle man arbeitet. +- **Nicht öffentlich erreichbar** sein dürfen: Studio, der Postgres-Port 5432, der Pooler (Supavisor, Standard 6543) und die Kong-Route `/pg/` (pg-meta). Zugriff nur über SSH-Tunnel oder VPN; die Basic-Auth des Gateways (`DASHBOARD_USERNAME`/`DASHBOARD_PASSWORD`) bleibt mit starkem Passwort als zweite Schicht. Gilt ebenso für Staging. (Einrichtung und Bestätigung: Aufgabe D1, devops.) +- **Immer in einer Transaktion arbeiten:** `begin; … commit;` (bei Fehler `rollback;`). Vor größeren Änderungen den Stand des Backups prüfen. +- **Niemals `set …` ohne `local` verwenden** (zum Beispiel `set shuffle.catalog_import`): Ohne `local` gilt die Einstellung für die ganze Sitzung. Der Import-Schalter `shuffle.catalog_import` ist ohnehin nur für die Rolle `catalog_importer` wirksam und gehört nicht ins Studio. Hinweis: `set local` statt `set` ist eine Konvention des Imports; ein sitzungsweites `set` bleibt hinter einem Pooler bis zum Ende der Verbindung bestehen und kann auf die nächste Anfrage durchschlagen. +- Die Rolle `app_metadata.role = 'admin'` (JWT) wird in R1 **niemandem vergeben** (siehe 7). + +## 2. Startbestand pflegen, einspielen und abnehmen + +Der Startbestand sind etwa 200 Spiele (mindestens 180) mit **nur Fakten**: Titel, Verlag, Erscheinungsjahr, Spielerzahl, Spieldauer, Mindestalter, Genre, Mechanik, dazu ein eigener deutscher Kurztext. Kein Cover, keine fremden Beschreibungstexte (Krit. 7 und 10, Klärung N11). + +### 2.1 Regeln (SB1–SB8) + +| # | Regel | +|---|---| +| SB1 | Zum Go-live mindestens 180 sichtbare Katalogspiele aus dem Startbestand (Ziel etwa 200). | +| SB2 | Jedes Spiel hat alle Pflicht-Fakten: Titel, mindestens ein Verlag, Jahr, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, mindestens ein Genre, mindestens eine Mechanik. | +| SB3 | Herkunft der Fakten: Verlagsseiten, Spielschachtel, eigene Kenntnis, ergänzend Wikidata (CC0). **Nicht** systematisch aus BGG oder einer anderen fremden Datenbank übernehmen. Die Spalte `facts_source` in der CSV hält die Herkunft fest, sie landet nicht in der Datenbank. | +| SB4 | Jedes Spiel hat einen eigenen deutschen Kurztext (`description_locale` = `de`), geschrieben oder geprüft und freigegeben von Chris. KI-Entwürfe nur aus den Fakten, nie aus fremden Texten (auch nicht umformuliert). | +| SB5 | Genres und Mechaniken nur aus `supabase/catalog/vocabulary.csv` (Freigabe durch Chris, C0). | +| SB6 | Kein Cover, keine Quelle, keine BGG-Kennung. Die CSV hat dafür keine Spalten. | +| SB7 | Erweiterungen sind erlaubt; `base_game_key` merkt das Basisspiel vor (wird nur auf Existenz geprüft, bis KAT-03 die Verknüpfung baut). | +| SB8 | Alle gesetzten Felder gelten als manuell gepflegt (`manual_fields`); das erledigt der Trigger beim Einspielen. | + +### 2.2 Dateien + +| Datei | Art | Inhalt | +|---|---|---| +| `supabase/catalog/vocabulary.csv` | von Hand | Spalten `kind` (`genre`/`mechanic`), `vocab_key`, `name_de`, `name_en` | +| `supabase/catalog/startbestand.csv` | von Hand | die Spiele (Spalten siehe 2.3) | +| `supabase/catalog/build_startbestand.ts` | Werkzeug | prüft beide CSV-Dateien und erzeugt die Dateien darunter | +| `supabase/catalog/vocabulary.sql`, `startbestand.sql`, `startbestand-keys.csv`, `checks.sql` | **erzeugt**, nie von Hand ändern | Einspiel-Skripte, Schlüsselliste, Prüfabfrage | +| `supabase/catalog/testdata/` und `supabase/tests/support/*.inc` | Testdaten (fiktiv) und daraus erzeugt | nur für pgTAP | + +### 2.3 CSV-Format `startbestand.csv` + +Kopfzeile ist Pflicht, Trennzeichen Komma, Felder mit Komma oder Zeilenumbruch in Anführungszeichen (Tabellenkalkulationen exportieren das korrekt). Listen innerhalb eines Feldes trennt `;`. + +| Spalte | Pflicht | Inhalt | +|---|---|---| +| `key` | ja | stabiler Schlüssel, `^[a-z0-9-]{2,60}$`, eindeutig, **unveränderlich** | +| `title` | ja | Haupttitel | +| `publishers` | ja | ein oder mehrere Verlage, getrennt durch `;` | +| `year` | ja | Erscheinungsjahr (1800–2100) | +| `min_players`, `max_players` | ja | Spielerzahl (`max` nicht kleiner als `min`) | +| `min_minutes`, `max_minutes` | ja | Spieldauer in Minuten | +| `min_age` | ja | Mindestalter | +| `genres` | ja | Genres aus dem Vokabular (`name_de`), getrennt durch `;` | +| `mechanics` | ja | Mechaniken aus dem Vokabular (`name_de`), getrennt durch `;` | +| `description` | ja | eigener Kurztext (SB4), höchstens 5000 Zeichen | +| `description_locale` | ja | immer `de` | +| `hidden` | nein | `ja` = ausblenden; leer = `hidden_at` bleibt unverändert | +| `facts_source` | nein | Herkunftsnachweis für SB3; nicht in der Datenbank | +| `alt_titles` | nein | reserviert für KAT-01 (`de:Titel;en:Title`), wird ignoriert | +| `base_game_key` | nein | reserviert für KAT-03: `key` des Basisspiels (SB7) | + +Andere Spalten (Cover, Quelle, BGG-Kennung) lehnt das Werkzeug ab. + +### 2.4 Ablauf: pflegen, prüfen, erzeugen, committen + +```bash +# Deno wird für das Werkzeug gebraucht (https://deno.com, getestet mit 2.9.8) +deno run --allow-read=supabase --allow-write=supabase supabase/catalog/build_startbestand.ts +deno test --allow-read=supabase supabase/catalog/ +git add supabase/catalog supabase/tests/support && git commit +``` + +- Das Werkzeug bricht mit **Dateiname:Zeile und Grund** ab, wenn etwas nicht stimmt (fehlende Pflichtangabe, unbekanntes Genre oder unbekannte Mechanik, doppelter Schlüssel, `max < min`, Wert außerhalb der Datenbank-Grenzen, fehlender Kurztext, Sprache ≠ `de`, `base_game_key` ohne passenden Schlüssel, entfernter Schlüssel). Dabei wird nichts geschrieben. +- **Schlüssel nie umbenennen oder entfernen.** Jeder Schlüssel ergibt eine feste Spiel-ID (UUID v5, Namespace unten); `startbestand-keys.csv` hält sie fest und wächst nur (CI vergleicht mit `main`). Ein falsch geschriebener Schlüssel bleibt stehen, nur der Titel wird korrigiert. +- **Spiel entfernen:** Zeile bleibt in der CSV, `hidden` = `ja`. Gelöscht wird nie (Verweise aus Sammlungen bleiben gültig). +- **Wieder einblenden** geht nur im Studio (`hidden_at = null`, siehe 4): Ist `hidden` leer, fasst die Datei `hidden_at` nicht an. +- **UUID-v5-Namespace** (fest, nie ändern): `6f1d9a52-3c4e-4b8a-9f27-5d0e8c71a3b4`. Namen: Spiel `game:`, Genre `genre:`, Mechanik `mechanic:`. Verlage haben keine abgeleitete ID (sie werden über `lower(name)` aufgelöst, damit im Studio angelegte Verlage nicht stören). +- Das Vokabular kann in `vocabulary.csv` um Begriffe wachsen. Begriffe umbenennen: nicht in der CSV (die ID ist an `vocab_key` gebunden), sondern im Studio. + +### 2.5 Einspielen + +**Reihenfolge immer: Migrationen, `vocabulary.sql`, `startbestand.sql`.** Beide Dateien sind idempotent und dürfen **bis zum Go-live** wiederholt laufen; unveränderte Zeilen werden nicht angefasst. + +| Umgebung | Weg | +|---|---| +| lokal, CI | `supabase db reset` (`supabase/config.toml`, `[db.seed] sql_paths`: `seed.sql`, `catalog/vocabulary.sql`, `catalog/startbestand.sql`) | +| Staging, Produktion | per Tunnel/VPN: `psql "$DB_URL" -v ON_ERROR_STOP=1 --single-transaction -f supabase/catalog/vocabulary.sql -f supabase/catalog/startbestand.sql` (alternativ Inhalt im Studio-SQL-Editor ausführen) | + +- `startbestand.sql` bricht mit einer Fehlermeldung ab, wenn ein Genre oder eine Mechanik in der Datenbank fehlt (Vokabular nicht eingespielt oder im Studio umbenannt). Es löscht dann nichts. +- **Kollision mit im Studio angelegten Begriffen:** `vocabulary.sql` fügt mit `on conflict do nothing` ein, der vorhandene Begriff gewinnt; `startbestand.sql` löst Genres und Mechaniken über `name_de` auf. `checks.sql` meldet solche Begriffe als Information. +- **Ab dem Go-live ist `startbestand.sql` eingefroren** und wird nicht mehr eingespielt (sonst überschreibt es Korrekturen aus dem Studio). Korrekturen danach nur noch im Studio (siehe 3). + +### 2.6 Abnehmen: `checks.sql` + +```bash +psql "$DB_URL" -f supabase/catalog/checks.sql # oder Inhalt im Studio-SQL-Editor +``` + +Je Regel eine Zeile (Soll, Ist, Status `ok`/`Verstoß`/`Information`). **Abnahme (Krit. 7 und 10):** SB1 meldet mindestens 180 sichtbare Spiele, SB2, SB4 und SB6 melden 0. Die Abfrage betrachtet nur Spiele aus `startbestand-keys.csv`; im Studio angelegte Spiele und das Beispielspiel aus `seed.sql` (nur lokal) zählen nicht. Lokal und in CI ist SB1 nur eine Information, solange noch eingepflegt wird. Die Herkunft der Fakten (SB3) und der Kurztexte (SB4) bestätigt Chris selbst; die rechtliche Prüfung folgt vor dem Release. + +### 2.7 Ergebnis der Prüfung der CLI-Annahmen (Spec R11, Supabase CLI 3.0.0, geprüft am 2026-11-03) + +| Annahme | Ergebnis | +|---|---| +| `db reset` lädt alle Dateien aus `sql_paths` in der angegebenen Reihenfolge | **bestätigt** (Ausgabe `Seeding data from supabase/seed.sql…`, danach die weiteren Dateien in Listenreihenfolge) | +| `begin;`/`commit;` in einer Seed-Datei ist zulässig | **bestätigt**: Die Anweisungen laufen, die Daten sind danach da. Trotzdem enthalten die erzeugten Dateien keine, weil die CLI jede Datei als Batch sendet (siehe nächste Zeile) und ein Fehler sie ganz zurückrollt; auf Staging/Prod sorgt `--single-transaction` für dasselbe. | +| Fehler in einer Seed-Datei | Die Datei wird **als Ganzes zurückgerollt** (eine Anweisung vor dem Fehler blieb nicht erhalten), `db reset` endet mit Exit-Code 1 und `MigrationSeedError`. | +| Erzeugte Dateien mit echtem Inhalt (Wächter-`do`-Block, Zuordnungs-CTEs) über den Seed-Lader der CLI | **bestätigt**: `db reset` endet mit Exit 0, danach sind 3 Testspiele und je 4 Genre-, Mechanik- und Verlagszuordnungen da. Der Wächter löst bei einem umbenannten Genre den Fehler `startbestand: Genre fehlt in der Datenbank …` aus und lässt alle Zuordnungen unverändert. | +| Pfad ohne Treffer in `sql_paths` | Nur die Warnung `no files matched pattern: …`, **kein Fehler**, Exit-Code 0. Ein Tippfehler im Pfad überspringt die Datei also still; nach dem Reset `checks.sql` bzw. die Zeilenzahlen ansehen. | +| Datei, die nur Kommentare enthält | läuft ohne Fehler | +| psql-Metabefehle (`\i` …) in Seed-Dateien | nicht nutzbar (die Datei wird als SQL-Batch gesendet) | +| `supabase start` lädt Seeds nur beim ersten Start einer leeren Datenbank | **nicht geprüft** (hätte Chris' laufenden lokalen Stack stoppen müssen). Nicht darauf verlassen: nach Änderungen an Migrationen oder Seeds immer `supabase db reset`. | + +## 3. Spiel anlegen und bearbeiten + +Jede Änderung an einem Fakt eines Katalogspiels (und jede Zuordnung zu Verlag, Genre, Mechanik oder Kategorie) trägt der Trigger automatisch in `games.manual_fields` ein, damit ein späterer Import sie nicht überschreibt. Im Studio muss dafür nichts zusätzlich gepflegt werden. Als Rolle `postgres` bleibt das Kennzeichnen auch dann aktiv, wenn jemand `shuffle.catalog_import` setzt. + +**Spiel finden, bevor man es ändert.** Titel sind nicht eindeutig (ab PR 2b kann es gleichnamige Duplikate geben). Deshalb zuerst die ID nachschlagen und alle weiteren Bausteine über `id` ausführen: + +```sql +select id, title, year, hidden_at from public.games +where visibility = 'catalog' and lower(title) = lower('Beispielspiel Runbook'); +``` + +**Neues Katalogspiel mit Verlag und Genre** (Werte ersetzen; in einer Transaktion). Die ID vorher mit `select gen_random_uuid();` erzeugen und in diesem und den folgenden Bausteinen einsetzen (hier die Beispiel-ID): + +```sql +insert into public.publishers (name) values ('Beispielverlag') + on conflict ((lower(name))) do nothing; + +with neu as ( + insert into public.games (id, title, year, min_players, max_players, min_minutes, max_minutes, min_age, + description, description_locale, visibility) + values ('7a1b2c3d-0000-4000-8000-000000000001', 'Beispielspiel Runbook', 2020, 2, 4, 30, 60, 8, 'Eigener Kurztext.', 'de', 'catalog') + returning id +), verlag as ( + insert into public.game_publishers (game_id, publisher_id) + select neu.id, p.id from neu, public.publishers p where lower(p.name) = lower('Beispielverlag') +) +insert into public.game_genres (game_id, genre_id) +select neu.id, g.id from neu, public.genres g where g.name_de = 'Strategiespiel'; +``` + +Weitere Mechaniken, Kategorien und Verlage ordnet man mit `insert into public.game_mechanics (game_id, mechanic_id) …` usw. nach demselben Muster zu. Kurztext immer selbst schreiben (SB4), nie fremde Texte kopieren. Katalogspiele haben `owner_id` leer. + +**Fakten ändern:** + +```sql +update public.games set min_age = 10, max_minutes = 90 +where id = '7a1b2c3d-0000-4000-8000-000000000001' and visibility = 'catalog'; +``` + +**Markierung „manuell gepflegt“ bewusst entfernen** (nur per SQL; eine Änderung allein an `manual_fields` wird vom Trigger nicht zurückgesetzt): + +```sql +update public.games set manual_fields = array_remove(manual_fields, 'min_age') +where id = '7a1b2c3d-0000-4000-8000-000000000001' and visibility = 'catalog'; +``` + +Regeln: + +- **Katalogspiele nie löschen, sondern ausblenden** (siehe 4). Zugeordnete Sammlungen, Favoriten und Partien verweisen auf sie. +- **`merged_into_id` nie von Hand setzen**; Zusammenführen kommt mit PR 2b über die Funktion `admin_merge_games`. Der Trigger lehnt einen Verweis auf ein privates Spiel ab (`merged_into_not_catalog`). +- Private Spiele von Nutzern im Studio nicht anfassen. +- Zulässige Schlüssel in `manual_fields`: `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `genres`, `mechanics`, `categories`, `publishers`. + +## 4. Ausblenden und wieder einblenden + +Ausgeblendete Spiele (`hidden_at` gesetzt) erscheinen nicht mehr in Suche und Entdecken, weil die App Katalogdaten über die View `catalog_games` liest. Detailseite, bestehende Sammlungen, Favoriten und Partien bleiben erhalten (Spec-F2); die App zeigt dort „nicht mehr im Katalog“. + +```sql +-- ausblenden +update public.games set hidden_at = now() +where id = '7a1b2c3d-0000-4000-8000-000000000001' and visibility = 'catalog' and hidden_at is null; + +-- wieder einblenden +update public.games set hidden_at = null +where id = '7a1b2c3d-0000-4000-8000-000000000001' and visibility = 'catalog'; +``` + +Hinweis: Der Eintrag bleibt über die rohe Schnittstelle per ID lesbar (`games` ist öffentlich lesbar). Muss ein Spiel aus rechtlichen Gründen wirklich verschwinden, zusätzlich Titel und Texte inhaltlich leeren. + +## 5. Zusammenführen + +Kommt mit PR 2b. + +## 6. Meldungen bearbeiten + +Kommt mit PR 2c. + +## 7. Admin-Rolle in der App + +In R1 gibt es **kein App-Konto mit Admin-Rolle** (E10, Klärung 44). Die vorhandenen `is_admin()`-Policies (JWT `app_metadata.role = 'admin'`) bleiben für spätere Releases (QUE-08, R4) stehen und greifen in R1 nie. Falls das später nötig wird, geht es nur serverseitig (als `postgres`), der Nutzer muss sich danach neu anmelden: + +``` +update auth.users set raw_app_meta_data = raw_app_meta_data || '{"role":"admin"}' where id = ''; +``` + +**Nicht in R1 ausführen und nicht in pgTAP getestet.** Nie `user_metadata` dafür verwenden (vom Client änderbar). + +Hinweis für QUE-08 (R4): Der `is_admin()`-Zweig der Policies `game_*_write` (Genre, Mechanik, Kategorie, Verlag) funktioniert für Katalogspiele faktisch nicht mehr, weil der Trigger `game_links_mark_manual` (security invoker) `games.manual_fields` schreiben muss und `authenticated` dafür kein Spaltenrecht hat. Ein Admin-Pfad über die API braucht eigene RPCs (security definer). diff --git a/docs/specs/2-katalog-daten.md b/docs/specs/2-katalog-daten.md index 1450da8..b2e0134 100644 --- a/docs/specs/2-katalog-daten.md +++ b/docs/specs/2-katalog-daten.md @@ -690,3 +690,16 @@ Chris hat W1, W4, W6, W7, H1/H2 und H7 (§ 9.3) einzeln und ohne Änderung best ### 2026-11-03 – Umsetzung PR 2a, Flutter-Anteil - `GameCover` umgesetzt; Review-Nacharbeit: Semantics-Label folgt dem sichtbaren Zustand, zusätzlicher ARB-Schlüssel `gameCoverLabel`, Parameter `titleVisibleNearby`, `cacheWidth` nach Gerätepixeln, Hinweise für #3/#4 (5). + +### 2026-11-03 – Umsetzung PR 2a (backend-dev) + +Umgesetzt: B1, B2, B3a, B3, B4 und die beiden CI-Schritte aus D1 (Deno-Tests und „erzeugte Dateien aktuell“, „`startbestand-keys.csv` wächst nur“). Abweichungen und Ergänzungen, jeweils zur Prüfung im PR: + +- **Branch:** `feature/2a-katalog-datenmodell` statt `feature/2-katalog-fundament` (Vorgabe des Auftrags). +- **CLI-Annahmen (R11) geprüft** mit Supabase CLI 3.0.0, Ergebnis in `docs/katalog-pflege.md` 2.7: Reihenfolge und `begin`/`commit` funktionieren; ein Fehler rollt die ganze Datei zurück (Exit 1); ein Pfad ohne Treffer ist nur eine Warnung (Exit 0). Die erzeugten Dateien enthalten kein `begin`/`commit`. +- **Nicht in 2a:** `games_visibility_immutable` (2c, wie in 3.4). +- **API-Hinweis für das Frontend:** `catalog_games` ist über PostgREST nicht schreibbar (Fehler 55000 „cannot insert/update view“, zusätzlich fehlen die Rechte). Clients dürfen beim Anlegen oder Ändern von `games` keine `id`, `created_at` und `updated_at` mehr senden (42501; bisher setzte der Trigger `set_updated_at` diese Werte nur bei UPDATE stillschweigend zurück, bei INSERT wurden `id` und `created_at` vom Client übernommen). Ein `toJson()` eines Modells darf deshalb nicht unverändert an `insert`/`update` gehen, sondern nur die erlaubten Spalten (3.2); Merkpunkt für KAT-01 (#3) und KAT-04 (#13). Außerdem funktioniert der `is_admin()`-Zweig der `game_*_write`-Policies für Katalogspiele faktisch nicht mehr (der Trigger `game_links_mark_manual` braucht Schreibrecht auf `manual_fields`; R8), relevant für QUE-08. Es gibt keine generierten Datenbanktypen im Repo. +- **Ergänzung `startbestand.sql`:** Ein Wächter am Anfang bricht ab, wenn ein Genre oder eine Mechanik aus der CSV in der Datenbank fehlt; sonst würde der Abgleich der Zuordnungen still Verknüpfungen löschen. +- **Ergänzung Testdaten:** `supabase/catalog/testdata/` (fiktives Vokabular und drei Testspiele) erzeugt `supabase/tests/support/*.inc`, die `06_catalog_foundation.test.sql` per `\ir` einbindet; so laufen Idempotenz und `checks.sql` im pgTAP-Test mit den echten erzeugten Skripten. +- **`game_links_mark_manual`:** prüft zuerst per `select`, ob das Spiel ein Katalogspiel ist, und schreibt nur dann `manual_fields`. Ohne diese Prüfung scheiterte jede Zuordnung zu einem privaten Spiel mit 42501, weil Nutzer kein Schreibrecht auf `manual_fields` haben. +- **Werkzeug:** Deno wird für das Skript gebraucht (lokal 2.9.8, in CI gepinnt über `DENO_VERSION`). Keine externen Importe. diff --git a/supabase/README.md b/supabase/README.md index b805cd6..3a4af99 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -3,10 +3,11 @@ ## Inhalt | Pfad | Zweck | |---|---| -| `migrations/` | Schema, Trigger und RLS-Policies für R1 (17 Tabellen, 2 Views). Nur additiv ändern, nie bestehende Dateien editieren. | +| `migrations/` | Schema, Trigger und RLS-Policies für R1 (20 Tabellen, 3 Views). Nur additiv ändern, nie bestehende Dateien editieren. | | `tests/database/` | pgTAP-Tests: Struktur-Regeln und Zugriffsrechte je Bereich. | | `templates/` | Auth-Mailvorlagen (HTML, Go-Template) für Bestätigung und Passwort-Reset. | | `seed.sql` | Beispieldaten nur für die lokale Entwicklung. | +| `catalog/` | Katalog-Vokabular und Startbestand (CSV, Werkzeug `build_startbestand.ts`, erzeugte SQL-Dateien), siehe unten. | ## Lokal starten und testen ```bash @@ -28,12 +29,23 @@ supabase test db # alle pgTAP-Tests ausführen - Der Extra-Container für `throttle`/`spike` startet mit `e2e/support/start-throttle-gotrue.sh`. Details: [`e2e/README.md`](../e2e/README.md). - CI: Job `e2e` in `.forgejo/workflows/ci.yml` (Runner `docker-host`, dind); Ablauf in [`docs/auth-konfiguration.md`](../docs/auth-konfiguration.md) Abschnitt 7. +## Katalog (KAT-05) +- Pflege in R1 nur über Studio bzw. `psql` (Rolle `postgres`), Ablauf und Regeln: [`docs/katalog-pflege.md`](../docs/katalog-pflege.md). Spec: `docs/specs/2-katalog-daten.md`. +- Suche und Entdecken lesen nur die View `catalog_games` (ohne ausgeblendete und zusammengeführte Spiele). Geänderte Fakten trägt ein Trigger in `games.manual_fields` ein. +- `db reset` spielt `seed.sql`, `catalog/vocabulary.sql` und `catalog/startbestand.sql` ein (`[db.seed] sql_paths` in `config.toml`). Die SQL-Dateien in `catalog/` sind **erzeugt**; Quellen sind die CSV-Dateien. Neu erzeugen und testen (Deno, `brew install deno`): + ```bash + deno run --allow-read=supabase --allow-write=supabase supabase/catalog/build_startbestand.ts + deno test --allow-read=supabase supabase/catalog/ + ``` +- Ein Pfad in `sql_paths`, der nicht existiert, wird von der CLI nur mit Warnung übersprungen (kein Fehler). Genauere CLI-Ergebnisse: `docs/katalog-pflege.md`, Abschnitt 2.7. +- pgTAP-Tests, die Include-Dateien brauchen, nutzen `\ir ../support/…`; die Dateien in `tests/support/` sind erzeugt (gleiches Werkzeug) und keine Tests. + ## Regeln - Jede Tabelle hat `id` (uuid, PK), `created_at`, `updated_at` und Row Level Security. - `updated_at` setzt ein Trigger; `id` und `created_at` sind nach dem Anlegen unveränderlich. - Fremdschlüssel nur dort, wo Tabellen in Beziehung stehen, benannt `_id`. - Rechte werden explizit vergeben (erst alles entziehen, dann gezielt gewähren). -- Admin-Rolle kommt aus `app_metadata.role = 'admin'` im JWT, nie aus `user_metadata`. +- Admin-Rolle kommt aus `app_metadata.role = 'admin'` im JWT, nie aus `user_metadata`; in R1 niemandem vergeben, Pflege über Studio (KAT-05 E10). ## Noch nicht enthalten - Storage-Buckets und -Policies für Avatare und Spielbilder. diff --git a/supabase/catalog/build_startbestand.ts b/supabase/catalog/build_startbestand.ts new file mode 100644 index 0000000..d6723da --- /dev/null +++ b/supabase/catalog/build_startbestand.ts @@ -0,0 +1,670 @@ +// Startbestand-Werkzeug (KAT-05, Spec docs/specs/2-katalog-daten.md, Abschnitt 4.3). +// +// Liest die von Hand gepflegten Dateien +// supabase/catalog/vocabulary.csv (Genres und Mechaniken, de/en) +// supabase/catalog/startbestand.csv (Spiele; Format siehe docs/katalog-pflege.md) +// prüft sie (SB2, SB4-SB7) und erzeugt daraus +// supabase/catalog/vocabulary.sql, startbestand.sql, startbestand-keys.csv, checks.sql +// supabase/tests/support/{vocabulary,startbestand,checks}_fixture.inc (nur für pgTAP, aus catalog/testdata/, +// siehe 06_catalog_foundation.test.sql) +// Die erzeugten Dateien nie von Hand bearbeiten. Keine Abhängigkeiten außer der Deno-Standardumgebung. +// +// Aufruf (im Repo-Root): +// deno run --allow-read=supabase --allow-write=supabase supabase/catalog/build_startbestand.ts +// +// Das Skript erfindet keine Daten. Es erzeugt nie Cover-, Quellen- oder BGG-Angaben (SB6). + +export const NAMESPACE = "6f1d9a52-3c4e-4b8a-9f27-5d0e8c71a3b4"; +export const MIN_VISIBLE = 180; + +export const GENERATED_HEADER = + "-- Erzeugt von supabase/catalog/build_startbestand.ts. Nicht von Hand bearbeiten.\n"; + +// --------------------------------------------------------------------------------------------- +// Fehler +// --------------------------------------------------------------------------------------------- +export class BuildError extends Error { + constructor(public readonly problems: string[]) { + super(problems.join("\n")); + this.name = "BuildError"; + } +} + +// --------------------------------------------------------------------------------------------- +// UUID v5 (RFC 4122), gleiches Ergebnis wie uuid_generate_v5() in PostgreSQL +// --------------------------------------------------------------------------------------------- +function uuidToBytes(uuid: string): Uint8Array { + const hex = uuid.replaceAll("-", ""); + const out = new Uint8Array(16); + for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + return out; +} + +export async function uuidV5(name: string, namespace: string = NAMESPACE): Promise { + const nameBytes = new TextEncoder().encode(name); + const data = new Uint8Array(16 + nameBytes.length); + data.set(uuidToBytes(namespace), 0); + data.set(nameBytes, 16); + const hash = new Uint8Array(await crypto.subtle.digest("SHA-1", data)).slice(0, 16); + hash[6] = (hash[6] & 0x0f) | 0x50; + hash[8] = (hash[8] & 0x3f) | 0x80; + const hex = Array.from(hash, (b) => b.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +// --------------------------------------------------------------------------------------------- +// CSV (RFC 4180: Anführungszeichen, "" als Escape, Zeilenumbrüche in Feldern, optionales BOM) +// --------------------------------------------------------------------------------------------- +export interface CsvRow { + line: number; + cells: Record; +} +export interface Csv { + header: string[]; + rows: CsvRow[]; +} + +export function parseCsv(text: string, file: string): Csv { + if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); + const records: { line: number; fields: string[] }[] = []; + let fields: string[] = []; + let field = ""; + let inQuotes = false; + let line = 1; + let recordLine = 1; + let touched = false; // Satz hat Inhalt (auch ein leeres Feld nach Komma) + const problems: string[] = []; + + const endField = () => { + fields.push(field); + field = ""; + }; + const endRecord = () => { + endField(); + if (touched || fields.length > 1 || fields[0] !== "") { + records.push({ line: recordLine, fields }); + } + fields = []; + touched = false; + }; + + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (inQuotes) { + if (c === '"') { + if (text[i + 1] === '"') { + field += '"'; + i++; + } else inQuotes = false; + } else { + if (c === "\n") line++; + field += c; + } + continue; + } + if (c === '"' && field === "") { + inQuotes = true; + touched = true; + } else if (c === ",") { + touched = true; + endField(); + } else if (c === "\r") { + // wird mit dem folgenden \n behandelt; alleinstehendes \r zählt als Zeilenende + if (text[i + 1] !== "\n") { + endRecord(); + line++; + recordLine = line; + } + } else if (c === "\n") { + endRecord(); + line++; + recordLine = line; + } else { + field += c; + touched = true; + } + } + if (inQuotes) problems.push(`${file}:${recordLine}: Anführungszeichen nicht geschlossen`); + endRecord(); + + if (problems.length) throw new BuildError(problems); + if (records.length === 0) throw new BuildError([`${file}: Kopfzeile fehlt`]); + const header = records[0].fields.map((h) => h.trim()); + const rows: CsvRow[] = []; + for (const r of records.slice(1)) { + if (r.fields.length !== header.length) { + problems.push( + `${file}:${r.line}: ${r.fields.length} Spalten statt ${header.length} (Komma im Text? Feld in Anführungszeichen setzen)`, + ); + continue; + } + const cells: Record = {}; + header.forEach((h, idx) => (cells[h] = r.fields[idx])); + rows.push({ line: r.line, cells }); + } + if (problems.length) throw new BuildError(problems); + return { header, rows }; +} + +function checkHeader(csv: Csv, file: string, required: string[], optional: string[]): string[] { + const problems: string[] = []; + const allowed = new Set([...required, ...optional]); + for (const col of required) { + if (!csv.header.includes(col)) problems.push(`${file}:1: Spalte "${col}" fehlt`); + } + for (const col of csv.header) { + if (!allowed.has(col)) { + // SB6: Es gibt bewusst keine Spalte für Cover, Quelle oder BGG-Kennung. + problems.push(`${file}:1: unbekannte Spalte "${col}" (erlaubt: ${[...allowed].join(", ")})`); + } + } + const seen = new Set(); + for (const col of csv.header) { + if (seen.has(col)) problems.push(`${file}:1: Spalte "${col}" doppelt`); + seen.add(col); + } + return problems; +} + +// --------------------------------------------------------------------------------------------- +// Vokabular +// --------------------------------------------------------------------------------------------- +export type VocabKind = "genre" | "mechanic"; +export interface VocabEntry { + kind: VocabKind; + vocab_key: string; + name_de: string; + name_en: string; + id: string; +} + +export async function parseVocabulary(text: string, file = "vocabulary.csv"): Promise { + const csv = parseCsv(text, file); + const problems = checkHeader(csv, file, ["kind", "vocab_key", "name_de", "name_en"], []); + if (problems.length) throw new BuildError(problems); + + const entries: VocabEntry[] = []; + const seenKey = new Set(); + const seenDe = new Set(); + const seenEn = new Set(); + for (const { line, cells } of csv.rows) { + const where = `${file}:${line}`; + const kind = cells.kind.trim(); + const vocab_key = cells.vocab_key.trim(); + const name_de = cells.name_de.trim(); + const name_en = cells.name_en.trim(); + if (kind !== "genre" && kind !== "mechanic") { + problems.push(`${where}: kind muss "genre" oder "mechanic" sein, ist "${kind}"`); + continue; + } + if (!/^[a-z0-9-]{2,40}$/.test(vocab_key)) { + problems.push(`${where}: vocab_key "${vocab_key}" ungültig (a-z, 0-9, Bindestrich, 2-40 Zeichen)`); + } + for (const [label, v] of [["name_de", name_de], ["name_en", name_en]] as const) { + if (v.length < 1 || v.length > 100) problems.push(`${where}: ${label} muss 1-100 Zeichen haben`); + } + const k = `${kind}:${vocab_key}`; + if (seenKey.has(k)) problems.push(`${where}: vocab_key "${vocab_key}" doppelt (${kind})`); + seenKey.add(k); + const de = `${kind}:${name_de.toLowerCase()}`; + if (seenDe.has(de)) problems.push(`${where}: name_de "${name_de}" doppelt (${kind})`); + seenDe.add(de); + const en = `${kind}:${name_en.toLowerCase()}`; + if (seenEn.has(en)) problems.push(`${where}: name_en "${name_en}" doppelt (${kind})`); + seenEn.add(en); + entries.push({ kind, vocab_key, name_de, name_en, id: await uuidV5(`${kind}:${vocab_key}`) }); + } + if (problems.length) throw new BuildError(problems); + return entries; +} + +// --------------------------------------------------------------------------------------------- +// Schlüsselliste (append-only) +// --------------------------------------------------------------------------------------------- +export function parseKeys(text: string, file = "startbestand-keys.csv"): Map { + const csv = parseCsv(text, file); + const problems = checkHeader(csv, file, ["key", "uuid"], []); + if (problems.length) throw new BuildError(problems); + const keys = new Map(); + for (const { line, cells } of csv.rows) { + if (keys.has(cells.key)) problems.push(`${file}:${line}: key "${cells.key}" doppelt`); + keys.set(cells.key, cells.uuid); + } + if (problems.length) throw new BuildError(problems); + return keys; +} + +export function renderKeys(keys: Map): string { + const lines = [...keys.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([k, u]) => `${k},${u}`); + return ["key,uuid", ...lines].join("\n") + "\n"; +} + +// --------------------------------------------------------------------------------------------- +// Startbestand +// --------------------------------------------------------------------------------------------- +export interface Game { + key: string; + id: string; + title: string; + publishers: string[]; + year: number; + min_players: number; + max_players: number; + min_minutes: number; + max_minutes: number; + min_age: number; + genres: string[]; // name_de + mechanics: string[]; // name_de + description: string; + hidden: boolean; +} + +const REQUIRED_COLUMNS = [ + "key", "title", "publishers", "year", "min_players", "max_players", "min_minutes", "max_minutes", + "min_age", "genres", "mechanics", "description", "description_locale", +]; +const OPTIONAL_COLUMNS = ["hidden", "facts_source", "alt_titles", "base_game_key"]; +const SMALLINT_MAX = 32767; + +function splitList(value: string): string[] { + return value.split(";").map((s) => s.trim()).filter((s) => s !== ""); +} + +export async function parseStartbestand( + text: string, + vocabulary: VocabEntry[], + previousKeys: Map, + file = "startbestand.csv", +): Promise { + const csv = parseCsv(text, file); + const problems = checkHeader(csv, file, REQUIRED_COLUMNS, OPTIONAL_COLUMNS); + if (problems.length) throw new BuildError(problems); + + const genreNames = new Set(vocabulary.filter((v) => v.kind === "genre").map((v) => v.name_de)); + const mechanicNames = new Set(vocabulary.filter((v) => v.kind === "mechanic").map((v) => v.name_de)); + const games: Game[] = []; + const seenKeys = new Set(); + const allKeys = new Set(csv.rows.map((r) => r.cells.key.trim())); + + for (const { line, cells } of csv.rows) { + const where = `${file}:${line}`; + const key = cells.key.trim(); + const before = problems.length; + const fail = (msg: string) => problems.push(`${where}: ${msg}`); + + if (!/^[a-z0-9-]{2,60}$/.test(key)) fail(`key "${key}" ungültig (a-z, 0-9, Bindestrich, 2-60 Zeichen)`); + if (seenKeys.has(key)) fail(`key "${key}" doppelt`); + seenKeys.add(key); + + const title = cells.title.trim(); + if (title.length < 1 || title.length > 200) fail("title muss 1-200 Zeichen haben"); + + const publishers = splitList(cells.publishers); + if (publishers.length === 0) fail("mindestens ein Verlag nötig (publishers)"); + const seenPub = new Set(); + for (const p of publishers) { + if (p.length > 200) fail(`Verlag "${p.slice(0, 30)}…" länger als 200 Zeichen`); + if (seenPub.has(p.toLowerCase())) fail(`Verlag "${p}" doppelt`); + seenPub.add(p.toLowerCase()); + } + + const num = (col: string, min: number, max: number): number => { + const raw = cells[col].trim(); + if (raw === "") { + fail(`${col} fehlt (Pflicht-Fakt, SB2)`); + return NaN; + } + if (!/^\d+$/.test(raw)) { + fail(`${col} "${raw}" ist keine ganze Zahl`); + return NaN; + } + const n = Number(raw); + if (n < min || n > max) { + fail(`${col} ${n} außerhalb des erlaubten Bereichs ${min}-${max}`); + return NaN; + } + return n; + }; + const year = num("year", 1800, 2100); + const min_players = num("min_players", 1, SMALLINT_MAX); + const max_players = num("max_players", 1, SMALLINT_MAX); + const min_minutes = num("min_minutes", 0, SMALLINT_MAX); + const max_minutes = num("max_minutes", 0, SMALLINT_MAX); + const min_age = num("min_age", 0, SMALLINT_MAX); + if (max_players < min_players) fail(`max_players (${max_players}) kleiner als min_players (${min_players})`); + if (max_minutes < min_minutes) fail(`max_minutes (${max_minutes}) kleiner als min_minutes (${min_minutes})`); + + const resolveList = (col: "genres" | "mechanics", known: Set, label: string): string[] => { + const names = splitList(cells[col]); + if (names.length === 0) fail(`mindestens ${label} nötig (${col}, SB2)`); + const seen = new Set(); + for (const n of names) { + if (!known.has(n)) fail(`unbekannt in ${col}: "${n}" (nur Begriffe aus vocabulary.csv, name_de, SB5)`); + if (seen.has(n)) fail(`${col}: "${n}" doppelt`); + seen.add(n); + } + return names; + }; + const genres = resolveList("genres", genreNames, "ein Genre"); + const mechanics = resolveList("mechanics", mechanicNames, "eine Mechanik"); + + const description = cells.description.trim(); + if (description === "") fail("Kurztext fehlt (description, SB4)"); + if (description.length > 5000) fail("description länger als 5000 Zeichen"); + const locale = cells.description_locale.trim(); + if (locale !== "de") fail(`description_locale muss "de" sein, ist "${locale}" (SB4)`); + + const hiddenRaw = (cells.hidden ?? "").trim().toLowerCase(); + if (hiddenRaw !== "" && hiddenRaw !== "ja") fail(`hidden muss leer oder "ja" sein, ist "${cells.hidden}"`); + + const base = (cells.base_game_key ?? "").trim(); + if (base !== "") { + if (base === key) fail("base_game_key verweist auf das Spiel selbst"); + else if (!allKeys.has(base)) fail(`base_game_key "${base}" hat keinen passenden key (SB7)`); + } + + if (problems.length === before) { + games.push({ + key, id: await uuidV5(`game:${key}`), title, publishers, year, min_players, max_players, + min_minutes, max_minutes, min_age, genres, mechanics, description, hidden: hiddenRaw === "ja", + }); + } + } + + // Schlüssel sind unveränderlich (W4): Ein früher vorhandener key darf nicht fehlen. + for (const [key, uuid] of previousKeys) { + if (!allKeys.has(key)) { + problems.push( + `${file}: Schlüssel entfernt oder umbenannt: "${key}" (Spiel nie löschen: Spalte hidden = ja setzen)`, + ); + } + const expected = await uuidV5(`game:${key}`); + if (uuid !== expected) { + problems.push(`startbestand-keys.csv: uuid zu "${key}" ist ${uuid}, erwartet ${expected} (Namespace geändert?)`); + } + } + + if (problems.length) throw new BuildError(problems); + return games; +} + +// --------------------------------------------------------------------------------------------- +// SQL-Erzeugung +// --------------------------------------------------------------------------------------------- +export const q = (s: string): string => `'${s.replaceAll("'", "''")}'`; +const jsonLit = (v: unknown): string => `${q(JSON.stringify(v))}::jsonb`; +// Ein Eintrag pro Zeile, damit die erzeugten Dateien lesbar bleiben. +const jsonLines = (v: unknown[]): string => + v.length === 0 ? "'[]'::jsonb" : `${q("[\n" + v.map((x) => " " + JSON.stringify(x)).join(",\n") + "\n]")}::jsonb`; +const num = (n: number): string => String(n); + +export function generateVocabularySql(vocabulary: VocabEntry[]): string { + const out: string[] = [GENERATED_HEADER]; + out.push( + "-- Vokabular (Genres, Mechaniken). Idempotent. Ein im Studio angelegter Begriff mit gleichem Namen", + "-- gewinnt (on conflict do nothing fängt ID- und Namenskonflikte); startbestand.sql löst über name_de auf.", + "", + ); + for (const [kind, table] of [["genre", "genres"], ["mechanic", "mechanics"]] as const) { + const rows = vocabulary.filter((v) => v.kind === kind); + if (rows.length === 0) { + out.push(`-- keine ${table} im Vokabular`, ""); + continue; + } + out.push(`insert into public.${table} (id, name_de, name_en) values`); + out.push(rows.map((v) => ` (${q(v.id)}, ${q(v.name_de)}, ${q(v.name_en)})`).join(",\n")); + out.push("on conflict do nothing;", ""); + } + return out.join("\n"); +} + +export function generateStartbestandSql(games: Game[]): string { + const out: string[] = [GENERATED_HEADER]; + out.push( + "-- Startbestand (KAT-05). Idempotent bis zum Go-live; danach eingefroren (docs/katalog-pflege.md).", + "-- Reihenfolge: Migrationen, vocabulary.sql, dann diese Datei.", + "", + ); + if (games.length === 0) { + out.push("-- startbestand.csv enthält keine Spiele.", ""); + return out.join("\n"); + } + + // Wächter: Fehlt ein Begriff (Vokabular nicht eingespielt, im Studio umbenannt), bricht die Datei + // ab, statt still Zuordnungen zu löschen. + const genreNames = [...new Set(games.flatMap((g) => g.genres))].sort(); + const mechanicNames = [...new Set(games.flatMap((g) => g.mechanics))].sort(); + out.push( + "do $guard$", + "declare", + " missing text;", + "begin", + ` select string_agg(n, ', ') into missing from jsonb_array_elements_text(${jsonLit(genreNames)}) as t(n)`, + " where not exists (select 1 from public.genres g where g.name_de = t.n);", + " if missing is not null then", + " raise exception 'startbestand: Genre fehlt in der Datenbank (vocabulary.sql eingespielt?): %', missing;", + " end if;", + ` select string_agg(n, ', ') into missing from jsonb_array_elements_text(${jsonLit(mechanicNames)}) as t(n)`, + " where not exists (select 1 from public.mechanics m where m.name_de = t.n);", + " if missing is not null then", + " raise exception 'startbestand: Mechanik fehlt in der Datenbank (vocabulary.sql eingespielt?): %', missing;", + " end if;", + "end", + "$guard$;", + "", + ); + + // 1. Verlage (ohne abgeleitete ID, Auflösung über lower(name)) + const publisherNames = new Map(); // lower -> erste Schreibweise + for (const g of games) for (const p of g.publishers) if (!publisherNames.has(p.toLowerCase())) publisherNames.set(p.toLowerCase(), p); + out.push("-- Verlage"); + out.push("insert into public.publishers (name) values"); + out.push([...publisherNames.values()].sort().map((p) => ` (${q(p)})`).join(",\n")); + out.push("on conflict ((lower(name))) do nothing;", ""); + + // 2. Spiele + const cols = "title, description, description_locale, year, min_players, max_players, min_minutes, max_minutes, min_age"; + out.push("-- Spiele (unveränderte Zeilen werden nicht angefasst)"); + out.push(`insert into public.games (id, ${cols}, visibility) values`); + out.push( + games.map((g) => + ` (${q(g.id)}, ${q(g.title)}, ${q(g.description)}, 'de', ${num(g.year)}, ${num(g.min_players)}, ${num(g.max_players)}, ` + + `${num(g.min_minutes)}, ${num(g.max_minutes)}, ${num(g.min_age)}, 'catalog')` + ).join(",\n"), + ); + const colList = cols.split(", "); + out.push("on conflict (id) do update set"); + out.push(colList.map((c) => ` ${c} = excluded.${c}`).join(",\n")); + out.push(`where (${colList.map((c) => `games.${c}`).join(", ")})`); + out.push(` is distinct from (${colList.map((c) => `excluded.${c}`).join(", ")});`, ""); + + // 3. Entfernen = ausblenden (nur bei hidden = ja; sonst bleibt hidden_at unberührt) + const hidden = games.filter((g) => g.hidden); + if (hidden.length) { + out.push("-- hidden = ja: ausblenden, nichts löschen"); + out.push(`update public.games set hidden_at = now()`); + out.push(`where hidden_at is null and id = any(array[${hidden.map((g) => q(g.id)).join(", ")}]::uuid[]);`, ""); + } + + // 4. Zuordnungen als Abgleich + const ids = games.map((g) => q(g.id)).join(", "); + const wantRows = (pick: (g: Game) => string[]) => + games.flatMap((g) => pick(g).map((n) => ` (${q(g.id)}::uuid, ${q(n)})`)).join(",\n"); + const reconcile = ( + label: string, + linkTable: string, + fkColumn: string, + termTable: string, + termMatch: string, + pick: (g: Game) => string[], + ) => { + out.push(`-- Zuordnung ${label}: fehlende anlegen, nicht mehr gewünschte löschen`); + out.push(`with want(game_id, term) as (values`); + out.push(wantRows(pick)); + out.push(`), resolved as (`); + out.push(` select w.game_id, t.id as ${fkColumn} from want w join public.${termTable} t on ${termMatch}`); + out.push(`), removed as (`); + out.push(` delete from public.${linkTable} l`); + out.push(` where l.game_id = any(array[${ids}]::uuid[])`); + out.push(` and not exists (select 1 from resolved r where r.game_id = l.game_id and r.${fkColumn} = l.${fkColumn})`); + out.push(`)`); + out.push(`insert into public.${linkTable} (game_id, ${fkColumn})`); + out.push(`select game_id, ${fkColumn} from resolved`); + out.push(`on conflict (game_id, ${fkColumn}) do nothing;`, ""); + }; + reconcile("Genre", "game_genres", "genre_id", "genres", "t.name_de = w.term", (g) => g.genres); + reconcile("Mechanik", "game_mechanics", "mechanic_id", "mechanics", "t.name_de = w.term", (g) => g.mechanics); + reconcile("Verlag", "game_publishers", "publisher_id", "publishers", "lower(t.name) = lower(w.term)", (g) => g.publishers); + return out.join("\n"); +} + +// --------------------------------------------------------------------------------------------- +// Prüfabfrage checks.sql (und die Fixture-Variante für pgTAP) +// --------------------------------------------------------------------------------------------- +export function generateChecksQuery(startbestandIds: string[], vocabulary: VocabEntry[]): string { + const sbIds = [...startbestandIds].sort(); + const voc = vocabulary.map((v) => ({ kind: v.kind, id: v.id, name_de: v.name_de })); + return [ + `with sb(id) as (`, + ` select t.value::uuid from jsonb_array_elements_text(${jsonLines(sbIds)}) as t(value)`, + `), voc(kind, id, name_de) as (`, + ` select x.kind, x.id, x.name_de from jsonb_to_recordset(${jsonLines(voc)}) as x(kind text, id uuid, name_de text)`, + `)`, + `select rule, beschreibung, soll, ist, case when ok is null then 'Information' when ok then 'ok' else 'Verstoß' end as status`, + `from (`, + ` select 1 as ord, 'SB0' as rule, 'Startbestandsspiele in der Datenbank vorhanden' as beschreibung,`, + ` 'alle ' || (select count(*) from sb) as soll, n as ist, null::boolean as ok`, + ` from (select count(*) as n from public.games g join sb on sb.id = g.id) x`, + ` union all`, + ` select 2, 'SB1', 'Sichtbare Katalogspiele aus dem Startbestand (Abnahme: mindestens ${MIN_VISIBLE})',`, + ` '>= ${MIN_VISIBLE}', n, n >= ${MIN_VISIBLE}`, + ` from (select count(*) as n from public.catalog_games c join sb on sb.id = c.id) x`, + ` union all`, + ` select 3, 'SB2', 'Spiele ohne Pflicht-Fakt (Jahr, Spielerzahl, Dauer, Alter, Verlag, Genre, Mechanik)', '0', n, n = 0`, + ` from (select count(*) as n from public.games g join sb on sb.id = g.id`, + ` where g.year is null or g.min_players is null or g.max_players is null`, + ` or g.min_minutes is null or g.max_minutes is null or g.min_age is null`, + ` or not exists (select 1 from public.game_publishers x where x.game_id = g.id)`, + ` or not exists (select 1 from public.game_genres x where x.game_id = g.id)`, + ` or not exists (select 1 from public.game_mechanics x where x.game_id = g.id)) x`, + ` union all`, + ` select 4, 'SB4', 'Spiele ohne deutschen Kurztext', '0', n, n = 0`, + ` from (select count(*) as n from public.games g join sb on sb.id = g.id`, + ` where g.description is null or g.description_locale is distinct from 'de') x`, + ` union all`, + ` select 5, 'SB6', 'Spiele mit Cover, Quelle oder BGG-Kennung', '0', n, n = 0`, + ` from (select count(*) as n from public.games g join sb on sb.id = g.id`, + ` where g.image_path is not null or g.catalog_source_id is not null or g.bgg_id is not null) x`, + ` union all`, + ` select 6, 'Info', 'Begriffe mit abweichender ID (im Studio angelegt, bitte prüfen)', '-', n, null::boolean`, + ` from (select (select count(*) from public.genres t join voc v on v.kind = 'genre' and v.name_de = t.name_de where t.id <> v.id)`, + ` + (select count(*) from public.mechanics t join voc v on v.kind = 'mechanic' and v.name_de = t.name_de where t.id <> v.id) as n) x`, + `) r`, + `order by ord`, + ].join("\n"); +} + +export function generateChecksSql(startbestandIds: string[], vocabulary: VocabEntry[]): string { + return [ + GENERATED_HEADER.trimEnd(), + "-- Prüfabfrage zur Abnahme (KAT-05 Krit. 7 und 10). Im Studio-SQL-Editor oder per psql ausführen.", + "-- Beschränkt sich auf die Spiele aus startbestand-keys.csv; Spiele, die im Studio angelegt wurden,", + "-- und das Beispielspiel aus seed.sql zählen nicht mit. Abnahme: kein Verstoß, SB1 mindestens " + MIN_VISIBLE + ".", + "-- Lokal und in CI ist SB1 nur eine Information, solange noch eingepflegt wird.", + generateChecksQuery(startbestandIds, vocabulary) + ";", + "", + ].join("\n"); +} + +// Ausschließlich für pgTAP: legt die Prüfabfrage als temporäre View an (psql: \ir). Die IDs stammen +// aus den Testspielen in catalog/testdata/, damit die Prüfabfrage selbst getestet werden kann. +export function generateChecksFixtureInclude(startbestandIds: string[], vocabulary: VocabEntry[]): string { + return [ + "-- Erzeugt von supabase/catalog/build_startbestand.ts. Nicht von Hand bearbeiten.", + "-- Include für pgTAP (\\ir), keine Test-Datei. Dieselbe Abfrage wie checks.sql, aber mit den IDs der", + "-- Testspiele aus supabase/catalog/testdata/.", + "create temp view checks_result as", + generateChecksQuery(startbestandIds, vocabulary) + ";", + "", + ].join("\n"); +} + +// --------------------------------------------------------------------------------------------- +// Gesamtlauf +// --------------------------------------------------------------------------------------------- +export interface BuildInput { + vocabularyCsv: string; + startbestandCsv: string; + previousKeysCsv: string; +} +export interface BuildOutput { + "vocabulary.sql": string; + "startbestand.sql": string; + "startbestand-keys.csv": string; + "checks.sql": string; + checksFixtureInclude: string; +} + +export async function build(input: BuildInput): Promise { + const vocabulary = await parseVocabulary(input.vocabularyCsv); + const previous = parseKeys(input.previousKeysCsv); + const games = await parseStartbestand(input.startbestandCsv, vocabulary, previous); + const keys = new Map(previous); + for (const g of games) keys.set(g.key, g.id); + const ids = [...keys.values()]; + return { + "vocabulary.sql": generateVocabularySql(vocabulary), + "startbestand.sql": generateStartbestandSql(games), + "startbestand-keys.csv": renderKeys(keys), + "checks.sql": generateChecksSql(ids, vocabulary), + checksFixtureInclude: generateChecksFixtureInclude(ids, vocabulary), + }; +} + +async function main() { + const dir = new URL(".", import.meta.url); + const read = (name: string) => Deno.readTextFile(new URL(name, dir)); + let previousKeysCsv = "key,uuid\n"; + try { + previousKeysCsv = await read("startbestand-keys.csv"); + } catch (e) { + if (!(e instanceof Deno.errors.NotFound)) throw e; + } + const real = await build({ + vocabularyCsv: await read("vocabulary.csv"), + startbestandCsv: await read("startbestand.csv"), + previousKeysCsv, + }); + // Testdaten (offensichtlich fiktiv) für pgTAP: eigenes Vokabular, eigene Spiele, keine Schlüsselliste. + const fixture = await build({ + vocabularyCsv: await read("testdata/vocabulary.csv"), + startbestandCsv: await read("testdata/startbestand.csv"), + previousKeysCsv: "key,uuid\n", + }); + + for (const name of ["vocabulary.sql", "startbestand.sql", "startbestand-keys.csv", "checks.sql"] as const) { + await Deno.writeTextFile(new URL(name, dir), real[name]); + } + const support = new URL("../tests/support/", dir); + await Deno.mkdir(support, { recursive: true }); + await Deno.writeTextFile(new URL("vocabulary_fixture.inc", support), fixture["vocabulary.sql"]); + await Deno.writeTextFile(new URL("startbestand_fixture.inc", support), fixture["startbestand.sql"]); + await Deno.writeTextFile(new URL("checks_fixture.inc", support), fixture.checksFixtureInclude); + console.log( + "Erzeugt: catalog/{vocabulary.sql,startbestand.sql,startbestand-keys.csv,checks.sql}, " + + "tests/support/{vocabulary,startbestand,checks}_fixture.inc", + ); +} + +if (import.meta.main) { + try { + await main(); + } catch (e) { + if (e instanceof BuildError) { + console.error("Prüfung fehlgeschlagen:"); + for (const p of e.problems) console.error(" " + p); + Deno.exit(1); + } + throw e; + } +} diff --git a/supabase/catalog/build_startbestand_test.ts b/supabase/catalog/build_startbestand_test.ts new file mode 100644 index 0000000..6ec0e07 --- /dev/null +++ b/supabase/catalog/build_startbestand_test.ts @@ -0,0 +1,329 @@ +// Tests für build_startbestand.ts. Aufruf (Repo-Root): +// deno test --allow-read=supabase supabase/catalog/ +// Fixtures sind offensichtlich fiktiv ("Testspiel", "Testverlag"). Es werden keine echten Spieldaten verwendet. +import assert from "node:assert/strict"; +import { + build, + BuildError, + generateStartbestandSql, + NAMESPACE, + parseCsv, + parseKeys, + parseStartbestand, + parseVocabulary, + renderKeys, + uuidV5, +} from "./build_startbestand.ts"; + +const VOCAB = `kind,vocab_key,name_de,name_en +genre,test-genre-a,Testgenre A,Test genre A +genre,test-genre-b,Testgenre B,Test genre B +mechanic,test-mechanic-a,Testmechanik A,Test mechanic A +`; +const HEADER = + "key,title,publishers,year,min_players,max_players,min_minutes,max_minutes,min_age,genres,mechanics,description,description_locale,hidden,facts_source,alt_titles,base_game_key"; + +// Zeile mit Standardwerten; einzelne Felder lassen sich überschreiben. +function row(over: Record = {}): string { + const base: Record = { + key: "testspiel-a", title: "Testspiel A", publishers: "Testverlag", year: "2020", min_players: "2", + max_players: "4", min_minutes: "30", max_minutes: "60", min_age: "8", genres: "Testgenre A", + mechanics: "Testmechanik A", description: "Fiktiver Text.", description_locale: "de", hidden: "", + facts_source: "Test", alt_titles: "", base_game_key: "", + }; + const r = { ...base, ...over }; + return HEADER.split(",").map((c) => { + const v = r[c]; + return /[",\n]/.test(v) ? `"${v.replaceAll('"', '""')}"` : v; + }).join(","); +} +const csv = (...rows: string[]) => [HEADER, ...rows].join("\n") + "\n"; + +async function vocab() { + return await parseVocabulary(VOCAB); +} +async function rejects(text: string, includes: RegExp, keys = new Map()) { + const v = await vocab(); + await assert.rejects( + () => parseStartbestand(text, v, keys), + (e: unknown) => { + assert.ok(e instanceof BuildError, "BuildError erwartet"); + assert.match(e.problems.join("\n"), includes); + return true; + }, + ); +} + +// ---- UUID v5 ------------------------------------------------------------------------------ +Deno.test("uuidV5: Referenzwert aus RFC 4122 / Python uuid5(NAMESPACE_DNS, ...)", async () => { + assert.equal( + await uuidV5("www.example.com", "6ba7b810-9dad-11d1-80b4-00c04fd430c8"), + "2ed6657d-e927-568b-95e1-2665a8aea6a2", + ); +}); + +Deno.test("uuidV5: gleicher key gibt gleiche UUID wie uuid_generate_v5 in PostgreSQL (siehe pgTAP 06)", async () => { + assert.equal(NAMESPACE, "6f1d9a52-3c4e-4b8a-9f27-5d0e8c71a3b4"); + assert.equal(await uuidV5("game:testspiel-a"), "5189ad0e-ab5b-5500-a3e0-a0b3671cdf14"); + assert.equal(await uuidV5("game:testspiel-a"), await uuidV5("game:testspiel-a")); + assert.notEqual(await uuidV5("game:testspiel-a"), await uuidV5("game:testspiel-b")); +}); + +// ---- CSV ---------------------------------------------------------------------------------- +Deno.test("parseCsv: Anführungszeichen, doppelte Anführungszeichen, Zeilenumbruch im Feld, CRLF, BOM", () => { + const parsed = parseCsv('a,b\r\n"x, y","sagt ""hallo"""\r\n"zwei\nZeilen",z\r\n', "t.csv"); + assert.deepEqual(parsed.header, ["a", "b"]); + assert.equal(parsed.rows[0].cells.a, "x, y"); + assert.equal(parsed.rows[0].cells.b, 'sagt "hallo"'); + assert.equal(parsed.rows[1].cells.a, "zwei\nZeilen"); + assert.equal(parsed.rows[1].line, 3); +}); + +Deno.test("parseCsv: falsche Spaltenzahl und offenes Anführungszeichen nennen die Zeile", () => { + assert.throws(() => parseCsv("a,b\n1,2,3\n", "t.csv"), /t\.csv:2: 3 Spalten statt 2/); + assert.throws(() => parseCsv('a,b\n"offen,2\n', "t.csv"), /t\.csv:2: Anführungszeichen nicht geschlossen/); +}); + +Deno.test("parseCsv: nur Kopfzeile ergibt keine Zeilen", () => { + assert.equal(parseCsv("a,b\n", "t.csv").rows.length, 0); +}); + +// ---- Vokabular ---------------------------------------------------------------------------- +Deno.test("parseVocabulary: gültig, IDs nach UUID v5", async () => { + const v = await vocab(); + assert.equal(v.length, 3); + assert.equal(v[0].id, await uuidV5("genre:test-genre-a")); + assert.equal(v[2].id, await uuidV5("mechanic:test-mechanic-a")); +}); + +Deno.test("parseVocabulary: doppelter vocab_key, doppelter Name, falsche kind, falscher Schlüssel", async () => { + const head = "kind,vocab_key,name_de,name_en\n"; + await assert.rejects(() => parseVocabulary(head + "genre,a-b,A,A\ngenre,a-b,B,B\n"), /vocabulary\.csv:3: vocab_key "a-b" doppelt/); + await assert.rejects(() => parseVocabulary(head + "genre,a-b,A,A\ngenre,c-d,a,C\n"), /vocabulary\.csv:3: name_de "a" doppelt/); + await assert.rejects(() => parseVocabulary(head + "genre,a-b,A,A\ngenre,c-d,C,a\n"), /vocabulary\.csv:3: name_en "a" doppelt/); + await assert.rejects(() => parseVocabulary(head + "kategorie,a-b,A,A\n"), /kind muss/); + await assert.rejects(() => parseVocabulary(head + "genre,A_B,A,A\n"), /vocab_key "A_B" ungültig/); + await assert.rejects(() => parseVocabulary("kind,vocab_key,name_de\ngenre,a-b,A\n"), /Spalte "name_en" fehlt/); +}); + +Deno.test("parseVocabulary: gleiche Namen in verschiedenen Arten sind erlaubt", async () => { + const v = await parseVocabulary("kind,vocab_key,name_de,name_en\ngenre,a-b,A,A\nmechanic,a-b,A,A\n"); + assert.equal(v.length, 2); + assert.notEqual(v[0].id, v[1].id); +}); + +// ---- Startbestand: gültig ----------------------------------------------------------------- +Deno.test("parseStartbestand: gültige Zeile (Positivfall)", async () => { + const games = await parseStartbestand(csv(row()), await vocab(), new Map()); + assert.equal(games.length, 1); + assert.equal(games[0].id, await uuidV5("game:testspiel-a")); + assert.deepEqual(games[0].publishers, ["Testverlag"]); + assert.equal(games[0].hidden, false); +}); + +Deno.test("parseStartbestand: nur Kopfzeile ist gültig (heutiger Stand ohne Spiele)", async () => { + assert.deepEqual(await parseStartbestand(HEADER + "\n", await vocab(), new Map()), []); +}); + +// ---- Startbestand: eine Negativprobe je Prüfregel ----------------------------------------- +Deno.test("SB2: fehlende Pflicht-Fakten", async () => { + for (const col of ["year", "min_players", "max_players", "min_minutes", "max_minutes", "min_age"]) { + await rejects(csv(row({ [col]: "" })), new RegExp(`startbestand\\.csv:2: ${col} fehlt`)); + } + await rejects(csv(row({ publishers: "" })), /mindestens ein Verlag/); + await rejects(csv(row({ publishers: " ; " })), /mindestens ein Verlag/); + await rejects(csv(row({ genres: "" })), /mindestens ein Genre/); + await rejects(csv(row({ mechanics: "" })), /mindestens eine Mechanik/); + await rejects(csv(row({ title: "" })), /title muss 1-200 Zeichen/); +}); + +Deno.test("DB-Grenzen: Jahr, max < min, keine Zahl, negativ", async () => { + await rejects(csv(row({ year: "1799" })), /year 1799 außerhalb/); + await rejects(csv(row({ year: "2101" })), /year 2101 außerhalb/); + await rejects(csv(row({ year: "20x0" })), /year "20x0" ist keine ganze Zahl/); + await rejects(csv(row({ min_players: "0" })), /min_players 0 außerhalb/); + await rejects(csv(row({ min_players: "5", max_players: "3" })), /max_players \(3\) kleiner als min_players \(5\)/); + await rejects(csv(row({ min_minutes: "90", max_minutes: "30" })), /max_minutes \(30\) kleiner als min_minutes \(90\)/); + await rejects(csv(row({ min_age: "-1" })), /min_age "-1" ist keine ganze Zahl/); + await rejects(csv(row({ title: "x".repeat(201) })), /title muss 1-200 Zeichen/); +}); + +Deno.test("SB4: Kurztext Pflicht, Sprache de, Länge", async () => { + await rejects(csv(row({ description: "" })), /Kurztext fehlt/); + await rejects(csv(row({ description_locale: "en" })), /description_locale muss "de" sein/); + await rejects(csv(row({ description_locale: "" })), /description_locale muss "de" sein/); + await rejects(csv(row({ description: "x".repeat(5001) })), /länger als 5000/); +}); + +Deno.test("SB5: unbekanntes Genre oder unbekannte Mechanik, Doppelte", async () => { + await rejects(csv(row({ genres: "Erfundenes" })), /unbekannt in genres: "Erfundenes"/); + await rejects(csv(row({ mechanics: "Testgenre A" })), /unbekannt in mechanics/); // Genre ist keine Mechanik + await rejects(csv(row({ genres: "Testgenre A;Testgenre A" })), /genres: "Testgenre A" doppelt/); +}); + +Deno.test("SB6: keine Spalte für Cover, Quelle oder BGG-Kennung", async () => { + for (const col of ["image_path", "bgg_id", "catalog_source_id", "source_ref", "cover"]) { + await rejects(`${HEADER},${col}\n${row()},x\n`, new RegExp(`unbekannte Spalte "${col}"`)); + } +}); + +Deno.test("SB7: base_game_key muss auf einen vorhandenen key zeigen", async () => { + await rejects(csv(row({ base_game_key: "gibt-es-nicht" })), /base_game_key "gibt-es-nicht" hat keinen passenden key/); + await rejects(csv(row({ base_game_key: "testspiel-a" })), /verweist auf das Spiel selbst/); + const ok = await parseStartbestand( + csv(row(), row({ key: "testspiel-b", title: "Testspiel B", base_game_key: "testspiel-a" })), + await vocab(), + new Map(), + ); + assert.equal(ok.length, 2); +}); + +Deno.test("key: Format und Eindeutigkeit", async () => { + await rejects(csv(row({ key: "Ungültig_Key" })), /key "Ungültig_Key" ungültig/); + await rejects(csv(row({ key: "a" })), /key "a" ungültig/); + await rejects(csv(row(), row({ title: "Noch eins" })), /startbestand\.csv:3: key "testspiel-a" doppelt/); +}); + +Deno.test("hidden: nur leer oder ja; Verlage nicht doppelt", async () => { + await rejects(csv(row({ hidden: "nein" })), /hidden muss leer oder "ja" sein/); + await rejects(csv(row({ publishers: "Testverlag;TESTVERLAG" })), /Verlag "TESTVERLAG" doppelt/); +}); + +Deno.test("Pflichtspalte fehlt in der Kopfzeile", async () => { + await assert.rejects( + async () => await parseStartbestand("key,title\nx,y\n", await vocab(), new Map()), + /Spalte "publishers" fehlt/, + ); +}); + +Deno.test("mehrere Fehler werden gesammelt, mit Zeilennummern", async () => { + const v = await vocab(); + await assert.rejects( + () => parseStartbestand(csv(row({ year: "" }), row({ key: "testspiel-b", genres: "X" })), v, new Map()), + (e: unknown) => { + assert.ok(e instanceof BuildError); + assert.match(e.problems.join("\n"), /startbestand\.csv:2: year fehlt/); + assert.match(e.problems.join("\n"), /startbestand\.csv:3: unbekannt in genres/); + return true; + }, + ); +}); + +// ---- Schlüsselliste ----------------------------------------------------------------------- +Deno.test("Schlüssel sind unveränderlich: ein entfernter key bricht ab", async () => { + const previous = parseKeys(`key,uuid\ntestspiel-a,${await uuidV5("game:testspiel-a")}\ntestspiel-alt,${await uuidV5("game:testspiel-alt")}\n`); + await rejects(csv(row()), /Schlüssel entfernt oder umbenannt: "testspiel-alt"/, previous); + // Umbenennen ist dasselbe wie Entfernen + await rejects(csv(row({ key: "testspiel-neu" })), /Schlüssel entfernt oder umbenannt: "testspiel-a"/, previous); +}); + +Deno.test("Schlüssel: falsche UUID in der Liste (Namespace geändert?)", async () => { + const previous = parseKeys("key,uuid\ntestspiel-a,00000000-0000-5000-8000-000000000000\n"); + await rejects(csv(row()), /uuid zu "testspiel-a" ist 00000000-0000-5000-8000-000000000000, erwartet/, previous); +}); + +Deno.test("Schlüsselliste wächst nur und ist sortiert", async () => { + const first = await build({ vocabularyCsv: VOCAB, startbestandCsv: csv(row({ key: "zzz-spiel" })), previousKeysCsv: "key,uuid\n" }); + const second = await build({ + vocabularyCsv: VOCAB, + startbestandCsv: csv(row({ key: "zzz-spiel" }), row({ key: "aaa-spiel", title: "Neu" })), + previousKeysCsv: first["startbestand-keys.csv"], + }); + const before = first["startbestand-keys.csv"].trim().split("\n"); + const after = second["startbestand-keys.csv"].trim().split("\n"); + for (const line of before) assert.ok(after.includes(line), `Zeile fehlt: ${line}`); + assert.deepEqual(after.slice(1).map((l) => l.split(",")[0]), ["aaa-spiel", "zzz-spiel"]); + assert.equal(renderKeys(parseKeys(second["startbestand-keys.csv"])), second["startbestand-keys.csv"]); +}); + +// ---- SQL-Erzeugung ------------------------------------------------------------------------ +async function sqlFor(...rows: string[]): Promise { + return generateStartbestandSql(await parseStartbestand(csv(...rows), await vocab(), new Map())); +} + +Deno.test("hidden = ja erzeugt hidden_at, leer fasst hidden_at nicht an", async () => { + const hiddenSql = await sqlFor(row({ hidden: "ja" })); + assert.match(hiddenSql, /update public\.games set hidden_at = now\(\)/); + assert.match(hiddenSql, /where hidden_at is null and id = any/); + const plain = await sqlFor(row()); + assert.doesNotMatch(plain, /hidden_at/); + assert.doesNotMatch(plain, /update public\.games set/); +}); + +Deno.test("facts_source, alt_titles und base_game_key landen nicht in der SQL", async () => { + const sql = await sqlFor(row({ facts_source: "GEHEIMER-NACHWEIS", alt_titles: "de:Alt", base_game_key: "" })); + assert.doesNotMatch(sql, /GEHEIMER-NACHWEIS|alt_titles|base_game_key|facts_source/); +}); + +Deno.test("SQL: Apostroph im Text wird verdoppelt, Upsert nutzt is distinct from, Zuordnungen werden abgeglichen", async () => { + const sql = await sqlFor(row({ title: "Wo's lang", description: "Es ist 'nett'." })); + assert.match(sql, /'Wo''s lang'/); + assert.match(sql, /'Es ist ''nett''\.'/); + assert.match(sql, /on conflict \(id\) do update set/); + assert.match(sql, /is distinct from \(excluded\.title/); + assert.match(sql, /on conflict \(\(lower\(name\)\)\) do nothing/); // Verlage + assert.match(sql, /delete from public\.game_genres/); + assert.match(sql, /delete from public\.game_mechanics/); + assert.match(sql, /delete from public\.game_publishers/); + assert.match(sql, /on conflict \(game_id, genre_id\) do nothing/); + assert.match(sql, /Genre fehlt in der Datenbank/); // Wächter gegen still verlorene Zuordnungen + assert.doesNotMatch(sql, /image_path|bgg_id|catalog_source_id|source_ref/); // SB6 +}); + +Deno.test("SQL: leerer Startbestand ergibt eine gültige, leere Datei", async () => { + const sql = generateStartbestandSql([]); + assert.match(sql, /keine Spiele/); + assert.doesNotMatch(sql, /insert into/); +}); + +Deno.test("build: deterministisch, ohne Zeitstempel", async () => { + const input = { vocabularyCsv: VOCAB, startbestandCsv: csv(row(), row({ key: "testspiel-b", title: "B" })), previousKeysCsv: "key,uuid\n" }; + assert.deepEqual(await build(input), await build(input)); +}); + +Deno.test("checks.sql: bettet die Startbestands-IDs ein, Sollwert 180, leerer Bestand ist gültiges SQL", async () => { + const out = await build({ vocabularyCsv: VOCAB, startbestandCsv: csv(row()), previousKeysCsv: "key,uuid\n" }); + assert.match(out["checks.sql"], new RegExp(await uuidV5("game:testspiel-a"))); + assert.match(out["checks.sql"], />= 180/); + const empty = await build({ vocabularyCsv: VOCAB, startbestandCsv: HEADER + "\n", previousKeysCsv: "key,uuid\n" }); + assert.match(empty["checks.sql"], /jsonb_array_elements_text\('\[\]'::jsonb\)/); + assert.doesNotMatch(empty["checks.sql"], /values \(\s*\)/); +}); + +Deno.test("vocabulary.sql: on conflict do nothing ohne Konfliktziel", async () => { + const out = await build({ vocabularyCsv: VOCAB, startbestandCsv: HEADER + "\n", previousKeysCsv: "key,uuid\n" }); + assert.match(out["vocabulary.sql"], /insert into public\.genres \(id, name_de, name_en\) values/); + assert.match(out["vocabulary.sql"], /insert into public\.mechanics/); + assert.equal((out["vocabulary.sql"].match(/on conflict do nothing;/g) ?? []).length, 2); +}); + +// ---- Eingecheckte, erzeugte Dateien sind aktuell ------------------------------------------ +Deno.test("die eingecheckten erzeugten Dateien entsprechen den CSV-Quellen", async () => { + const dir = new URL(".", import.meta.url); + const read = (name: string) => Deno.readTextFile(new URL(name, dir)); + const real = await build({ + vocabularyCsv: await read("vocabulary.csv"), + startbestandCsv: await read("startbestand.csv"), + previousKeysCsv: await read("startbestand-keys.csv"), + }); + for (const name of ["vocabulary.sql", "startbestand.sql", "startbestand-keys.csv", "checks.sql"] as const) { + assert.equal(await read(name), real[name], `${name} ist nicht aktuell: build_startbestand.ts ausführen`); + } + const fixture = await build({ + vocabularyCsv: await read("testdata/vocabulary.csv"), + startbestandCsv: await read("testdata/startbestand.csv"), + previousKeysCsv: "key,uuid\n", + }); + const support = (name: string) => Deno.readTextFile(new URL(`../tests/support/${name}`, dir)); + assert.equal(await support("vocabulary_fixture.inc"), fixture["vocabulary.sql"]); + assert.equal(await support("startbestand_fixture.inc"), fixture["startbestand.sql"]); + assert.equal(await support("checks_fixture.inc"), fixture.checksFixtureInclude); +}); + +Deno.test("Vokabular-Vorschlag: 12-15 Genres, 20-30 Mechaniken (Aufgabe B3a)", async () => { + const v = await parseVocabulary(await Deno.readTextFile(new URL("vocabulary.csv", import.meta.url))); + const genres = v.filter((e) => e.kind === "genre").length; + const mechanics = v.filter((e) => e.kind === "mechanic").length; + assert.ok(genres >= 12 && genres <= 15, `Genres: ${genres}`); + assert.ok(mechanics >= 20 && mechanics <= 30, `Mechaniken: ${mechanics}`); +}); diff --git a/supabase/catalog/checks.sql b/supabase/catalog/checks.sql new file mode 100644 index 0000000..087ffb4 --- /dev/null +++ b/supabase/catalog/checks.sql @@ -0,0 +1,86 @@ +-- Erzeugt von supabase/catalog/build_startbestand.ts. Nicht von Hand bearbeiten. +-- Prüfabfrage zur Abnahme (KAT-05 Krit. 7 und 10). Im Studio-SQL-Editor oder per psql ausführen. +-- Beschränkt sich auf die Spiele aus startbestand-keys.csv; Spiele, die im Studio angelegt wurden, +-- und das Beispielspiel aus seed.sql zählen nicht mit. Abnahme: kein Verstoß, SB1 mindestens 180. +-- Lokal und in CI ist SB1 nur eine Information, solange noch eingepflegt wird. +with sb(id) as ( + select t.value::uuid from jsonb_array_elements_text('[]'::jsonb) as t(value) +), voc(kind, id, name_de) as ( + select x.kind, x.id, x.name_de from jsonb_to_recordset('[ + {"kind":"genre","id":"91c2b7c9-5a7c-5001-b90f-af05c0a8bc76","name_de":"Strategiespiel"}, + {"kind":"genre","id":"ecd63153-af09-5423-8579-483c4f1dc87c","name_de":"Familienspiel"}, + {"kind":"genre","id":"39c44fac-9323-5695-a02a-019620057a50","name_de":"Partyspiel"}, + {"kind":"genre","id":"7f50e253-41bb-5cde-ad0a-dc48e9c9295f","name_de":"Kooperatives Spiel"}, + {"kind":"genre","id":"503248a5-6424-5e06-aa9a-ec1d43fb0e28","name_de":"Kartenspiel"}, + {"kind":"genre","id":"963e400e-b6c9-5719-b6f0-2440d93fff7d","name_de":"Abstraktes Spiel"}, + {"kind":"genre","id":"aefb13d7-2a29-5901-bb8a-aa3f897a8e20","name_de":"Thematisches Spiel"}, + {"kind":"genre","id":"f7418ea2-db50-5e8c-a737-cab1ee15b34b","name_de":"Wirtschaftsspiel"}, + {"kind":"genre","id":"1b7da3a2-ace2-503f-b632-f697d651e1d0","name_de":"Kriegsspiel"}, + {"kind":"genre","id":"0114584b-4a22-5986-8896-8ee491aad1c7","name_de":"Kinderspiel"}, + {"kind":"genre","id":"71b582a5-c53d-5370-920f-cadc03725324","name_de":"Wortspiel"}, + {"kind":"genre","id":"693bbe28-03e5-554a-9548-ab54f186bffe","name_de":"Deduktionsspiel"}, + {"kind":"genre","id":"72baa1f7-835e-56d7-8341-c1c524148dd4","name_de":"Geschicklichkeitsspiel"}, + {"kind":"genre","id":"d8a5a92f-05b5-5dff-b321-baaecb5803bf","name_de":"Quizspiel"}, + {"kind":"mechanic","id":"00f902bc-e66b-5976-b235-7cd462ee23b6","name_de":"Arbeitereinsatz"}, + {"kind":"mechanic","id":"c7fde702-f7c3-5b81-b2b5-736253974a75","name_de":"Deckbau"}, + {"kind":"mechanic","id":"afb217d3-38bb-5989-8383-07fa200ded35","name_de":"Handkartenmanagement"}, + {"kind":"mechanic","id":"2ca2b8c5-9604-5186-a037-7386cd3f299b","name_de":"Würfeln"}, + {"kind":"mechanic","id":"2d35402f-c714-5cf7-a2e6-b035023ad51a","name_de":"Plättchenlegen"}, + {"kind":"mechanic","id":"83b32eef-078a-5822-91e9-d48157476056","name_de":"Sets sammeln"}, + {"kind":"mechanic","id":"c4cd186c-401b-5cc2-81cb-fcfd544c8a4d","name_de":"Drafting"}, + {"kind":"mechanic","id":"7d327434-bf71-5be2-a659-429c81c54cb5","name_de":"Gebietsmehrheit"}, + {"kind":"mechanic","id":"77565f86-d2e0-51c6-908a-dd1d05a08d26","name_de":"Bieten und Versteigern"}, + {"kind":"mechanic","id":"96dd12a0-f303-50d4-9baf-f56d9d8aa489","name_de":"Handel"}, + {"kind":"mechanic","id":"84019ba4-fc52-5343-97ad-2ea5c4f6ee6e","name_de":"Verhandlung"}, + {"kind":"mechanic","id":"6e49822b-dc6b-553f-b9e8-c5685d1e953b","name_de":"Streckenbau"}, + {"kind":"mechanic","id":"42521d16-2532-5583-8e41-ade8e0a15679","name_de":"Engine Building"}, + {"kind":"mechanic","id":"2d4aac3a-728d-54a2-a2cb-569410a56523","name_de":"Abholen und Liefern"}, + {"kind":"mechanic","id":"31d0ed7c-9dad-58da-8390-55786ee1e6c4","name_de":"Stichspiel"}, + {"kind":"mechanic","id":"1c83518d-9b9f-512c-a71e-585a5650adea","name_de":"Glück herausfordern"}, + {"kind":"mechanic","id":"f53a7662-1580-57e0-87d3-c79ed720a63a","name_de":"Bluffen"}, + {"kind":"mechanic","id":"71a4f344-709e-5536-a5a9-325d40732509","name_de":"Verdeckte Rollen"}, + {"kind":"mechanic","id":"3c02a90b-9cb3-50f8-950a-9464a2767c28","name_de":"Variable Spielerfähigkeiten"}, + {"kind":"mechanic","id":"e49b1223-2605-5c52-98e3-7e0d3e571e98","name_de":"Bewegung auf Raster"}, + {"kind":"mechanic","id":"3f98436d-5aab-56dc-b5e9-4d8a7b0cbfa4","name_de":"Muster bauen"}, + {"kind":"mechanic","id":"de7d374a-d1c6-506b-a92f-c726b06e0563","name_de":"Echtzeit"}, + {"kind":"mechanic","id":"c92e4a17-82f5-5e8a-8932-77dcad0cba29","name_de":"Gedächtnis"}, + {"kind":"mechanic","id":"edec6ec8-5b68-5fc0-892b-8fd739285593","name_de":"Würfeln und Notieren"}, + {"kind":"mechanic","id":"d7ef7709-0a6a-5cbd-b135-086dba038839","name_de":"Kampagne"}, + {"kind":"mechanic","id":"8ed41477-247c-5570-8c61-155e6f827660","name_de":"Aktionspunkte"}, + {"kind":"mechanic","id":"b90c9692-ff39-5170-a1ec-003b35d60950","name_de":"Ressourcenmanagement"}, + {"kind":"mechanic","id":"ef336193-3cfb-5f69-81c6-d57f8f720871","name_de":"Erzählen"}, + {"kind":"mechanic","id":"bc7940fd-3e2f-5932-b99f-31d4f55fd289","name_de":"Gegenseitig ärgern"}, + {"kind":"mechanic","id":"8ec5712b-42be-525a-b60d-1b79379d6f40","name_de":"Spieler scheiden aus"} +]'::jsonb) as x(kind text, id uuid, name_de text) +) +select rule, beschreibung, soll, ist, case when ok is null then 'Information' when ok then 'ok' else 'Verstoß' end as status +from ( + select 1 as ord, 'SB0' as rule, 'Startbestandsspiele in der Datenbank vorhanden' as beschreibung, + 'alle ' || (select count(*) from sb) as soll, n as ist, null::boolean as ok + from (select count(*) as n from public.games g join sb on sb.id = g.id) x + union all + select 2, 'SB1', 'Sichtbare Katalogspiele aus dem Startbestand (Abnahme: mindestens 180)', + '>= 180', n, n >= 180 + from (select count(*) as n from public.catalog_games c join sb on sb.id = c.id) x + union all + select 3, 'SB2', 'Spiele ohne Pflicht-Fakt (Jahr, Spielerzahl, Dauer, Alter, Verlag, Genre, Mechanik)', '0', n, n = 0 + from (select count(*) as n from public.games g join sb on sb.id = g.id + where g.year is null or g.min_players is null or g.max_players is null + or g.min_minutes is null or g.max_minutes is null or g.min_age is null + or not exists (select 1 from public.game_publishers x where x.game_id = g.id) + or not exists (select 1 from public.game_genres x where x.game_id = g.id) + or not exists (select 1 from public.game_mechanics x where x.game_id = g.id)) x + union all + select 4, 'SB4', 'Spiele ohne deutschen Kurztext', '0', n, n = 0 + from (select count(*) as n from public.games g join sb on sb.id = g.id + where g.description is null or g.description_locale is distinct from 'de') x + union all + select 5, 'SB6', 'Spiele mit Cover, Quelle oder BGG-Kennung', '0', n, n = 0 + from (select count(*) as n from public.games g join sb on sb.id = g.id + where g.image_path is not null or g.catalog_source_id is not null or g.bgg_id is not null) x + union all + select 6, 'Info', 'Begriffe mit abweichender ID (im Studio angelegt, bitte prüfen)', '-', n, null::boolean + from (select (select count(*) from public.genres t join voc v on v.kind = 'genre' and v.name_de = t.name_de where t.id <> v.id) + + (select count(*) from public.mechanics t join voc v on v.kind = 'mechanic' and v.name_de = t.name_de where t.id <> v.id) as n) x +) r +order by ord; diff --git a/supabase/catalog/startbestand-keys.csv b/supabase/catalog/startbestand-keys.csv new file mode 100644 index 0000000..ac6e2e7 --- /dev/null +++ b/supabase/catalog/startbestand-keys.csv @@ -0,0 +1 @@ +key,uuid diff --git a/supabase/catalog/startbestand.csv b/supabase/catalog/startbestand.csv new file mode 100644 index 0000000..9b2a0e6 --- /dev/null +++ b/supabase/catalog/startbestand.csv @@ -0,0 +1 @@ +key,title,publishers,year,min_players,max_players,min_minutes,max_minutes,min_age,genres,mechanics,description,description_locale,hidden,facts_source,alt_titles,base_game_key diff --git a/supabase/catalog/startbestand.sql b/supabase/catalog/startbestand.sql new file mode 100644 index 0000000..4274f51 --- /dev/null +++ b/supabase/catalog/startbestand.sql @@ -0,0 +1,6 @@ +-- Erzeugt von supabase/catalog/build_startbestand.ts. Nicht von Hand bearbeiten. + +-- Startbestand (KAT-05). Idempotent bis zum Go-live; danach eingefroren (docs/katalog-pflege.md). +-- Reihenfolge: Migrationen, vocabulary.sql, dann diese Datei. + +-- startbestand.csv enthält keine Spiele. diff --git a/supabase/catalog/testdata/startbestand.csv b/supabase/catalog/testdata/startbestand.csv new file mode 100644 index 0000000..e120cb6 --- /dev/null +++ b/supabase/catalog/testdata/startbestand.csv @@ -0,0 +1,4 @@ +key,title,publishers,year,min_players,max_players,min_minutes,max_minutes,min_age,genres,mechanics,description,description_locale,hidden,facts_source,alt_titles,base_game_key +testspiel-a,Testspiel A,Testverlag,2020,2,4,30,60,8,Testgenre A;Testgenre B,Testmechanik A,Fiktives Testspiel A. Nur für automatische Tests.,de,,Testdaten,, +testspiel-b,Testspiel B,Testverlag;Zweiter Testverlag,2021,1,5,10,20,6,Testgenre A,Testmechanik A;Testmechanik B,"Fiktives Testspiel B, mit Komma.",de,,Testdaten,,testspiel-a +testspiel-c,Testspiel C: Wo's lang geht,TESTVERLAG,1999,3,3,45,45,12,Testgenre B,Testmechanik B,Fiktives Testspiel C mit Apostroph im Titel.,de,ja,Testdaten,, diff --git a/supabase/catalog/testdata/vocabulary.csv b/supabase/catalog/testdata/vocabulary.csv new file mode 100644 index 0000000..91a2f5d --- /dev/null +++ b/supabase/catalog/testdata/vocabulary.csv @@ -0,0 +1,5 @@ +kind,vocab_key,name_de,name_en +genre,test-genre-a,Testgenre A,Test genre A +genre,test-genre-b,Testgenre B,Test genre B +mechanic,test-mechanic-a,Testmechanik A,Test mechanic A +mechanic,test-mechanic-b,Testmechanik B,Test mechanic B diff --git a/supabase/catalog/vocabulary.csv b/supabase/catalog/vocabulary.csv new file mode 100644 index 0000000..380ead4 --- /dev/null +++ b/supabase/catalog/vocabulary.csv @@ -0,0 +1,45 @@ +kind,vocab_key,name_de,name_en +genre,strategy,Strategiespiel,Strategy game +genre,family,Familienspiel,Family game +genre,party,Partyspiel,Party game +genre,cooperative,Kooperatives Spiel,Cooperative game +genre,card-game,Kartenspiel,Card game +genre,abstract,Abstraktes Spiel,Abstract game +genre,thematic,Thematisches Spiel,Thematic game +genre,economic,Wirtschaftsspiel,Economic game +genre,wargame,Kriegsspiel,Wargame +genre,kids,Kinderspiel,Children's game +genre,word,Wortspiel,Word game +genre,deduction,Deduktionsspiel,Deduction game +genre,dexterity,Geschicklichkeitsspiel,Dexterity game +genre,trivia,Quizspiel,Trivia game +mechanic,worker-placement,Arbeitereinsatz,Worker placement +mechanic,deck-building,Deckbau,Deck building +mechanic,hand-management,Handkartenmanagement,Hand management +mechanic,dice-rolling,Würfeln,Dice rolling +mechanic,tile-placement,Plättchenlegen,Tile placement +mechanic,set-collection,Sets sammeln,Set collection +mechanic,drafting,Drafting,Drafting +mechanic,area-control,Gebietsmehrheit,Area control +mechanic,auction,Bieten und Versteigern,Auction and bidding +mechanic,trading,Handel,Trading +mechanic,negotiation,Verhandlung,Negotiation +mechanic,route-building,Streckenbau,Route building +mechanic,engine-building,Engine Building,Engine building +mechanic,pick-up-and-deliver,Abholen und Liefern,Pick-up and deliver +mechanic,trick-taking,Stichspiel,Trick-taking +mechanic,push-your-luck,Glück herausfordern,Push your luck +mechanic,bluffing,Bluffen,Bluffing +mechanic,hidden-roles,Verdeckte Rollen,Hidden roles +mechanic,variable-powers,Variable Spielerfähigkeiten,Variable player powers +mechanic,grid-movement,Bewegung auf Raster,Grid movement +mechanic,pattern-building,Muster bauen,Pattern building +mechanic,real-time,Echtzeit,Real-time +mechanic,memory,Gedächtnis,Memory +mechanic,roll-and-write,Würfeln und Notieren,Roll and write +mechanic,campaign,Kampagne,Campaign +mechanic,action-points,Aktionspunkte,Action points +mechanic,resource-management,Ressourcenmanagement,Resource management +mechanic,storytelling,Erzählen,Storytelling +mechanic,take-that,Gegenseitig ärgern,Take that +mechanic,player-elimination,Spieler scheiden aus,Player elimination diff --git a/supabase/catalog/vocabulary.sql b/supabase/catalog/vocabulary.sql new file mode 100644 index 0000000..40080e1 --- /dev/null +++ b/supabase/catalog/vocabulary.sql @@ -0,0 +1,54 @@ +-- Erzeugt von supabase/catalog/build_startbestand.ts. Nicht von Hand bearbeiten. + +-- Vokabular (Genres, Mechaniken). Idempotent. Ein im Studio angelegter Begriff mit gleichem Namen +-- gewinnt (on conflict do nothing fängt ID- und Namenskonflikte); startbestand.sql löst über name_de auf. + +insert into public.genres (id, name_de, name_en) values + ('91c2b7c9-5a7c-5001-b90f-af05c0a8bc76', 'Strategiespiel', 'Strategy game'), + ('ecd63153-af09-5423-8579-483c4f1dc87c', 'Familienspiel', 'Family game'), + ('39c44fac-9323-5695-a02a-019620057a50', 'Partyspiel', 'Party game'), + ('7f50e253-41bb-5cde-ad0a-dc48e9c9295f', 'Kooperatives Spiel', 'Cooperative game'), + ('503248a5-6424-5e06-aa9a-ec1d43fb0e28', 'Kartenspiel', 'Card game'), + ('963e400e-b6c9-5719-b6f0-2440d93fff7d', 'Abstraktes Spiel', 'Abstract game'), + ('aefb13d7-2a29-5901-bb8a-aa3f897a8e20', 'Thematisches Spiel', 'Thematic game'), + ('f7418ea2-db50-5e8c-a737-cab1ee15b34b', 'Wirtschaftsspiel', 'Economic game'), + ('1b7da3a2-ace2-503f-b632-f697d651e1d0', 'Kriegsspiel', 'Wargame'), + ('0114584b-4a22-5986-8896-8ee491aad1c7', 'Kinderspiel', 'Children''s game'), + ('71b582a5-c53d-5370-920f-cadc03725324', 'Wortspiel', 'Word game'), + ('693bbe28-03e5-554a-9548-ab54f186bffe', 'Deduktionsspiel', 'Deduction game'), + ('72baa1f7-835e-56d7-8341-c1c524148dd4', 'Geschicklichkeitsspiel', 'Dexterity game'), + ('d8a5a92f-05b5-5dff-b321-baaecb5803bf', 'Quizspiel', 'Trivia game') +on conflict do nothing; + +insert into public.mechanics (id, name_de, name_en) values + ('00f902bc-e66b-5976-b235-7cd462ee23b6', 'Arbeitereinsatz', 'Worker placement'), + ('c7fde702-f7c3-5b81-b2b5-736253974a75', 'Deckbau', 'Deck building'), + ('afb217d3-38bb-5989-8383-07fa200ded35', 'Handkartenmanagement', 'Hand management'), + ('2ca2b8c5-9604-5186-a037-7386cd3f299b', 'Würfeln', 'Dice rolling'), + ('2d35402f-c714-5cf7-a2e6-b035023ad51a', 'Plättchenlegen', 'Tile placement'), + ('83b32eef-078a-5822-91e9-d48157476056', 'Sets sammeln', 'Set collection'), + ('c4cd186c-401b-5cc2-81cb-fcfd544c8a4d', 'Drafting', 'Drafting'), + ('7d327434-bf71-5be2-a659-429c81c54cb5', 'Gebietsmehrheit', 'Area control'), + ('77565f86-d2e0-51c6-908a-dd1d05a08d26', 'Bieten und Versteigern', 'Auction and bidding'), + ('96dd12a0-f303-50d4-9baf-f56d9d8aa489', 'Handel', 'Trading'), + ('84019ba4-fc52-5343-97ad-2ea5c4f6ee6e', 'Verhandlung', 'Negotiation'), + ('6e49822b-dc6b-553f-b9e8-c5685d1e953b', 'Streckenbau', 'Route building'), + ('42521d16-2532-5583-8e41-ade8e0a15679', 'Engine Building', 'Engine building'), + ('2d4aac3a-728d-54a2-a2cb-569410a56523', 'Abholen und Liefern', 'Pick-up and deliver'), + ('31d0ed7c-9dad-58da-8390-55786ee1e6c4', 'Stichspiel', 'Trick-taking'), + ('1c83518d-9b9f-512c-a71e-585a5650adea', 'Glück herausfordern', 'Push your luck'), + ('f53a7662-1580-57e0-87d3-c79ed720a63a', 'Bluffen', 'Bluffing'), + ('71a4f344-709e-5536-a5a9-325d40732509', 'Verdeckte Rollen', 'Hidden roles'), + ('3c02a90b-9cb3-50f8-950a-9464a2767c28', 'Variable Spielerfähigkeiten', 'Variable player powers'), + ('e49b1223-2605-5c52-98e3-7e0d3e571e98', 'Bewegung auf Raster', 'Grid movement'), + ('3f98436d-5aab-56dc-b5e9-4d8a7b0cbfa4', 'Muster bauen', 'Pattern building'), + ('de7d374a-d1c6-506b-a92f-c726b06e0563', 'Echtzeit', 'Real-time'), + ('c92e4a17-82f5-5e8a-8932-77dcad0cba29', 'Gedächtnis', 'Memory'), + ('edec6ec8-5b68-5fc0-892b-8fd739285593', 'Würfeln und Notieren', 'Roll and write'), + ('d7ef7709-0a6a-5cbd-b135-086dba038839', 'Kampagne', 'Campaign'), + ('8ed41477-247c-5570-8c61-155e6f827660', 'Aktionspunkte', 'Action points'), + ('b90c9692-ff39-5170-a1ec-003b35d60950', 'Ressourcenmanagement', 'Resource management'), + ('ef336193-3cfb-5f69-81c6-d57f8f720871', 'Erzählen', 'Storytelling'), + ('bc7940fd-3e2f-5932-b99f-31d4f55fd289', 'Gegenseitig ärgern', 'Take that'), + ('8ec5712b-42be-525a-b60d-1b79379d6f40', 'Spieler scheiden aus', 'Player elimination') +on conflict do nothing; diff --git a/supabase/config.toml b/supabase/config.toml index 984ed68..5608a41 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -69,7 +69,10 @@ schema_paths = [] enabled = true # Specifies an ordered list of seed files to load during db reset. # Supports glob patterns relative to supabase directory: "./seeds/*.sql" -sql_paths = ["./seed.sql"] +# Reihenfolge: seed.sql (nur Entwicklung), Vokabular, Startbestand (KAT-05, erzeugt von catalog/build_startbestand.ts). +# Eine nicht gefundene Datei überspringt die CLI nur mit Warnung ("no files matched pattern"), der Reset schlägt dann +# nicht fehl. Fehler in einer Datei brechen den Reset ab (Exit 1); die Datei wird als Ganzes zurückgerollt. +sql_paths = ["./seed.sql", "./catalog/vocabulary.sql", "./catalog/startbestand.sql"] [db.network_restrictions] # Enable management of network restrictions. diff --git a/supabase/migrations/20261103142204_catalog_foundation.sql b/supabase/migrations/20261103142204_catalog_foundation.sql new file mode 100644 index 0000000..d5eb928 --- /dev/null +++ b/supabase/migrations/20261103142204_catalog_foundation.sql @@ -0,0 +1,336 @@ +-- KAT-05 / PR 2a: Katalog-Fundament (Spec docs/specs/2-katalog-daten.md, Abschnitt 3.2). +-- Nur additiv. Enthält: Quellen (catalog_sources), neue Spalten an games, Verlage, +-- Kennzeichnung manuell gepflegter Felder, Ausblenden/Zusammenführen (Spalten), View catalog_games +-- und die neu geschnittenen Spaltenrechte auf games. +-- Nicht enthalten (spätere PRs): admin_merge_games (2b), game_reports und +-- games_visibility_immutable (2c). + +-- --------------------------------------------------------------------------------------------- +-- Quellen: woher Katalogdaten stammen und was davon angezeigt werden darf (Krit. 2, 6; E5) +-- --------------------------------------------------------------------------------------------- +create table public.catalog_sources ( + id uuid primary key default gen_random_uuid(), + key text not null unique check (key ~ '^[a-z0-9_]{2,30}$'), + name text not null check (char_length(name) between 1 and 100), + attribution_text text check (char_length(attribution_text) <= 200), + attribution_url text check (char_length(attribution_url) <= 500 and attribution_url ~ '^https://'), + images_allowed boolean not null default false, + texts_allowed boolean not null default false, + terms_url text check (char_length(terms_url) <= 500 and terms_url ~ '^https://'), + terms_checked_at date, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create trigger set_updated_at before update on public.catalog_sources + for each row execute function public.set_updated_at(); + +-- Referenzdaten, keine Katalogdaten: BGG ist als Quelle bekannt, aber ohne Anzeige-Erlaubnis. +insert into public.catalog_sources (key, name, attribution_text, attribution_url, images_allowed, texts_allowed) +values ('bgg', 'BoardGameGeek', 'Powered by BGG', 'https://boardgamegeek.com', false, false); + +-- --------------------------------------------------------------------------------------------- +-- Verlage (Klärung 4, E7) +-- --------------------------------------------------------------------------------------------- +create table public.publishers ( + id uuid primary key default gen_random_uuid(), + name text not null check (char_length(name) between 1 and 200), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); +create unique index publishers_name_key on public.publishers (lower(name)); + +create table public.game_publishers ( + id uuid primary key default gen_random_uuid(), + game_id uuid not null references public.games (id) on delete cascade, + publisher_id uuid not null references public.publishers (id) on delete cascade, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (game_id, publisher_id) +); +create index game_publishers_publisher_id_idx on public.game_publishers (publisher_id); + +create trigger set_updated_at before update on public.publishers + for each row execute function public.set_updated_at(); +create trigger set_updated_at before update on public.game_publishers + for each row execute function public.set_updated_at(); + +-- --------------------------------------------------------------------------------------------- +-- Neue Spalten an games +-- --------------------------------------------------------------------------------------------- +alter table public.games + add column description_locale text, + add column hidden_at timestamptz, + add column merged_into_id uuid, + add column manual_fields text[] not null default '{}', + add column catalog_source_id uuid, + add column source_ref text, + add column source_url text, + add column source_synced_at timestamptz; + +-- Fremdschlüssel: Ziel eines Zusammenführens und Quellen werden nie gelöscht (H7). +alter table public.games + add constraint games_merged_into_id_fkey + foreign key (merged_into_id) references public.games (id) on delete restrict, + add constraint games_catalog_source_id_fkey + foreign key (catalog_source_id) references public.catalog_sources (id) on delete restrict; + +alter table public.games + add constraint games_description_locale_check + check (description_locale in ('de', 'en')), + add constraint games_description_locale_needs_description + check (description_locale is null or description is not null), + add constraint games_merged_into_not_self + check (merged_into_id <> id), + add constraint games_merged_requires_hidden + check (merged_into_id is null or hidden_at is not null), + -- Schlüsselliste siehe Spec 3.2. Neue Schlüssel ergänzt die jeweilige Story per Migration. + add constraint games_manual_fields_check + check (manual_fields <@ array['title', 'description', 'description_locale', 'year', + 'min_players', 'max_players', 'min_minutes', 'max_minutes', 'min_age', 'image_path', + 'genres', 'mechanics', 'categories', 'publishers']::text[]), + add constraint games_source_ref_check + check (char_length(source_ref) between 1 and 100), + add constraint games_source_url_check + check (char_length(source_url) <= 500 and source_url ~ '^https://'), + add constraint games_source_ref_matches_source + check ((catalog_source_id is null) = (source_ref is null)), + add constraint games_source_details_need_source + check (catalog_source_id is not null or (source_url is null and source_synced_at is null)); + +create unique index games_catalog_source_ref_key on public.games (catalog_source_id, source_ref) + where visibility = 'catalog' and catalog_source_id is not null; +create index games_catalog_source_id_idx on public.games (catalog_source_id); +create index games_merged_into_id_idx on public.games (merged_into_id); +-- Grundlage der Suche (KAT-01 baut darauf aus): nur sichtbare Katalogspiele. +create index games_catalog_listed_idx on public.games (lower(title)) + where visibility = 'catalog' and hidden_at is null and merged_into_id is null; + +-- --------------------------------------------------------------------------------------------- +-- Trigger: manuell gepflegte Felder kennzeichnen (Krit. 1, 4; E4) +-- Beide Funktionen: security invoker, search_path leer. EXECUTE wird entzogen; ein Trigger feuert +-- unabhängig davon. +-- --------------------------------------------------------------------------------------------- +create function public.games_mark_manual_fields() +returns trigger +language plpgsql +set search_path = '' +as $$ +declare + cols constant text[] := array['title', 'description', 'description_locale', 'year', + 'min_players', 'max_players', 'min_minutes', 'max_minutes', 'min_age', 'image_path']; + col text; + n jsonb := to_jsonb(new); + o jsonb; + marked text[] := '{}'; +begin + if new.visibility <> 'catalog' then + return new; + end if; + -- Ausnahme für den späteren Import: nur als Rolle catalog_importer UND mit Einstellung 'on' (der Import setzt sie per set local; das ist Konvention). + -- Ein bloßes set im Studio (Rolle postgres) schaltet die Kennzeichnung nicht ab (W7). + if current_user = 'catalog_importer' + and coalesce(current_setting('shuffle.catalog_import', true), '') = 'on' then + return new; + end if; + + if tg_op = 'UPDATE' then + o := to_jsonb(old); + end if; + foreach col in array cols loop + if tg_op = 'INSERT' then + if n -> col <> 'null'::jsonb then + marked := marked || col; + end if; + elsif n -> col is distinct from o -> col then + marked := marked || col; + end if; + end loop; + + -- Es wird nur ergänzt, nie entfernt: Eine bewusste Änderung nur an manual_fields bleibt erhalten. + if cardinality(marked) > 0 or tg_op = 'INSERT' then + new.manual_fields := array( + select distinct x from unnest(new.manual_fields || marked) as x order by 1); + end if; + return new; +end; +$$; + +create trigger games_mark_manual_fields before insert or update on public.games + for each row execute function public.games_mark_manual_fields(); + +-- Zuordnungen (Genre, Mechanik, Kategorie, Verlag) zählen als Pseudo-Felder. Argument: Feldname. +create function public.game_links_mark_manual() +returns trigger +language plpgsql +set search_path = '' +as $$ +declare + field constant text := tg_argv[0]; + ids uuid[] := '{}'; + gid uuid; +begin + if current_user = 'catalog_importer' + and coalesce(current_setting('shuffle.catalog_import', true), '') = 'on' then + return null; + end if; + + if tg_op in ('INSERT', 'UPDATE') then + ids := ids || new.game_id; + end if; + if tg_op in ('UPDATE', 'DELETE') then + if not (old.game_id = any(ids)) then + ids := ids || old.game_id; + end if; + end if; + + foreach gid in array ids loop + -- Erst prüfen, dann schreiben: Nutzer ändern Zuordnungen privater Spiele und haben kein + -- Schreibrecht auf manual_fields; die UPDATE-Anweisung darf für sie nie ausgeführt werden. + -- Nur bei fehlendem Schlüssel schreiben, damit updated_at des Spiels stabil bleibt. + if exists ( + select 1 from public.games g + where g.id = gid and g.visibility = 'catalog' and not (field = any(g.manual_fields)) + ) then + update public.games g + set manual_fields = array(select distinct x from unnest(g.manual_fields || field) as x order by 1) + where g.id = gid; + end if; + end loop; + return null; +end; +$$; + +create trigger game_links_mark_manual after insert or update or delete on public.game_genres + for each row execute function public.game_links_mark_manual('genres'); +create trigger game_links_mark_manual after insert or update or delete on public.game_mechanics + for each row execute function public.game_links_mark_manual('mechanics'); +create trigger game_links_mark_manual after insert or update or delete on public.game_categories + for each row execute function public.game_links_mark_manual('categories'); +create trigger game_links_mark_manual after insert or update or delete on public.game_publishers + for each row execute function public.game_links_mark_manual('publishers'); + +-- merged_into_id darf nur auf Katalogspiele zeigen (H7). Ein Check-Constraint darf keine andere +-- Zeile lesen. Ohne den Trigger könnte ein Studio-Handgriff einen Verweis auf ein privates Spiel +-- erzeugen, dessen Löschen beim Konto-Löschen an on delete restrict scheitern würde (KTO-05). +create function public.games_merged_into_catalog() +returns trigger +language plpgsql +set search_path = '' +as $$ +declare + target_visibility text; +begin + if new.merged_into_id is not null then + select g.visibility into target_visibility from public.games g where g.id = new.merged_into_id; + -- Existiert das Ziel nicht, meldet der Fremdschlüssel den Fehler (23503). + if target_visibility is not null and target_visibility <> 'catalog' then + raise exception 'merged_into_not_catalog' using errcode = 'P0001'; + end if; + end if; + return new; +end; +$$; + +create trigger games_merged_into_catalog before insert or update of merged_into_id on public.games + for each row execute function public.games_merged_into_catalog(); + +-- Rechte-Regel: neue Funktionen sind für niemanden aufrufbar (nur Eigentümer). +revoke execute on function public.games_mark_manual_fields() from public, anon, authenticated, service_role; +revoke execute on function public.game_links_mark_manual() from public, anon, authenticated, service_role; +revoke execute on function public.games_merged_into_catalog() from public, anon, authenticated, service_role; + +-- --------------------------------------------------------------------------------------------- +-- Rechte und RLS (Supabase-Standardrechte zuerst entziehen) +-- --------------------------------------------------------------------------------------------- +alter table public.catalog_sources enable row level security; +alter table public.publishers enable row level security; +alter table public.game_publishers enable row level security; + +revoke all on public.catalog_sources, public.publishers, public.game_publishers + from anon, authenticated; +grant select on public.catalog_sources, public.publishers, public.game_publishers + to anon, authenticated; +grant insert, update, delete on public.catalog_sources, public.publishers, public.game_publishers + to authenticated; + +create policy catalog_sources_select on public.catalog_sources + for select to anon, authenticated using (true); +-- Schreiben nur Admin (is_admin); in R1 ungenutzt, Pflege über Studio (E10). +create policy catalog_sources_write_admin on public.catalog_sources + for all to authenticated + using (public.is_admin()) with check (public.is_admin()); + +create policy publishers_select on public.publishers + for select to anon, authenticated using (true); +create policy publishers_write_admin on public.publishers + for all to authenticated + using (public.is_admin()) with check (public.is_admin()); + +create policy game_publishers_select on public.game_publishers + for select to anon, authenticated + using (exists (select 1 from public.games g where g.id = game_id)); +create policy game_publishers_write on public.game_publishers + for all to authenticated + using ( + public.is_admin() + or exists (select 1 from public.games g + where g.id = game_id and g.owner_id = (select auth.uid())) + ) + with check ( + public.is_admin() + or exists (select 1 from public.games g + where g.id = game_id and g.owner_id = (select auth.uid())) + ); + +-- games: Schreibrechte spaltenweise. Nutzer können hidden_at, merged_into_id, manual_fields, +-- catalog_source_id und source_* nie setzen, auch nicht an eigenen privaten Spielen (Krit. 9). +-- id fehlt bewusst: Clients wählen keine IDs. select und delete bleiben wie bisher. +revoke insert, update on public.games from authenticated; +grant insert (owner_id, title, description, description_locale, year, min_players, max_players, + min_minutes, max_minutes, min_age, image_path, bgg_id, visibility) + on public.games to authenticated; +grant update (title, description, description_locale, year, min_players, max_players, + min_minutes, max_minutes, min_age, image_path, bgg_id, visibility) + on public.games to authenticated; + +-- --------------------------------------------------------------------------------------------- +-- View catalog_games: was Suche und Entdecken sehen (Krit. 4, E3, E6) +-- Ausgeblendete und zusammengeführte Spiele fehlen. description/image_path nur, wenn die Quelle +-- die Anzeige erlaubt, das Feld manuell gepflegt ist oder es keine Quelle gibt (zweite Sicherung; +-- die erste ist, solche Daten gar nicht zu speichern). +-- security_invoker: die RLS-Regeln von games und catalog_sources gelten für den Aufrufer. +-- --------------------------------------------------------------------------------------------- +create view public.catalog_games with (security_invoker = true) as +select + g.id, + g.title, + g.year, + g.min_players, + g.max_players, + g.min_minutes, + g.max_minutes, + g.min_age, + g.bgg_id, + g.description_locale, + g.created_at, + g.updated_at, + case when g.catalog_source_id is null + or 'description' = any(g.manual_fields) + or s.texts_allowed + then g.description end as description, + case when g.catalog_source_id is null + or 'image_path' = any(g.manual_fields) + or s.images_allowed + then g.image_path end as image_path, + s.key as source_key, + s.name as source_name, + s.attribution_text as source_attribution_text, + s.attribution_url as source_attribution_url, + g.source_url as source_url +from public.games g +left join public.catalog_sources s on s.id = g.catalog_source_id +where g.visibility = 'catalog' and g.hidden_at is null and g.merged_into_id is null; + +revoke all on public.catalog_games from anon, authenticated; +grant select on public.catalog_games to anon, authenticated; diff --git a/supabase/seed.sql b/supabase/seed.sql index 077f05e..8b78cfb 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -1,14 +1,6 @@ -- Beispieldaten nur für die lokale Entwicklung (supabase db reset). Nicht für Produktion. -insert into public.genres (name_de, name_en) values - ('Strategie', 'Strategy'), ('Familie', 'Family'), ('Kooperativ', 'Cooperative'), - ('Party', 'Party'), ('Kartenspiel', 'Card game') -on conflict do nothing; - -insert into public.mechanics (name_de, name_en) values - ('Handkartenmanagement', 'Hand management'), ('Worker Placement', 'Worker placement'), - ('Deckbau', 'Deck building'), ('Würfeln', 'Dice rolling') -on conflict do nothing; - +-- Genres und Mechaniken kommen aus supabase/catalog/vocabulary.sql (KAT-05), nicht von hier: +-- seed.sql läuft zuerst, ein Begriff mit zufälliger ID würde das Vokabular verdrängen. insert into public.categories (name_de, name_en) values ('Wirtschaft', 'Economic'), ('Fantasy', 'Fantasy'), ('Abenteuer', 'Adventure'), ('Zivilisation', 'Civilization') on conflict do nothing; diff --git a/supabase/tests/database/00_schema.test.sql b/supabase/tests/database/00_schema.test.sql index bd39c67..359840d 100644 --- a/supabase/tests/database/00_schema.test.sql +++ b/supabase/tests/database/00_schema.test.sql @@ -2,8 +2,8 @@ begin; select plan(5); -select is((select count(*) from pg_tables where schemaname = 'public'), 17::bigint, - 'R1 hat genau 17 Tabellen'); +select is((select count(*) from pg_tables where schemaname = 'public'), 20::bigint, + 'R1 hat genau 20 Tabellen (17 + catalog_sources, publishers, game_publishers)'); select is_empty($$ select c.relname from pg_class c join pg_namespace n on n.oid = c.relnamespace diff --git a/supabase/tests/database/06_catalog_foundation.test.sql b/supabase/tests/database/06_catalog_foundation.test.sql new file mode 100644 index 0000000..e9c8241 --- /dev/null +++ b/supabase/tests/database/06_catalog_foundation.test.sql @@ -0,0 +1,601 @@ +-- KAT-05 (PR 2a): Katalog-Fundament. Quellen, Verlage, Ausblenden, manual_fields, View catalog_games, +-- Spaltenrechte auf games, Startbestand-Werkzeug (Fixtures) und die SQL-Bausteine aus docs/katalog-pflege.md. +-- Läuft auch mit eingespieltem Startbestand: eigene Fixtures mit festen IDs, gezählt wird nur darüber. +-- Die Include-Dateien in ../support/ (\ir) erzeugt supabase/catalog/build_startbestand.ts aus catalog/testdata/. +begin; +select plan(148); + +insert into auth.users (id, email, raw_user_meta_data) values + ('11111111-1111-1111-1111-111111111111', 'anna@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Anna"}'), + ('22222222-2222-2222-2222-222222222222', 'ben@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Ben"}'); + +-- --------------------------------------------------------------------------------------------- +-- Struktur (KAT-05 Kriterium 2) +-- --------------------------------------------------------------------------------------------- +select col_is_pk('public', 'games', 'id', 'KAT-05 Kriterium 2: games.id ist der Primärschlüssel'); +select col_type_is('public', 'games', 'id', 'uuid', 'KAT-05 Kriterium 2: games.id ist eine uuid'); +select col_has_default('public', 'games', 'id', 'KAT-05 Kriterium 2: games.id hat einen Standardwert (eigene Shuffle-ID)'); +select is((select count(*) from information_schema.columns + where table_schema = 'public' and table_name = 'games' + and column_name in ('description_locale', 'hidden_at', 'merged_into_id', 'manual_fields', + 'catalog_source_id', 'source_ref', 'source_url', 'source_synced_at')), + 8::bigint, 'KAT-05 Kriterium 2: games hat die neuen Spalten'); +select results_eq($$select key, name, images_allowed, texts_allowed from public.catalog_sources where key = 'bgg'$$, + $$values ('bgg'::text, 'BoardGameGeek'::text, false, false)$$, + 'KAT-05 Kriterium 6: Quelle bgg ist angelegt, ohne Anzeige-Erlaubnis für Bilder und Texte'); + +insert into public.games (id, title, visibility) values + ('f6000000-0000-0000-0000-000000000001', 'Fixture Eins', 'catalog'), + ('f6000000-0000-0000-0000-000000000002', 'Fixture Zwei', 'catalog'); + +select lives_ok($$update public.games set id = 'f6000000-0000-0000-0000-0000000000ff', title = 'Fixture Eins' + where id = 'f6000000-0000-0000-0000-000000000001'$$, 'Update mit neuer id läuft ohne Fehler'); +select is((select count(*) from public.games where id = 'f6000000-0000-0000-0000-000000000001'), 1::bigint, + 'KAT-05 Kriterium 2: die ID bleibt bei einem Update unverändert'); + +select throws_ok($$update public.games set catalog_source_id = (select id from public.catalog_sources where key = 'bgg') + where id = 'f6000000-0000-0000-0000-000000000001'$$, '23514', null, + 'KAT-05 Kriterium 2: Quelle ohne source_ref wird abgelehnt'); +select throws_ok($$update public.games set source_ref = '13' where id = 'f6000000-0000-0000-0000-000000000001'$$, + '23514', null, 'KAT-05 Kriterium 2: source_ref ohne Quelle wird abgelehnt'); +select throws_ok($$update public.games set source_url = 'https://example.org/x' where id = 'f6000000-0000-0000-0000-000000000001'$$, + '23514', null, 'source_url ohne Quelle wird abgelehnt'); +update public.games set catalog_source_id = (select id from public.catalog_sources where key = 'bgg'), source_ref = '13' + where id = 'f6000000-0000-0000-0000-000000000001'; +select throws_ok($$update public.games set catalog_source_id = (select id from public.catalog_sources where key = 'bgg'), source_ref = '13' + where id = 'f6000000-0000-0000-0000-000000000002'$$, '23505', null, + 'KAT-05 Kriterium 2: (Quelle, source_ref) ist im Katalog eindeutig'); +select throws_ok($$update public.games set source_url = 'http://example.org/x' where id = 'f6000000-0000-0000-0000-000000000001'$$, + '23514', null, 'source_url muss mit https:// beginnen'); +select throws_ok($$update public.catalog_sources set terms_url = 'http://example.org' where key = 'bgg'$$, + '23514', null, 'KAT-05 Kriterium 2: terms_url muss mit https:// beginnen'); +select throws_ok($$update public.catalog_sources set attribution_url = 'ftp://example.org' where key = 'bgg'$$, + '23514', null, 'attribution_url muss mit https:// beginnen'); +select throws_ok($$insert into public.catalog_sources (key, name) values ('BGG!', 'Falsch')$$, '23514', null, + 'Schlüssel einer Quelle: nur a-z, 0-9, Unterstrich'); +select throws_ok($$delete from public.catalog_sources where key = 'bgg'$$, '23503', null, + 'Eine Quelle mit Spielen wird nicht gelöscht (on delete restrict)'); + +select throws_ok($$update public.games set description_locale = 'fr', description = 'x' where id = 'f6000000-0000-0000-0000-000000000002'$$, + '23514', null, 'description_locale nur de oder en'); +select throws_ok($$update public.games set description_locale = 'de' where id = 'f6000000-0000-0000-0000-000000000002'$$, + '23514', null, 'description_locale nur zusammen mit description'); +select throws_ok($$update public.games set manual_fields = array['title', 'unbekannt'] where id = 'f6000000-0000-0000-0000-000000000002'$$, + '23514', null, 'KAT-05 Kriterium 1: unbekannter Schlüssel in manual_fields wird abgelehnt'); +select throws_ok($$update public.games set merged_into_id = id, hidden_at = now() where id = 'f6000000-0000-0000-0000-000000000002'$$, + '23514', null, 'Ein Spiel kann nicht in sich selbst aufgehen'); +select throws_ok($$update public.games set merged_into_id = 'f6000000-0000-0000-0000-000000000001' where id = 'f6000000-0000-0000-0000-000000000002'$$, + '23514', null, 'Zusammengeführte Spiele müssen ausgeblendet sein'); + +-- --------------------------------------------------------------------------------------------- +-- Trigger games_merged_into_catalog und on delete restrict (KAT-05 Kriterium 4) +-- --------------------------------------------------------------------------------------------- +insert into public.games (id, title, visibility, owner_id) values + ('f6000000-0000-0000-0000-0000000000a1', 'Annas Prototyp', 'private', '11111111-1111-1111-1111-111111111111'), + ('f6000000-0000-0000-0000-0000000000b1', 'Bens Prototyp', 'private', '22222222-2222-2222-2222-222222222222'); +select throws_ok($$update public.games set merged_into_id = 'f6000000-0000-0000-0000-0000000000a1', hidden_at = now() + where id = 'f6000000-0000-0000-0000-000000000002'$$, 'P0001', 'merged_into_not_catalog', + 'KAT-05 Kriterium 4: merged_into_id auf ein privates Spiel wird abgelehnt'); +select throws_ok($$insert into public.games (title, visibility, hidden_at, merged_into_id) + values ('Fixture Drei', 'catalog', now(), 'f6000000-0000-0000-0000-0000000000a1')$$, 'P0001', 'merged_into_not_catalog', + 'merged_into_id auf ein privates Spiel wird auch beim Anlegen abgelehnt'); +select lives_ok($$update public.games set merged_into_id = 'f6000000-0000-0000-0000-000000000001', hidden_at = now() + where id = 'f6000000-0000-0000-0000-000000000002'$$, 'merged_into_id auf ein Katalogspiel ist erlaubt'); +select throws_ok($$delete from public.games where id = 'f6000000-0000-0000-0000-000000000001'$$, '23503', null, + 'KAT-05 Kriterium 4: Ein Zieleintrag mit Verweis wird nicht gelöscht (on delete restrict)'); +update public.games set merged_into_id = null, hidden_at = null where id = 'f6000000-0000-0000-0000-000000000002'; + +-- --------------------------------------------------------------------------------------------- +-- Verlage +-- --------------------------------------------------------------------------------------------- +insert into public.publishers (id, name) values ('f6000000-0000-0000-0000-0000000000c1', 'Fixture Verlag'); +select throws_ok($$insert into public.publishers (name) values ('FIXTURE VERLAG')$$, '23505', null, + 'Verlagsnamen sind unabhängig von der Schreibweise eindeutig'); +select throws_ok($$insert into public.publishers (name) values (repeat('x', 201))$$, '23514', null, + 'Verlagsname höchstens 200 Zeichen'); +select lives_ok($$insert into public.game_publishers (game_id, publisher_id) + values ('f6000000-0000-0000-0000-000000000001', 'f6000000-0000-0000-0000-0000000000c1')$$, + 'Ein Katalogspiel bekommt einen Verlag'); +select throws_ok($$insert into public.game_publishers (game_id, publisher_id) + values ('f6000000-0000-0000-0000-000000000001', 'f6000000-0000-0000-0000-0000000000c1')$$, '23505', null, + 'Das Paar Spiel/Verlag ist eindeutig'); +select lives_ok($$insert into public.game_publishers (game_id, publisher_id) + values ('f6000000-0000-0000-0000-000000000002', 'f6000000-0000-0000-0000-0000000000c1')$$, + 'Ein Verlag hat mehrere Spiele'); + +-- --------------------------------------------------------------------------------------------- +-- manual_fields (KAT-05 Kriterium 1, nur Datenmodell; Kriterium 4) +-- --------------------------------------------------------------------------------------------- +-- Zeile 1: nur Titel gesetzt. Zeile 2: Fakten gesetzt (Spec-F4: alle gesetzten Felder gelten als manuell). +insert into public.games (id, title, year, min_players, description, description_locale, visibility) values + ('f6000000-0000-0000-0000-0000000000d1', 'Fixture Manuell', 1999, 2, 'Eigener Text.', 'de', 'catalog'); +select is((select manual_fields from public.games where id = 'f6000000-0000-0000-0000-0000000000d1'), + array['description', 'description_locale', 'min_players', 'title', 'year'], + 'KAT-05 Kriterium 1: Insert markiert alle gesetzten Felder, sortiert und ohne Duplikate'); +insert into public.games (id, title, visibility) values ('f6000000-0000-0000-0000-0000000000d2', 'Fixture Nur Titel', 'catalog'); +select is((select manual_fields from public.games where id = 'f6000000-0000-0000-0000-0000000000d2'), + array['title'], 'KAT-05 Kriterium 1: Insert mit nur einem gesetzten Feld markiert nur dieses'); + +update public.games set min_age = 8 where id = 'f6000000-0000-0000-0000-0000000000d1'; +select is((select manual_fields from public.games where id = 'f6000000-0000-0000-0000-0000000000d1'), + array['description', 'description_locale', 'min_age', 'min_players', 'title', 'year'], + 'KAT-05 Kriterium 1: Update als postgres ergänzt das geänderte Feld'); +update public.games set manual_fields = array_remove(manual_fields, 'min_age') where id = 'f6000000-0000-0000-0000-0000000000d1'; +select is((select 'min_age' = any(manual_fields) from public.games where id = 'f6000000-0000-0000-0000-0000000000d1'), + false, 'KAT-05 Kriterium 4: Eine bewusst entfernte Markierung bleibt entfernt'); +update public.games set min_age = min_age where id = 'f6000000-0000-0000-0000-0000000000d1'; +select is((select 'min_age' = any(manual_fields) from public.games where id = 'f6000000-0000-0000-0000-0000000000d1'), + false, 'Ein Update ohne Wertänderung markiert das Feld nicht wieder'); +update public.games set min_age = 10, manual_fields = array_remove(manual_fields, 'min_age') where id = 'f6000000-0000-0000-0000-0000000000d1'; +select is((select 'min_age' = any(manual_fields) from public.games where id = 'f6000000-0000-0000-0000-0000000000d1'), + true, 'Wer das Feld ändert, markiert es wieder, auch wenn die Markierung im selben Update entfernt wird'); + +-- set local als postgres schaltet die Kennzeichnung NICHT ab (W7) +set local shuffle.catalog_import = 'on'; +update public.games set min_age = 12 where id = 'f6000000-0000-0000-0000-0000000000d1'; +update public.games set manual_fields = array_remove(manual_fields, 'min_age') where id = 'f6000000-0000-0000-0000-0000000000d1'; +update public.games set min_age = 13 where id = 'f6000000-0000-0000-0000-0000000000d1'; +select is((select 'min_age' = any(manual_fields) from public.games where id = 'f6000000-0000-0000-0000-0000000000d1'), + true, 'KAT-05 Kriterium 1: set local shuffle.catalog_import als postgres schaltet die Kennzeichnung nicht ab'); +reset shuffle.catalog_import; + +-- Privates Spiel: keine Kennzeichnung +insert into public.games (id, title, year, visibility, owner_id) values + ('f6000000-0000-0000-0000-0000000000a2', 'Annas Zweites', 2001, 'private', '11111111-1111-1111-1111-111111111111'); +select is((select manual_fields from public.games where id = 'f6000000-0000-0000-0000-0000000000a2'), + '{}'::text[], 'Private Spiele werden nicht gekennzeichnet'); + +-- Import-Ausnahme: Rolle catalog_importer UND Einstellung on (der Import setzt sie per set local) +create role catalog_importer nologin; +grant catalog_importer to postgres with set true; +grant usage on schema public to catalog_importer; +grant select, insert, update on public.games to catalog_importer; +grant select, insert, update, delete on public.game_genres to catalog_importer; +create policy t_importer_games on public.games for all to catalog_importer using (true) with check (true); +create policy t_importer_links on public.game_genres for all to catalog_importer using (true) with check (true); +insert into public.genres (id, name_de, name_en) values + ('f6000000-0000-0000-0000-0000000000e1', 'Fixture Genre Eins', 'Fixture genre one'), + ('f6000000-0000-0000-0000-0000000000e2', 'Fixture Genre Zwei', 'Fixture genre two'); + +set local role catalog_importer; +set local shuffle.catalog_import = 'on'; +insert into public.games (id, title, year, visibility) values + ('f6000000-0000-0000-0000-0000000000f1', 'Fixture Import', 2010, 'catalog'); +insert into public.game_genres (game_id, genre_id) values + ('f6000000-0000-0000-0000-0000000000f1', 'f6000000-0000-0000-0000-0000000000e1'); +reset shuffle.catalog_import; +reset role; +select is((select manual_fields from public.games where id = 'f6000000-0000-0000-0000-0000000000f1'), + '{}'::text[], 'KAT-05 Kriterium 1: Rolle catalog_importer mit Einstellung on (der Import setzt sie per set local) markiert nichts (Insert und Zuordnung)'); + +set local role catalog_importer; +update public.games set year = 2011 where id = 'f6000000-0000-0000-0000-0000000000f1'; +reset role; +select is((select manual_fields from public.games where id = 'f6000000-0000-0000-0000-0000000000f1'), + array['year'], 'KAT-05 Kriterium 1: Rolle catalog_importer ohne die Einstellung wird gekennzeichnet'); + +-- Zuordnungen als Pseudo-Felder +update public.games set manual_fields = '{}' where id = 'f6000000-0000-0000-0000-0000000000f1'; +insert into public.mechanics (id, name_de, name_en) values ('f6000000-0000-0000-0000-0000000000e3', 'Fixture Mechanik', 'Fixture mechanic'); +insert into public.categories (id, name_de, name_en) values ('f6000000-0000-0000-0000-0000000000e4', 'Fixture Kategorie', 'Fixture category'); +insert into public.game_genres (game_id, genre_id) values ('f6000000-0000-0000-0000-000000000002', 'f6000000-0000-0000-0000-0000000000e1'); +insert into public.game_mechanics (game_id, mechanic_id) values ('f6000000-0000-0000-0000-000000000002', 'f6000000-0000-0000-0000-0000000000e3'); +insert into public.game_categories (game_id, category_id) values ('f6000000-0000-0000-0000-000000000002', 'f6000000-0000-0000-0000-0000000000e4'); +select is((select manual_fields from public.games where id = 'f6000000-0000-0000-0000-000000000002'), + array['categories', 'genres', 'mechanics', 'publishers', 'title'], + 'KAT-05 Kriterium 1: Zuordnungen anlegen markiert genres, mechanics, categories und publishers'); + +update public.games set manual_fields = array['title'] where id = 'f6000000-0000-0000-0000-000000000002'; +update public.game_genres set genre_id = 'f6000000-0000-0000-0000-0000000000e2' + where game_id = 'f6000000-0000-0000-0000-000000000002'; +select is((select manual_fields from public.games where id = 'f6000000-0000-0000-0000-000000000002'), + array['genres', 'title'], 'KAT-05 Kriterium 1: Zuordnung ändern markiert genres'); +update public.games set manual_fields = array['title'] where id = 'f6000000-0000-0000-0000-000000000002'; +delete from public.game_genres where game_id = 'f6000000-0000-0000-0000-000000000002'; +delete from public.game_publishers where game_id = 'f6000000-0000-0000-0000-000000000002'; +select is((select manual_fields from public.games where id = 'f6000000-0000-0000-0000-000000000002'), + array['genres', 'publishers', 'title'], 'KAT-05 Kriterium 1: Zuordnung löschen markiert genres und publishers'); + +-- Stabilität: ist 'genres' schon markiert, schreibt eine neue oder gelöschte Zuordnung die Spielzeile nicht noch einmal. +-- (ctid statt updated_at, weil now() in der Transaktion konstant ist; jedes UPDATE erzeugt eine neue ctid.) +create temp table snap_game on commit drop as + select ctid::text as c from public.games where id = 'f6000000-0000-0000-0000-000000000002'; +insert into public.game_genres (game_id, genre_id) values ('f6000000-0000-0000-0000-000000000002', 'f6000000-0000-0000-0000-0000000000e1'); +select is((select count(*) from public.game_genres where game_id = 'f6000000-0000-0000-0000-000000000002'), 1::bigint, + 'Neue Zuordnung ist angelegt (Trigger hat gefeuert)'); +select is((select 'genres' = any(manual_fields) from public.games where id = 'f6000000-0000-0000-0000-000000000002'), true, + 'genres ist bereits markiert'); +select is((select g.ctid::text from public.games g where g.id = 'f6000000-0000-0000-0000-000000000002'), + (select c from snap_game), 'Eine neue Zuordnung schreibt die Spielzeile nicht erneut, wenn die Markierung schon da ist'); +delete from public.game_genres where game_id = 'f6000000-0000-0000-0000-000000000002'; +select is((select count(*) from public.game_genres where game_id = 'f6000000-0000-0000-0000-000000000002'), 0::bigint, + 'Die Zuordnung ist gelöscht (Trigger hat gefeuert)'); +select is((select g.ctid::text from public.games g where g.id = 'f6000000-0000-0000-0000-000000000002'), + (select c from snap_game), 'Eine gelöschte Zuordnung schreibt die Spielzeile nicht erneut, wenn die Markierung schon da ist'); + +-- --------------------------------------------------------------------------------------------- +-- View catalog_games: Ausblenden, Zusammenführen, Anzeige-Erlaubnis (Kriterien 4 und 6) +-- --------------------------------------------------------------------------------------------- +select is((select count(*) from public.catalog_games where id in + ('f6000000-0000-0000-0000-000000000001', 'f6000000-0000-0000-0000-000000000002', 'f6000000-0000-0000-0000-0000000000a1')), + 2::bigint, 'catalog_games enthält Katalogspiele, keine privaten'); + +insert into public.collection_items (owner_id, game_id) + values ('11111111-1111-1111-1111-111111111111', 'f6000000-0000-0000-0000-000000000002'); + +-- docs/katalog-pflege.md, Abschnitt 4: ausblenden +update public.games set hidden_at = now() +where title = 'Fixture Zwei' and visibility = 'catalog' and hidden_at is null; + +select is((select count(*) from public.catalog_games where id = 'f6000000-0000-0000-0000-000000000002'), 0::bigint, + 'KAT-05 Kriterium 4: Ein ausgeblendetes Spiel fehlt in catalog_games'); +set local role anon; +select is((select count(*) from public.catalog_games where id = 'f6000000-0000-0000-0000-000000000002'), 0::bigint, + 'KAT-05 Kriterium 4: Besucher finden das ausgeblendete Spiel nicht in catalog_games'); +select is((select count(*) from public.games where id = 'f6000000-0000-0000-0000-000000000002'), 1::bigint, + 'KAT-05 Kriterium 4: Das ausgeblendete Spiel bleibt per ID lesbar (Spec-F2)'); +reset role; +select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true); +set local role authenticated; +select is((select count(*) from public.catalog_games where id = 'f6000000-0000-0000-0000-000000000002'), 0::bigint, + 'KAT-05 Kriterium 4: Angemeldete finden das ausgeblendete Spiel nicht in catalog_games'); +select is((select count(*) from public.collection_items where game_id = 'f6000000-0000-0000-0000-000000000002'), 1::bigint, + 'KAT-05 Kriterium 4: Ein Sammlungseintrag zu einem ausgeblendeten Spiel bleibt lesbar'); +reset role; + +-- docs/katalog-pflege.md, Abschnitt 4: wieder einblenden +update public.games set hidden_at = null +where title = 'Fixture Zwei' and visibility = 'catalog'; +select is((select count(*) from public.catalog_games where id = 'f6000000-0000-0000-0000-000000000002'), 1::bigint, + 'KAT-05 Kriterium 4: Wieder eingeblendet ist das Spiel in catalog_games'); + +-- zusammengeführt +update public.games set merged_into_id = 'f6000000-0000-0000-0000-000000000001', hidden_at = now() + where id = 'f6000000-0000-0000-0000-000000000002'; +select is((select count(*) from public.catalog_games where id = 'f6000000-0000-0000-0000-000000000002'), 0::bigint, + 'KAT-05 Kriterium 4: Ein zusammengeführtes Spiel fehlt in catalog_games'); +update public.games set merged_into_id = null, hidden_at = null where id = 'f6000000-0000-0000-0000-000000000002'; + +-- Anzeige-Erlaubnis je Quelle (zweite Sicherung, E6). Ein Import würde manual_fields leer lassen. +insert into public.games (id, title, description, description_locale, image_path, visibility, + catalog_source_id, source_ref, source_url) +values ('f6000000-0000-0000-0000-0000000000f2', 'Fixture Quelle', 'Fremder Text', 'en', 'covers/fremd.png', 'catalog', + (select id from public.catalog_sources where key = 'bgg'), '99', 'https://boardgamegeek.com/boardgame/99'); +update public.games set manual_fields = '{}' where id = 'f6000000-0000-0000-0000-0000000000f2'; + +select results_eq($$select description, image_path from public.catalog_games where id = 'f6000000-0000-0000-0000-0000000000f2'$$, + $$values (null::text, null::text)$$, + 'KAT-05 Kriterium 6: Text und Bild einer Quelle ohne Erlaubnis sind in catalog_games leer'); +select results_eq($$select source_key, source_name, source_attribution_text, source_attribution_url, source_url + from public.catalog_games where id = 'f6000000-0000-0000-0000-0000000000f2'$$, + $$values ('bgg'::text, 'BoardGameGeek'::text, 'Powered by BGG'::text, 'https://boardgamegeek.com'::text, + 'https://boardgamegeek.com/boardgame/99'::text)$$, + 'catalog_games liefert die Quellenangabe'); +update public.catalog_sources set texts_allowed = true where key = 'bgg'; +select results_eq($$select description, image_path from public.catalog_games where id = 'f6000000-0000-0000-0000-0000000000f2'$$, + $$values ('Fremder Text'::text, null::text)$$, + 'KAT-05 Kriterium 6: Mit Text-Erlaubnis erscheint der Text, das Bild weiter nicht'); +update public.catalog_sources set texts_allowed = false, images_allowed = true where key = 'bgg'; +select results_eq($$select description, image_path from public.catalog_games where id = 'f6000000-0000-0000-0000-0000000000f2'$$, + $$values (null::text, 'covers/fremd.png'::text)$$, + 'KAT-05 Kriterium 6: Mit Bild-Erlaubnis erscheint das Bild, der Text nicht'); +update public.catalog_sources set images_allowed = false where key = 'bgg'; +update public.games set manual_fields = array['description', 'image_path'] where id = 'f6000000-0000-0000-0000-0000000000f2'; +select results_eq($$select description, image_path from public.catalog_games where id = 'f6000000-0000-0000-0000-0000000000f2'$$, + $$values ('Fremder Text'::text, 'covers/fremd.png'::text)$$, + 'KAT-05 Kriterium 6: Manuell gepflegte Felder erscheinen auch ohne Erlaubnis der Quelle'); +select is((select description from public.catalog_games where id = 'f6000000-0000-0000-0000-0000000000d1'), 'Eigener Text.', + 'KAT-05 Kriterium 6: Ein Spiel ohne Quelle zeigt seinen Text'); + +-- --------------------------------------------------------------------------------------------- +-- Rechte (KAT-05 Kriterium 9, Sicherheit der Daten Kriterium 2) +-- --------------------------------------------------------------------------------------------- +select is_empty($$select col from unnest(array['id', 'hidden_at', 'merged_into_id', 'manual_fields', 'catalog_source_id', + 'source_ref', 'source_url', 'source_synced_at', 'created_at', 'updated_at']) as col + where has_column_privilege('authenticated', 'public.games', col, 'update') + or has_column_privilege('authenticated', 'public.games', col, 'insert')$$, + 'KAT-05 Kriterium 9: Nutzer haben kein Schreibrecht auf id, hidden_at, merged_into_id, manual_fields, catalog_source_id, source_*'); +select is_empty($$select col from unnest(array['owner_id', 'title', 'description', 'description_locale', 'year', 'min_players', + 'max_players', 'min_minutes', 'max_minutes', 'min_age', 'image_path', 'bgg_id', 'visibility']) as col + where not has_column_privilege('authenticated', 'public.games', col, 'insert') + or (col <> 'owner_id' and not has_column_privilege('authenticated', 'public.games', col, 'update'))$$, + 'Nutzer dürfen die Spalten laut Spec 3.2 anlegen und ändern'); +select is_empty($$select p.proname from pg_proc p join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname in ('games_mark_manual_fields', 'game_links_mark_manual', 'games_merged_into_catalog') + and (has_function_privilege('anon', p.oid, 'execute') or has_function_privilege('authenticated', p.oid, 'execute') + or has_function_privilege('service_role', p.oid, 'execute'))$$, + 'Die neuen Funktionen sind für anon, authenticated und service_role nicht ausführbar'); +select is_empty($$select t from unnest(array['catalog_sources', 'publishers', 'game_publishers']) as t + where not (select relrowsecurity from pg_class where oid = ('public.' || t)::regclass)$$, + 'Die neuen Tabellen haben Row Level Security'); +select is_empty($$select t, p from unnest(array['catalog_sources', 'publishers', 'game_publishers']) as t, + unnest(array['insert', 'update', 'delete', 'truncate', 'references', 'trigger']) as p + where has_table_privilege('anon', 'public.' || t, p) + or (p in ('truncate', 'references', 'trigger') and has_table_privilege('authenticated', 'public.' || t, p))$$, + 'Grant-Matrix: anon hat auf den neuen Tabellen nur select, niemand hat truncate, references oder trigger'); +select is_empty($$select t, p from unnest(array['catalog_sources', 'publishers', 'game_publishers']) as t, + unnest(array['select', 'insert', 'update', 'delete']) as p + where not has_table_privilege('authenticated', 'public.' || t, p) + or (p = 'select' and not has_table_privilege('anon', 'public.' || t, p))$$, + 'Grant-Matrix: authenticated hat select, insert, update, delete (Schutz durch Policies), anon select'); + +-- Besucher +set local role anon; +select ok((select count(*) from public.catalog_sources) >= 1, 'Sicherheit Kriterium 2: Besucher lesen catalog_sources'); +select is((select count(*) from public.publishers where id = 'f6000000-0000-0000-0000-0000000000c1'), 1::bigint, + 'Sicherheit Kriterium 2: Besucher lesen publishers'); +select is((select count(*) from public.game_publishers where game_id = 'f6000000-0000-0000-0000-000000000001'), 1::bigint, + 'Sicherheit Kriterium 2: Besucher lesen game_publishers von Katalogspielen'); +select is((select count(*) from public.catalog_games where id = 'f6000000-0000-0000-0000-000000000001'), 1::bigint, + 'Sicherheit Kriterium 2: Besucher lesen catalog_games'); +select throws_ok($$insert into public.publishers (name) values ('Besucher-Verlag')$$, '42501', null, 'Besucher legen keine Verlage an'); +select throws_ok($$insert into public.catalog_sources (key, name) values ('besucher', 'Besucher')$$, '42501', null, + 'Besucher legen keine Quellen an'); +select throws_ok($$update public.games set title = 'Gehackt' where id = 'f6000000-0000-0000-0000-000000000001'$$, '42501', null, + 'KAT-05 Kriterium 9: Besucher ändern keine Katalogspiele'); +select throws_ok($$delete from public.game_publishers where game_id = 'f6000000-0000-0000-0000-000000000001'$$, '42501', null, + 'Besucher löschen keine Verlagszuordnungen'); +select throws_ok($$update public.catalog_sources set texts_allowed = true$$, '42501', null, 'Besucher ändern keine Quellen'); +select throws_ok($$delete from public.catalog_sources$$, '42501', null, 'Besucher löschen keine Quellen'); +select throws_ok($$update public.publishers set name = 'Gehackt'$$, '42501', null, 'Besucher ändern keine Verlage'); +select throws_ok($$delete from public.publishers$$, '42501', null, 'Besucher löschen keine Verlage'); +select throws_ok($$update public.catalog_games set title = 'Gehackt'$$, '55000', null, 'Die View catalog_games ist für Besucher nicht schreibbar (nicht aktualisierbar)'); +select is_empty($$select p from unnest(array['insert', 'update', 'delete']) as p + where has_table_privilege('anon', 'public.catalog_games', p) or has_table_privilege('authenticated', 'public.catalog_games', p)$$, + 'Kriterium 9: anon und authenticated haben auf catalog_games nur select'); +reset role; + +-- Anna +select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true); +set local role authenticated; +select lives_ok($$update public.games set title = 'Annas Prototyp 2' where id = 'f6000000-0000-0000-0000-0000000000a1'$$, + 'Anna ändert den Titel ihres privaten Spiels'); +select throws_ok($$update public.games set hidden_at = now() where id = 'f6000000-0000-0000-0000-0000000000a1'$$, '42501', null, + 'KAT-05 Kriterium 9: Anna setzt hidden_at nicht, auch nicht an ihrem privaten Spiel'); +select throws_ok($$update public.games set merged_into_id = 'f6000000-0000-0000-0000-000000000001' where id = 'f6000000-0000-0000-0000-0000000000a1'$$, + '42501', null, 'KAT-05 Kriterium 9: Anna setzt merged_into_id nicht'); +select throws_ok($$update public.games set manual_fields = array['title'] where id = 'f6000000-0000-0000-0000-0000000000a1'$$, + '42501', null, 'KAT-05 Kriterium 9: Anna setzt manual_fields nicht'); +select throws_ok($$update public.games set catalog_source_id = (select id from public.catalog_sources limit 1), source_ref = 'x' + where id = 'f6000000-0000-0000-0000-0000000000a1'$$, '42501', null, 'KAT-05 Kriterium 9: Anna setzt catalog_source_id nicht'); +select throws_ok($$update public.games set source_url = 'https://example.org' where id = 'f6000000-0000-0000-0000-0000000000a1'$$, + '42501', null, 'KAT-05 Kriterium 9: Anna setzt source_url nicht'); +select throws_ok($$update public.games set id = gen_random_uuid() where id = 'f6000000-0000-0000-0000-0000000000a1'$$, + '42501', null, 'KAT-05 Kriterium 9: Anna ändert die id nicht'); +select throws_ok($$insert into public.games (id, title, visibility, owner_id) + values (gen_random_uuid(), 'Mit eigener ID', 'private', '11111111-1111-1111-1111-111111111111')$$, '42501', null, + 'KAT-05 Kriterium 9: Anna wählt beim Anlegen keine eigene id'); +select throws_ok($$insert into public.games (title, visibility, owner_id, hidden_at) + values ('Versteckt', 'private', '11111111-1111-1111-1111-111111111111', now())$$, '42501', null, + 'Anna legt kein Spiel mit hidden_at an'); +select lives_ok($$insert into public.games (title, description, year, visibility, owner_id) + values ('Annas Neues', 'Text', 2022, 'private', '11111111-1111-1111-1111-111111111111')$$, 'Anna legt ein privates Spiel an'); +select is((select manual_fields from public.games where title = 'Annas Neues'), '{}'::text[], + 'Annas privates Spiel wird nicht als manuell gepflegt gekennzeichnet'); +select is_empty($$update public.games set title = 'Gehackt' where id = 'f6000000-0000-0000-0000-000000000001' returning 1$$, + 'KAT-05 Kriterium 9: Anna ändert keine Katalogspiele'); +select lives_ok($$insert into public.game_publishers (game_id, publisher_id) + values ('f6000000-0000-0000-0000-0000000000a1', 'f6000000-0000-0000-0000-0000000000c1')$$, + 'Anna ordnet ihrem privaten Spiel einen Verlag zu (Trigger stört nicht)'); +select lives_ok($$insert into public.game_genres (game_id, genre_id) + values ('f6000000-0000-0000-0000-0000000000a1', 'f6000000-0000-0000-0000-0000000000e1')$$, + 'Anna ordnet ihrem privaten Spiel ein Genre zu (Trigger stört nicht)'); +select lives_ok($$delete from public.game_genres where game_id = 'f6000000-0000-0000-0000-0000000000a1'$$, + 'Anna löscht eine Zuordnung ihres privaten Spiels'); +select throws_ok($$insert into public.game_publishers (game_id, publisher_id) + values ('f6000000-0000-0000-0000-000000000001', 'f6000000-0000-0000-0000-0000000000c1')$$, '42501', null, + 'KAT-05 Kriterium 9: Anna ordnet einem Katalogspiel keinen Verlag zu'); +select is_empty($$delete from public.game_publishers where game_id = 'f6000000-0000-0000-0000-000000000001' returning 1$$, + 'KAT-05 Kriterium 9: Anna löscht keine Verlagszuordnung eines Katalogspiels'); +select throws_ok($$insert into public.publishers (name) values ('Annas Verlag')$$, '42501', null, 'Anna legt keine Verlage an'); +select throws_ok($$insert into public.catalog_sources (key, name) values ('anna', 'Anna')$$, '42501', null, 'Anna legt keine Quellen an'); +select is_empty($$update public.catalog_sources set texts_allowed = true returning 1$$, 'Anna ändert keine Quellen'); +select is_empty($$delete from public.catalog_sources returning 1$$, 'Anna löscht keine Quellen'); +select is_empty($$update public.publishers set name = 'Gehackt' returning 1$$, 'Anna ändert keine Verlage'); +select throws_ok($$insert into public.catalog_games (id, title) values (gen_random_uuid(), 'Gehackt')$$, '55000', null, + 'Die View catalog_games ist für Nutzer nicht schreibbar (nicht aktualisierbar)'); +select is((select count(*) from public.catalog_games where id = 'f6000000-0000-0000-0000-0000000000a1'), 0::bigint, + 'catalog_games zeigt auch Annas eigenes privates Spiel nicht'); +reset role; + +-- Gefälschte Admin-Rolle in user_metadata zählt nicht +select set_config('request.jwt.claims', + '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated","user_metadata":{"role":"admin"}}', true); +set local role authenticated; +select throws_ok($$insert into public.publishers (name) values ('Admin-Verlag')$$, '42501', null, + 'KAT-05 Kriterium 9: user_metadata.role = admin gibt keine Rechte'); +select is_empty($$update public.games set title = 'Gehackt' where id = 'f6000000-0000-0000-0000-000000000001' returning 1$$, + 'KAT-05 Kriterium 9: user_metadata.role = admin ändert keine Katalogspiele'); +select throws_ok($$insert into public.catalog_sources (key, name) values ('admin', 'Admin')$$, '42501', null, + 'KAT-05 Kriterium 9: user_metadata.role = admin legt keine Quellen an'); +select is_empty($$update public.catalog_sources set texts_allowed = true returning 1$$, + 'KAT-05 Kriterium 9: user_metadata.role = admin ändert keine Quellen'); +select throws_ok($$insert into public.game_publishers (game_id, publisher_id) + values ('f6000000-0000-0000-0000-000000000002', 'f6000000-0000-0000-0000-0000000000c1')$$, '42501', null, + 'KAT-05 Kriterium 9: user_metadata.role = admin ordnet Katalogspielen keinen Verlag zu'); +reset role; + +-- Ben +select set_config('request.jwt.claims', '{"sub":"22222222-2222-2222-2222-222222222222","role":"authenticated"}', true); +set local role authenticated; +select is((select count(*) from public.game_publishers where game_id = 'f6000000-0000-0000-0000-0000000000a1'), 0::bigint, + 'Ben sieht keine Verlagszuordnungen von Annas privatem Spiel'); +select throws_ok($$insert into public.game_publishers (game_id, publisher_id) + values ('f6000000-0000-0000-0000-0000000000a1', 'f6000000-0000-0000-0000-0000000000c1')$$, '42501', null, + 'Ben ordnet Annas privatem Spiel keinen Verlag zu'); +reset role; + +-- Konto löschen erfasst die privaten Spiele samt Zuordnungen (Kaskaden) +select lives_ok($$delete from auth.users where id = '11111111-1111-1111-1111-111111111111'$$, + 'Das Konto von Anna lässt sich löschen'); +select is((select count(*) from public.games where owner_id = '11111111-1111-1111-1111-111111111111'), 0::bigint, + 'Annas private Spiele sind mit dem Konto gelöscht'); +select is((select count(*) from public.game_publishers where game_id = 'f6000000-0000-0000-0000-0000000000a1'), 0::bigint, + 'Annas Zuordnungen sind mit dem Konto gelöscht'); +select is((select count(*) from public.games where id = 'f6000000-0000-0000-0000-000000000001'), 1::bigint, + 'Katalogspiele bleiben beim Löschen eines Kontos erhalten'); + +-- --------------------------------------------------------------------------------------------- +-- Runbook docs/katalog-pflege.md, Abschnitt 3 und 4 (SQL-Bausteine unverändert, ohne begin/commit) +-- --------------------------------------------------------------------------------------------- +insert into public.genres (name_de, name_en) values ('Strategiespiel', 'Strategy game') on conflict do nothing; + +-- Abschnitt 3: Spiel finden (vorab nachschlagen; das Spiel gibt es hier noch nicht) +select is((select count(*) from ( +select id, title, year, hidden_at from public.games +where visibility = 'catalog' and lower(title) = lower('Beispielspiel Runbook')) f), 0::bigint, 'Runbook 3: Suche vor dem Anlegen findet nichts'); + +-- Abschnitt 3: Neues Katalogspiel mit Verlag und Genre +insert into public.publishers (name) values ('Beispielverlag') + on conflict ((lower(name))) do nothing; + +with neu as ( + insert into public.games (id, title, year, min_players, max_players, min_minutes, max_minutes, min_age, + description, description_locale, visibility) + values ('7a1b2c3d-0000-4000-8000-000000000001', 'Beispielspiel Runbook', 2020, 2, 4, 30, 60, 8, 'Eigener Kurztext.', 'de', 'catalog') + returning id +), verlag as ( + insert into public.game_publishers (game_id, publisher_id) + select neu.id, p.id from neu, public.publishers p where lower(p.name) = lower('Beispielverlag') +) +insert into public.game_genres (game_id, genre_id) +select neu.id, g.id from neu, public.genres g where g.name_de = 'Strategiespiel'; + +select is((select count(*) from public.games where id = '7a1b2c3d-0000-4000-8000-000000000001' and visibility = 'catalog' and owner_id is null), 1::bigint, + 'KAT-05 Kriterium 8: Runbook 3, Spiel anlegen'); +select is((select manual_fields from public.games where title = 'Beispielspiel Runbook'), + array['description', 'description_locale', 'genres', 'max_minutes', 'max_players', 'min_age', 'min_minutes', 'min_players', + 'publishers', 'title', 'year'], + 'KAT-05 Kriterium 8: Runbook 3, das neue Spiel ist als manuell gepflegt gekennzeichnet'); +select is((select count(*) from public.game_publishers gp join public.games g on g.id = gp.game_id + where g.title = 'Beispielspiel Runbook'), 1::bigint, 'Runbook 3: Verlag zugeordnet'); +select is((select count(*) from public.game_genres gg join public.games g on g.id = gg.game_id + where g.title = 'Beispielspiel Runbook'), 1::bigint, 'Runbook 3: Genre zugeordnet'); + +select is((select count(*) from ( +select id, title, year, hidden_at from public.games +where visibility = 'catalog' and lower(title) = lower('Beispielspiel Runbook')) f), 1::bigint, 'Runbook 3: Suche findet das Spiel'); + +-- Abschnitt 3: Fakten ändern +update public.games set min_age = 10, max_minutes = 90 +where id = '7a1b2c3d-0000-4000-8000-000000000001' and visibility = 'catalog'; +select is((select min_age from public.games where title = 'Beispielspiel Runbook'), 10::smallint, 'Runbook 3: Fakten ändern'); + +-- Abschnitt 3: Markierung bewusst entfernen +update public.games set manual_fields = array_remove(manual_fields, 'min_age') +where id = '7a1b2c3d-0000-4000-8000-000000000001' and visibility = 'catalog'; +select is((select 'min_age' = any(manual_fields) from public.games where title = 'Beispielspiel Runbook'), false, + 'Runbook 3: Markierung entfernen'); + +-- Abschnitt 4: ausblenden +update public.games set hidden_at = now() +where id = '7a1b2c3d-0000-4000-8000-000000000001' and visibility = 'catalog' and hidden_at is null; +select is((select count(*) from public.catalog_games where title = 'Beispielspiel Runbook'), 0::bigint, + 'KAT-05 Kriterium 8: Runbook 4, ausgeblendet'); +-- Abschnitt 4: wieder einblenden +update public.games set hidden_at = null +where id = '7a1b2c3d-0000-4000-8000-000000000001' and visibility = 'catalog'; +select is((select count(*) from public.catalog_games where title = 'Beispielspiel Runbook'), 1::bigint, + 'KAT-05 Kriterium 8: Runbook 4, wieder eingeblendet'); + +-- --------------------------------------------------------------------------------------------- +-- Startbestand-Werkzeug mit fiktiven Testdaten (KAT-05 Kriterium 7 und 10) +-- Vorab: ein im Studio angelegter Begriff und Verlag mit gleichem Namen, aber anderer ID bzw. Schreibweise. +-- --------------------------------------------------------------------------------------------- +insert into public.genres (id, name_de, name_en) values + ('f6000000-0000-0000-0000-0000000000e5', 'Testgenre A', 'Test genre A (Studio)'); +insert into public.publishers (id, name) values ('f6000000-0000-0000-0000-0000000000c2', 'TestVerlag'); + +\ir ../support/vocabulary_fixture.inc +\ir ../support/startbestand_fixture.inc + +select is((select count(*) from public.genres where name_de = 'Testgenre A'), 1::bigint, + 'Vokabular: Ein im Studio angelegter Begriff mit gleichem Namen löst keinen Fehler aus'); +select is((select id from public.genres where name_de = 'Testgenre A'), 'f6000000-0000-0000-0000-0000000000e5'::uuid, + 'Vokabular: Der vorhandene Begriff gewinnt'); +select is((select count(*) from public.publishers where lower(name) = 'testverlag'), 1::bigint, + 'Startbestand: Ein Verlag in anderer Schreibweise wird wiederverwendet'); +select is((select count(*) from public.games where title like 'Testspiel %'), 3::bigint, 'KAT-05 Kriterium 7: drei Testspiele eingespielt'); +select is((select id from public.games where title = 'Testspiel A'), + extensions.uuid_generate_v5('6f1d9a52-3c4e-4b8a-9f27-5d0e8c71a3b4', 'game:testspiel-a'), + 'KAT-05 Kriterium 7: gleicher key gibt die gleiche UUID wie uuid_generate_v5 in PostgreSQL'); +select is((select id from public.games where title like 'Testspiel C%'), + extensions.uuid_generate_v5('6f1d9a52-3c4e-4b8a-9f27-5d0e8c71a3b4', 'game:testspiel-c'), + 'Die ID ist unabhängig von Sonderzeichen im Titel'); +select results_eq($$select title, year, min_players, max_players, min_minutes, max_minutes, min_age, description_locale, visibility + from public.games where title = 'Testspiel A'$$, + $$values ('Testspiel A'::text, 2020::smallint, 2::smallint, 4::smallint, 30::smallint, 60::smallint, 8::smallint, 'de'::text, 'catalog'::text)$$, + 'KAT-05 Kriterium 10: Fakten des Testspiels A'); +select is_empty($$select 1 from public.games where title like 'Testspiel %' + and (image_path is not null or catalog_source_id is not null or bgg_id is not null or source_ref is not null or owner_id is not null)$$, + 'KAT-05 Kriterium 10: kein Cover, keine Quelle, keine BGG-Kennung (SB6)'); +select is((select manual_fields from public.games where title = 'Testspiel A'), + array['description', 'description_locale', 'genres', 'max_minutes', 'max_players', 'mechanics', 'min_age', 'min_minutes', + 'min_players', 'publishers', 'title', 'year'], + 'KAT-05 Kriterium 1: Der Startbestand gilt als manuell gepflegt (SB8)'); +select is((select hidden_at is not null from public.games where title like 'Testspiel C%'), true, 'hidden = ja blendet das Spiel aus'); +select is((select hidden_at is null from public.games where title = 'Testspiel A'), true, 'hidden leer lässt hidden_at unberührt'); +select is((select count(*) from public.game_publishers gp join public.games g on g.id = gp.game_id where g.title = 'Testspiel B'), 2::bigint, + 'Testspiel B hat zwei Verlage'); +select is((select count(*) from public.game_genres gg join public.games g on g.id = gg.game_id where g.title = 'Testspiel A'), 2::bigint, + 'Testspiel A hat zwei Genres'); + +-- Abgleich der Zuordnungen und "die Datei gewinnt" vor dem Go-live +update public.games set title = 'Geändert' where title = 'Testspiel A'; +insert into public.game_genres (game_id, genre_id) + select g.id, t.id from public.games g, public.genres t where g.title = 'Testspiel B' and t.name_de = 'Testgenre B'; +delete from public.game_genres where game_id = (select id from public.games where title = 'Geändert') + and genre_id = (select id from public.genres where name_de = 'Testgenre B'); +\ir ../support/startbestand_fixture.inc +select is((select count(*) from public.games where title = 'Testspiel A'), 1::bigint, 'Zweites Einspielen stellt geänderte Fakten wieder her'); +select results_eq($$select t.name_de from public.game_genres gg join public.games g on g.id = gg.game_id + join public.genres t on t.id = gg.genre_id where g.title = 'Testspiel A' order by 1$$, + $$values ('Testgenre A'::text), ('Testgenre B'::text)$$, 'Zweites Einspielen ergänzt fehlende Zuordnungen'); +select results_eq($$select t.name_de from public.game_genres gg join public.games g on g.id = gg.game_id + join public.genres t on t.id = gg.genre_id where g.title = 'Testspiel B' order by 1$$, + $$values ('Testgenre A'::text)$$, 'Zweites Einspielen entfernt nicht mehr gewünschte Zuordnungen'); + +-- Idempotenz: ein weiteres Einspielen ändert nichts (ctid statt updated_at, weil now() in der Transaktion konstant ist) +create temp table snap_rows on commit drop as + select 'g' as t, id::text as a, null::text as b, ctid::text as c, updated_at as u from public.games where title like 'Testspiel %' + union all select 'gg', game_id::text, genre_id::text, ctid::text, updated_at from public.game_genres + where game_id in (select id from public.games where title like 'Testspiel %') + union all select 'gm', game_id::text, mechanic_id::text, ctid::text, updated_at from public.game_mechanics + where game_id in (select id from public.games where title like 'Testspiel %') + union all select 'gp', game_id::text, publisher_id::text, ctid::text, updated_at from public.game_publishers + where game_id in (select id from public.games where title like 'Testspiel %') + union all select 'p', id::text, null, ctid::text, updated_at from public.publishers where lower(name) in ('testverlag', 'zweiter testverlag') + union all select 'v', id::text, null, ctid::text, updated_at from public.genres where name_de like 'Testgenre %' + union all select 'm', id::text, null, ctid::text, updated_at from public.mechanics where name_de like 'Testmechanik %'; +\ir ../support/vocabulary_fixture.inc +\ir ../support/startbestand_fixture.inc +select is((select count(*) from snap_rows), 21::bigint, 'Schnappschuss enthält die erwarteten Zeilen (3 Spiele, 12 Zuordnungen, 2 Verlage, 2 Genres, 2 Mechaniken)'); +select is_empty($$select * from snap_rows s where not exists (select 1 from ( + select 'g' as t, id::text as a, null::text as b, ctid::text as c, updated_at as u from public.games + union all select 'gg', game_id::text, genre_id::text, ctid::text, updated_at from public.game_genres + union all select 'gm', game_id::text, mechanic_id::text, ctid::text, updated_at from public.game_mechanics + union all select 'gp', game_id::text, publisher_id::text, ctid::text, updated_at from public.game_publishers + union all select 'p', id::text, null, ctid::text, updated_at from public.publishers + union all select 'v', id::text, null, ctid::text, updated_at from public.genres + union all select 'm', id::text, null, ctid::text, updated_at from public.mechanics) n + where n.t = s.t and n.a = s.a and n.b is not distinct from s.b and n.c = s.c and n.u = s.u)$$, + 'KAT-05 Kriterium 7: Erneutes Einspielen ändert keine Zeile (Spiele, Zuordnungen, Verlage, Begriffe)'); + +-- Prüfabfrage checks.sql (Fixture-Variante derselben Abfrage) +\ir ../support/checks_fixture.inc +select results_eq($$select rule, ist::int, status from checks_result order by rule$$, + $$values ('Info'::text, 1, 'Information'::text), ('SB0', 3, 'Information'), ('SB1', 2, 'Verstoß'), + ('SB2', 0, 'ok'), ('SB4', 0, 'ok'), ('SB6', 0, 'ok')$$, + 'KAT-05 Kriterium 10: checks.sql meldet für die Testspiele keinen Verstoß bei SB2, SB4, SB6 (SB1 liegt lokal erwartbar unter 180)'); +update public.games set min_minutes = null where title = 'Testspiel A'; +update public.games set bgg_id = 4711 where title = 'Testspiel B'; +update public.games set description = null, description_locale = null where title like 'Testspiel C%'; +select results_eq($$select rule, ist::int, status from checks_result where rule in ('SB2', 'SB4', 'SB6') order by rule$$, + $$values ('SB2'::text, 1, 'Verstoß'::text), ('SB4', 1, 'Verstoß'), ('SB6', 1, 'Verstoß')$$, + 'KAT-05 Kriterium 10: checks.sql meldet fehlende Dauer, gesetzte BGG-Kennung und fehlenden Kurztext'); +insert into public.games (title, visibility) values ('Kein Startbestand', 'catalog'); +select is((select ist::int from checks_result where rule = 'SB2'), 1, + 'KAT-05 Kriterium 10: Ein Spiel außerhalb des Startbestands wird von checks.sql ignoriert'); + +select * from finish(); +rollback; diff --git a/supabase/tests/support/checks_fixture.inc b/supabase/tests/support/checks_fixture.inc new file mode 100644 index 0000000..caaedd7 --- /dev/null +++ b/supabase/tests/support/checks_fixture.inc @@ -0,0 +1,49 @@ +-- Erzeugt von supabase/catalog/build_startbestand.ts. Nicht von Hand bearbeiten. +-- Include für pgTAP (\ir), keine Test-Datei. Dieselbe Abfrage wie checks.sql, aber mit den IDs der +-- Testspiele aus supabase/catalog/testdata/. +create temp view checks_result as +with sb(id) as ( + select t.value::uuid from jsonb_array_elements_text('[ + "5189ad0e-ab5b-5500-a3e0-a0b3671cdf14", + "6577717f-4c1f-5829-af22-ae6bb37c8394", + "c3c6f0fd-43bc-5fb9-b692-3be7770b6cc3" +]'::jsonb) as t(value) +), voc(kind, id, name_de) as ( + select x.kind, x.id, x.name_de from jsonb_to_recordset('[ + {"kind":"genre","id":"b685bcce-91da-5bdb-b5bc-5ccb45e2445c","name_de":"Testgenre A"}, + {"kind":"genre","id":"4af96f83-6ee7-51e6-8ee0-f9554ca9bd4e","name_de":"Testgenre B"}, + {"kind":"mechanic","id":"d27d168e-f05f-5781-8ccf-2443e4224795","name_de":"Testmechanik A"}, + {"kind":"mechanic","id":"17182118-57e3-520c-8ff9-d4cce37a17ce","name_de":"Testmechanik B"} +]'::jsonb) as x(kind text, id uuid, name_de text) +) +select rule, beschreibung, soll, ist, case when ok is null then 'Information' when ok then 'ok' else 'Verstoß' end as status +from ( + select 1 as ord, 'SB0' as rule, 'Startbestandsspiele in der Datenbank vorhanden' as beschreibung, + 'alle ' || (select count(*) from sb) as soll, n as ist, null::boolean as ok + from (select count(*) as n from public.games g join sb on sb.id = g.id) x + union all + select 2, 'SB1', 'Sichtbare Katalogspiele aus dem Startbestand (Abnahme: mindestens 180)', + '>= 180', n, n >= 180 + from (select count(*) as n from public.catalog_games c join sb on sb.id = c.id) x + union all + select 3, 'SB2', 'Spiele ohne Pflicht-Fakt (Jahr, Spielerzahl, Dauer, Alter, Verlag, Genre, Mechanik)', '0', n, n = 0 + from (select count(*) as n from public.games g join sb on sb.id = g.id + where g.year is null or g.min_players is null or g.max_players is null + or g.min_minutes is null or g.max_minutes is null or g.min_age is null + or not exists (select 1 from public.game_publishers x where x.game_id = g.id) + or not exists (select 1 from public.game_genres x where x.game_id = g.id) + or not exists (select 1 from public.game_mechanics x where x.game_id = g.id)) x + union all + select 4, 'SB4', 'Spiele ohne deutschen Kurztext', '0', n, n = 0 + from (select count(*) as n from public.games g join sb on sb.id = g.id + where g.description is null or g.description_locale is distinct from 'de') x + union all + select 5, 'SB6', 'Spiele mit Cover, Quelle oder BGG-Kennung', '0', n, n = 0 + from (select count(*) as n from public.games g join sb on sb.id = g.id + where g.image_path is not null or g.catalog_source_id is not null or g.bgg_id is not null) x + union all + select 6, 'Info', 'Begriffe mit abweichender ID (im Studio angelegt, bitte prüfen)', '-', n, null::boolean + from (select (select count(*) from public.genres t join voc v on v.kind = 'genre' and v.name_de = t.name_de where t.id <> v.id) + + (select count(*) from public.mechanics t join voc v on v.kind = 'mechanic' and v.name_de = t.name_de where t.id <> v.id) as n) x +) r +order by ord; diff --git a/supabase/tests/support/startbestand_fixture.inc b/supabase/tests/support/startbestand_fixture.inc new file mode 100644 index 0000000..09a6a2f --- /dev/null +++ b/supabase/tests/support/startbestand_fixture.inc @@ -0,0 +1,100 @@ +-- Erzeugt von supabase/catalog/build_startbestand.ts. Nicht von Hand bearbeiten. + +-- Startbestand (KAT-05). Idempotent bis zum Go-live; danach eingefroren (docs/katalog-pflege.md). +-- Reihenfolge: Migrationen, vocabulary.sql, dann diese Datei. + +do $guard$ +declare + missing text; +begin + select string_agg(n, ', ') into missing from jsonb_array_elements_text('["Testgenre A","Testgenre B"]'::jsonb) as t(n) + where not exists (select 1 from public.genres g where g.name_de = t.n); + if missing is not null then + raise exception 'startbestand: Genre fehlt in der Datenbank (vocabulary.sql eingespielt?): %', missing; + end if; + select string_agg(n, ', ') into missing from jsonb_array_elements_text('["Testmechanik A","Testmechanik B"]'::jsonb) as t(n) + where not exists (select 1 from public.mechanics m where m.name_de = t.n); + if missing is not null then + raise exception 'startbestand: Mechanik fehlt in der Datenbank (vocabulary.sql eingespielt?): %', missing; + end if; +end +$guard$; + +-- Verlage +insert into public.publishers (name) values + ('Testverlag'), + ('Zweiter Testverlag') +on conflict ((lower(name))) do nothing; + +-- Spiele (unveränderte Zeilen werden nicht angefasst) +insert into public.games (id, title, description, description_locale, year, min_players, max_players, min_minutes, max_minutes, min_age, visibility) values + ('5189ad0e-ab5b-5500-a3e0-a0b3671cdf14', 'Testspiel A', 'Fiktives Testspiel A. Nur für automatische Tests.', 'de', 2020, 2, 4, 30, 60, 8, 'catalog'), + ('6577717f-4c1f-5829-af22-ae6bb37c8394', 'Testspiel B', 'Fiktives Testspiel B, mit Komma.', 'de', 2021, 1, 5, 10, 20, 6, 'catalog'), + ('c3c6f0fd-43bc-5fb9-b692-3be7770b6cc3', 'Testspiel C: Wo''s lang geht', 'Fiktives Testspiel C mit Apostroph im Titel.', 'de', 1999, 3, 3, 45, 45, 12, 'catalog') +on conflict (id) do update set + title = excluded.title, + description = excluded.description, + description_locale = excluded.description_locale, + year = excluded.year, + min_players = excluded.min_players, + max_players = excluded.max_players, + min_minutes = excluded.min_minutes, + max_minutes = excluded.max_minutes, + min_age = excluded.min_age +where (games.title, games.description, games.description_locale, games.year, games.min_players, games.max_players, games.min_minutes, games.max_minutes, games.min_age) + is distinct from (excluded.title, excluded.description, excluded.description_locale, excluded.year, excluded.min_players, excluded.max_players, excluded.min_minutes, excluded.max_minutes, excluded.min_age); + +-- hidden = ja: ausblenden, nichts löschen +update public.games set hidden_at = now() +where hidden_at is null and id = any(array['c3c6f0fd-43bc-5fb9-b692-3be7770b6cc3']::uuid[]); + +-- Zuordnung Genre: fehlende anlegen, nicht mehr gewünschte löschen +with want(game_id, term) as (values + ('5189ad0e-ab5b-5500-a3e0-a0b3671cdf14'::uuid, 'Testgenre A'), + ('5189ad0e-ab5b-5500-a3e0-a0b3671cdf14'::uuid, 'Testgenre B'), + ('6577717f-4c1f-5829-af22-ae6bb37c8394'::uuid, 'Testgenre A'), + ('c3c6f0fd-43bc-5fb9-b692-3be7770b6cc3'::uuid, 'Testgenre B') +), resolved as ( + select w.game_id, t.id as genre_id from want w join public.genres t on t.name_de = w.term +), removed as ( + delete from public.game_genres l + where l.game_id = any(array['5189ad0e-ab5b-5500-a3e0-a0b3671cdf14', '6577717f-4c1f-5829-af22-ae6bb37c8394', 'c3c6f0fd-43bc-5fb9-b692-3be7770b6cc3']::uuid[]) + and not exists (select 1 from resolved r where r.game_id = l.game_id and r.genre_id = l.genre_id) +) +insert into public.game_genres (game_id, genre_id) +select game_id, genre_id from resolved +on conflict (game_id, genre_id) do nothing; + +-- Zuordnung Mechanik: fehlende anlegen, nicht mehr gewünschte löschen +with want(game_id, term) as (values + ('5189ad0e-ab5b-5500-a3e0-a0b3671cdf14'::uuid, 'Testmechanik A'), + ('6577717f-4c1f-5829-af22-ae6bb37c8394'::uuid, 'Testmechanik A'), + ('6577717f-4c1f-5829-af22-ae6bb37c8394'::uuid, 'Testmechanik B'), + ('c3c6f0fd-43bc-5fb9-b692-3be7770b6cc3'::uuid, 'Testmechanik B') +), resolved as ( + select w.game_id, t.id as mechanic_id from want w join public.mechanics t on t.name_de = w.term +), removed as ( + delete from public.game_mechanics l + where l.game_id = any(array['5189ad0e-ab5b-5500-a3e0-a0b3671cdf14', '6577717f-4c1f-5829-af22-ae6bb37c8394', 'c3c6f0fd-43bc-5fb9-b692-3be7770b6cc3']::uuid[]) + and not exists (select 1 from resolved r where r.game_id = l.game_id and r.mechanic_id = l.mechanic_id) +) +insert into public.game_mechanics (game_id, mechanic_id) +select game_id, mechanic_id from resolved +on conflict (game_id, mechanic_id) do nothing; + +-- Zuordnung Verlag: fehlende anlegen, nicht mehr gewünschte löschen +with want(game_id, term) as (values + ('5189ad0e-ab5b-5500-a3e0-a0b3671cdf14'::uuid, 'Testverlag'), + ('6577717f-4c1f-5829-af22-ae6bb37c8394'::uuid, 'Testverlag'), + ('6577717f-4c1f-5829-af22-ae6bb37c8394'::uuid, 'Zweiter Testverlag'), + ('c3c6f0fd-43bc-5fb9-b692-3be7770b6cc3'::uuid, 'TESTVERLAG') +), resolved as ( + select w.game_id, t.id as publisher_id from want w join public.publishers t on lower(t.name) = lower(w.term) +), removed as ( + delete from public.game_publishers l + where l.game_id = any(array['5189ad0e-ab5b-5500-a3e0-a0b3671cdf14', '6577717f-4c1f-5829-af22-ae6bb37c8394', 'c3c6f0fd-43bc-5fb9-b692-3be7770b6cc3']::uuid[]) + and not exists (select 1 from resolved r where r.game_id = l.game_id and r.publisher_id = l.publisher_id) +) +insert into public.game_publishers (game_id, publisher_id) +select game_id, publisher_id from resolved +on conflict (game_id, publisher_id) do nothing; diff --git a/supabase/tests/support/vocabulary_fixture.inc b/supabase/tests/support/vocabulary_fixture.inc new file mode 100644 index 0000000..b365fd4 --- /dev/null +++ b/supabase/tests/support/vocabulary_fixture.inc @@ -0,0 +1,14 @@ +-- Erzeugt von supabase/catalog/build_startbestand.ts. Nicht von Hand bearbeiten. + +-- Vokabular (Genres, Mechaniken). Idempotent. Ein im Studio angelegter Begriff mit gleichem Namen +-- gewinnt (on conflict do nothing fängt ID- und Namenskonflikte); startbestand.sql löst über name_de auf. + +insert into public.genres (id, name_de, name_en) values + ('b685bcce-91da-5bdb-b5bc-5ccb45e2445c', 'Testgenre A', 'Test genre A'), + ('4af96f83-6ee7-51e6-8ee0-f9554ca9bd4e', 'Testgenre B', 'Test genre B') +on conflict do nothing; + +insert into public.mechanics (id, name_de, name_en) values + ('d27d168e-f05f-5781-8ccf-2443e4224795', 'Testmechanik A', 'Test mechanic A'), + ('17182118-57e3-520c-8ff9-d4cce37a17ce', 'Testmechanik B', 'Test mechanic B') +on conflict do nothing;