feat(appwrite): Schema für Galerie, Meldungen und Galerie-Bilder

Setup-Skript um Tabellen gallery und reports (Spalten boolean/integer/enum,
Volltext- und Key-Indizes) sowie den Bucket gallery-images erweitert.
DESIRED auf tables/buckets umgebaut, Vergleich je Spaltentyp und Index,
Indizes erst nach available-Spalten. Console-Anleitung um Abschnitt
Community (JWT an, Label admin, Key aircard-api) ergänzt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 13:50:54 +00:00
1 parent 25ff496548
commit 9e4f4ffacc
3 files changed
+803 -127

No files matched your search

+40 -5
View File
@@ -2,11 +2,11 @@
Klickliste für Kenso. Dauer: etwa 5 Minuten. Console: <https://appwrite.webklar.com/console> Klickliste für Kenso. Dauer: etwa 5 Minuten. Console: <https://appwrite.webklar.com/console>
Alles passiert in der bestehenden Organisation. Das Schema (Datenbank, Tabelle, Bucket) legt danach das Skript `web/scripts/appwrite-setup.mjs` an – dafür musst du nichts anklicken. Alles passiert in der bestehenden Organisation. Das Schema (Datenbank, Tabellen `cards`, `gallery`, `reports` mit Spalten und Indizes, Buckets `card-images` und `gallery-images`) legt danach das Skript `web/scripts/appwrite-setup.mjs` an – dafür musst du nichts anklicken.
**Hinweis:** Das Projekt wurde mit der automatisch generierten ID `6ac2130a00289343d6d5` angelegt (nicht mit einer selbst gewählten ID). Diese ID steht in `/home/webklar/apps/.env` unter `AIRCARD_APPWRITE_PROJECT_ID`; das Skript liest sie von dort, der Wert muss beim Aufruf verfügbar sein. **Hinweis:** Das Projekt wurde mit der automatisch generierten ID `6ac2130a00289343d6d5` angelegt (nicht mit einer selbst gewählten ID). Diese ID steht in `/home/webklar/apps/.env` unter `AIRCARD_APPWRITE_PROJECT_ID`; das Skript liest sie von dort, der Wert muss beim Aufruf verfügbar sein.
**Stand der Einstellungen:** Schritt 2 (Web-Plattform `aircard-ios.org`) ist Voraussetzung, ohne sie lehnt Appwrite die Anfragen der Seite ab. Die Schritte 3 und 4 sind empfohlene Härtung. Ob sie in der Console umgesetzt sind, prüft kein Skript (`--check` deckt nur Schema, Tabelle und Bucket ab). Solange du sie nicht selbst gesetzt hast, läuft das Live-Projekt mit den Appwrite-Standards für Auth und Dienste. Das Schema und die Zeilen-/Dateirechte sind davon unabhängig. **Stand der Einstellungen:** Schritt 2 (Web-Plattform `aircard-ios.org`) ist Voraussetzung, ohne sie lehnt Appwrite die Anfragen der Seite ab. Die Schritte 3 und 4 sind empfohlene Härtung. Ob sie in der Console umgesetzt sind, prüft kein Skript (`--check` deckt nur das Schema ab: Tabellen, Spalten, Indizes, Buckets). Solange du sie nicht selbst gesetzt hast, läuft das Live-Projekt mit den Appwrite-Standards für Auth und Dienste. Das Schema und die Zeilen-/Dateirechte sind davon unabhängig.
## 1. Projekt anlegen ## 1. Projekt anlegen
@@ -29,7 +29,7 @@ Projekt `AirCard` → **Overview** → **Integrations** → **Platforms** → **
Linke Leiste → **Auth** → **Settings**. Registrieren und Anmelden funktionieren auch mit den Standardwerten; die folgenden Einstellungen begrenzen nur, was Besucher mit dem Projekt anstellen können. Linke Leiste → **Auth** → **Settings**. Registrieren und Anmelden funktionieren auch mit den Standardwerten; die folgenden Einstellungen begrenzen nur, was Besucher mit dem Projekt anstellen können.
1. Bei **Auth methods** nur **Email/Password** eingeschaltet lassen. Ausschalten: **Magic URL**, **Email OTP**, **Phone**, **Anonymous**, **JWT** (steht dort auch **Invites**: ebenfalls aus). 1. Bei **Auth methods** nur **Email/Password** eingeschaltet lassen. Ausschalten: **Magic URL**, **Email OTP**, **Phone**, **Anonymous** (steht dort auch **Invites**: ebenfalls aus). **JWT** bleibt **an** – ohne JWT kann die Community-API (Abschnitt „Community“) die Nutzer nicht prüfen.
2. **Session duration**: `30` Tage → **Update**. 2. **Session duration**: `30` Tage → **Update**.
3. **Max sessions** (Sitzungen pro Nutzer): `5` → **Update**. 3. **Max sessions** (Sitzungen pro Nutzer): `5` → **Update**.
4. **Limit** (Nutzerlimit): `2000` → **Update**. 4. **Limit** (Nutzerlimit): `2000` → **Update**.
@@ -58,6 +58,8 @@ Dieser Key gehört nur dem Setup-Skript. Die Website selbst braucht ihn nicht.
4. **Create** klicken. 4. **Create** klicken.
5. Auf der Seite des neuen Keys den **Secret**-Wert kopieren (Auge-Symbol zum Einblenden, dann kopieren). 5. Auf der Seite des neuen Keys den **Secret**-Wert kopieren (Auge-Symbol zum Einblenden, dann kopieren).
Die Indizes der Tabellen `gallery` und `reports` legt das Skript mit denselben Scopes an; ein eigener Index-Scope ist nicht nötig.
## 6. Key auf dem Server eintragen ## 6. Key auf dem Server eintragen
Der Key gehört in die Datei `/home/webklar/apps/.env`, nirgendwo sonst. Der Key gehört in die Datei `/home/webklar/apps/.env`, nirgendwo sonst.
@@ -73,6 +75,39 @@ Der Key gehört in die Datei `/home/webklar/apps/.env`, nirgendwo sonst.
3. Speichern (in `nano`: Strg+O, Enter, Strg+X). Andere Zeilen nicht verändern. 3. Speichern (in `nano`: Strg+O, Enter, Strg+X). Andere Zeilen nicht verändern.
4. Den Key möglichst nicht im Chat teilen (wer ihn dort einfügt, gibt ihn in den Verlauf), nicht in Screenshots zeigen und nirgends einchecken. Das Skript bekommt ihn über `node --env-file=/home/webklar/apps/.env`, dafür muss ihn niemand sonst sehen. 4. Den Key möglichst nicht im Chat teilen (wer ihn dort einfügt, gibt ihn in den Verlauf), nicht in Screenshots zeigen und nirgends einchecken. Das Skript bekommt ihn über `node --env-file=/home/webklar/apps/.env`, dafür muss ihn niemand sonst sehen.
## 7. Bescheid geben ## 7. Community
Schreib Claude „Console fertig“. Danach legt Claude Datenbank `aircard`, Tabelle `cards` und Bucket `card-images` mit dem Skript an und prüft sie. Für die Community-Galerie (Tabellen `gallery` und `reports`, Bucket `gallery-images`, API-Dienst `aircard-api`) sind drei Dinge in der Console nötig. Das Schema dazu legt dasselbe Skript an (`node --env-file=/home/webklar/apps/.env web/scripts/appwrite-setup.mjs`, vorher mit `--check` ansehen, was fehlt).
### 7.1 JWT muss an sein
JWT muss **an** sein – falls in Schritt 3 ausgeschaltet, wieder einschalten. Auth → **Settings** → **Auth methods** → **JWT** einschalten. Die API prüft damit, wer Veröffentlichen, Zurückziehen oder eine Admin-Aktion auslöst.
### 7.2 Label `admin` am eigenen Konto
Das Label `admin` macht dich zum Admin der Galerie: Die Website zeigt dir dann Ein-/Ausblenden-Knöpfe und den Galerie-Filter „Gemeldet“/„Ausgeblendet“.
1. Linke Leiste → **Auth** → **Users** → dein Konto öffnen.
2. Bei **Labels** den Wert `admin` eintragen → **Update**.
### 7.3 API-Key `aircard-api` anlegen
Dieser Key gehört nur dem API-Dienst `aircard-api`, nicht dem Setup-Skript und nicht der Website.
1. **Overview** → **Integrations** → **API keys** → **Create API key**.
2. Name: `aircard-api`. Ablauf (Expiration): **Never**.
3. Unter **Scopes** genau diese vier ankreuzen:
- `rows.read` und `rows.write`
- `files.read` und `files.write`
4. **Create** klicken und auf der Seite des Keys den **Secret**-Wert kopieren.
5. Auf dem Server den Wert verdeckt in die `.env` schreiben. Der Befehl fragt den Wert ohne Anzeige ab (`read -s`), hängt nur eine Zeile an und zeigt den Key nirgends an:
```
read -s -p "AIRCARD_API_KEY: " K; echo; printf 'AIRCARD_API_KEY=%s\n' "$K" >> /home/webklar/apps/.env; unset K
```
Den Wert nicht im Chat teilen. Stand der Provisioning-Key aus Schritt 5 einmal im Chat, in der Console unter **API keys** löschen und bei Bedarf neu anlegen.
## 8. Bescheid geben
Schreib Claude „Console fertig“. Danach legt Claude Datenbank `aircard`, die Tabellen `cards`, `gallery`, `reports` (mit Spalten und Indizes) und die Buckets `card-images`, `gallery-images` mit dem Skript an und prüft sie. Für die Community-Galerie schreib zusätzlich „Label und Key sind gesetzt“.
+210 -68
View File
@@ -2,8 +2,9 @@
/** /**
* AirCard – Appwrite-Schema-Setup (idempotent) * AirCard – Appwrite-Schema-Setup (idempotent)
* *
* Legt im selbst gehosteten Appwrite (Projekt `aircard`) Datenbank, Tabelle * Legt im selbst gehosteten Appwrite (Projekt `aircard`) die Datenbank `aircard`,
* `cards` (TablesDB) mit vier Spalten und den Bucket `card-images` an. * die Tabellen `cards`, `gallery` und `reports` (TablesDB, mit Spalten und
* Indizes) und die Buckets `card-images` und `gallery-images` an.
* Reines `fetch`, keine Abhängigkeiten, Node 22+. * Reines `fetch`, keine Abhängigkeiten, Node 22+.
* *
* Aufruf (vom Repo-Wurzelverzeichnis): * Aufruf (vom Repo-Wurzelverzeichnis):
@@ -13,8 +14,9 @@
* AIRCARD_APPWRITE_API_KEY Projekt-API-Key `aircard-provision` (Pflicht) * AIRCARD_APPWRITE_API_KEY Projekt-API-Key `aircard-provision` (Pflicht)
* AIRCARD_APPWRITE_ENDPOINT optional, Standard https://appwrite.webklar.com/v1 * AIRCARD_APPWRITE_ENDPOINT optional, Standard https://appwrite.webklar.com/v1
* *
* Ohne --check: fehlende Objekte werden angelegt. Bestehende Objekte werden * Ohne --check: fehlende Objekte werden angelegt (Indizes erst, wenn alle Spalten
* nur verglichen – Abweichungen werden gemeldet, nie geändert. * der Tabelle `available` sind). Bestehende Objekte werden nur verglichen –
* Abweichungen werden gemeldet, nie geändert.
* Mit --check: es wird nichts angelegt; Exit 1, wenn etwas fehlt oder abweicht. * Mit --check: es wird nichts angelegt; Exit 1, wenn etwas fehlt oder abweicht.
* *
* Ausgabe: eine Zeile je Objekt – * Ausgabe: eine Zeile je Objekt –
@@ -34,30 +36,81 @@ export const DEFAULT_ENDPOINT = 'https://appwrite.webklar.com/v1';
// Soll-Zustand // Soll-Zustand
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const str = (key, size, required) => ({ key, type: 'string', size, required });
export const DESIRED = { export const DESIRED = {
database: { id: 'aircard', name: 'AirCard' }, database: { id: 'aircard', name: 'AirCard' },
table: { // Reihenfolge ist Ablauf: cards, gallery, reports (je Tabelle: Spalten, dann Indizes).
id: 'cards', tables: [
name: 'Cards', {
permissions: ['create("users")'], id: 'cards',
rowSecurity: true, name: 'Cards',
columns: [ permissions: ['create("users")'],
{ key: 'name', size: 80, required: true }, rowSecurity: true,
{ key: 'config', size: 2000, required: true }, columns: [str('name', 80, true), str('config', 2000, true), str('imageFileId', 36, true), str('previewFileId', 36, true)],
{ key: 'imageFileId', size: 36, required: true }, indexes: [],
{ key: 'previewFileId', size: 36, required: true }, },
], {
}, // Zeilenrechte setzt nur die API (aircard-api), Clients dürfen nichts auf Tabellenebene.
bucket: { id: 'gallery',
id: 'card-images', name: 'Gallery',
name: 'Card Images', permissions: [],
permissions: ['create("users")'], rowSecurity: true,
fileSecurity: true, columns: [
maximumFileSize: 5242880, str('title', 60, true),
allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'], str('issuer', 40, false),
compression: 'none', str('tags', 120, false),
antivirus: false, str('displayName', 30, false),
}, str('imageFileId', 36, true),
str('previewFileId', 36, true),
str('sourceCardId', 36, false),
str('ownerId', 36, true),
{ key: 'hidden', type: 'boolean', required: false, default: false },
{ key: 'reportCount', type: 'integer', required: false, default: 0, min: 0 },
str('searchText', 300, false),
],
indexes: [
{ key: 'search_text', type: 'fulltext', columns: ['searchText'] },
{ key: 'owner', type: 'key', columns: ['ownerId'] },
{ key: 'hidden', type: 'key', columns: ['hidden'] },
],
},
{
// Nur Admins lesen (Label `admin`); geschrieben wird ausschließlich über die API.
id: 'reports',
name: 'Reports',
permissions: ['read("label:admin")'],
rowSecurity: false,
columns: [
str('galleryId', 36, true),
{ key: 'reason', type: 'enum', elements: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true },
str('reporterHash', 64, true),
],
indexes: [{ key: 'gallery', type: 'key', columns: ['galleryId'] }],
},
],
buckets: [
{
id: 'card-images',
name: 'Card Images',
permissions: ['create("users")'],
fileSecurity: true,
maximumFileSize: 5242880,
allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'],
compression: 'none',
antivirus: false,
},
{
id: 'gallery-images',
name: 'Gallery Images',
permissions: ['create("users")'],
fileSecurity: true,
maximumFileSize: 5242880,
allowedFileExtensions: ['png', 'webp'],
compression: 'none',
antivirus: false,
},
],
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -94,11 +147,35 @@ export function compareTable(actual, desired) {
return out; return out;
} }
/** Vergleich einer Spalte: `size`, `required`, `status` (erwartet `available`). */ /**
* Vergleich einer Spalte, je nach Typ:
* string → type, size, required
* boolean → type, required, default
* integer → type, required, default (min/max der Antwort sind riesige Standardwerte, werden nicht verglichen)
* enum → elements (sortiert), required (Appwrite meldet Enum-Spalten als type `string` + format `enum`)
* Außerdem immer `status` (erwartet `available`).
*/
export function compareColumn(actual, desired) { export function compareColumn(actual, desired) {
const out = []; const out = [];
if (actual.size !== desired.size) out.push(mismatch('size', actual.size, desired.size)); if (desired.type !== 'enum' && actual.type !== desired.type) out.push(mismatch('type', actual.type, desired.type));
if (desired.type === 'string' && actual.size !== desired.size) out.push(mismatch('size', actual.size, desired.size));
if (desired.type === 'enum' && !sameList(actual.elements, desired.elements)) {
out.push(mismatch('elements', sorted(actual.elements), sorted(desired.elements)));
}
if (actual.required !== desired.required) out.push(mismatch('required', actual.required, desired.required)); if (actual.required !== desired.required) out.push(mismatch('required', actual.required, desired.required));
if ((desired.type === 'boolean' || desired.type === 'integer') && actual.default !== desired.default) {
out.push(mismatch('default', actual.default, desired.default));
}
if (actual.status !== 'available') out.push(mismatch('status', actual.status, 'available'));
return out;
}
/** Vergleich eines Index: `type`, `columns` (ältere Antworten: `attributes`; Reihenfolge zählt), `status`. */
export function compareIndex(actual, desired) {
const out = [];
const columns = Array.isArray(actual.columns) ? actual.columns : (actual.attributes ?? []);
if (actual.type !== desired.type) out.push(mismatch('type', actual.type, desired.type));
if (JSON.stringify(columns) !== JSON.stringify(desired.columns)) out.push(mismatch('columns', columns, desired.columns));
if (actual.status !== 'available') out.push(mismatch('status', actual.status, 'available')); if (actual.status !== 'available') out.push(mismatch('status', actual.status, 'available'));
return out; return out;
} }
@@ -197,7 +274,9 @@ async function createObject(ctx, path, body, what) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const DB_ID = DESIRED.database.id; const DB_ID = DESIRED.database.id;
const columnPath = (key) => `/tablesdb/${DB_ID}/tables/${DESIRED.table.id}/columns/${key}`; const tablePath = (tableId) => `/tablesdb/${DB_ID}/tables/${tableId}`;
const columnPath = (tableId, key) => `${tablePath(tableId)}/columns/${key}`;
const indexPath = (tableId, key) => `${tablePath(tableId)}/indexes/${key}`;
/** Gibt zurück, ob die Datenbank (jetzt) existiert. */ /** Gibt zurück, ob die Datenbank (jetzt) existiert. */
export async function ensureDatabase(ctx) { export async function ensureDatabase(ctx) {
@@ -217,13 +296,12 @@ export async function ensureDatabase(ctx) {
} }
/** Gibt zurück, ob die Tabelle (jetzt) existiert. `parentExists`: Datenbank vorhanden. */ /** Gibt zurück, ob die Tabelle (jetzt) existiert. `parentExists`: Datenbank vorhanden. */
export async function ensureTable(ctx, parentExists) { export async function ensureTable(ctx, table, parentExists) {
const t = DESIRED.table; const label = `Tabelle ${table.id}`;
const label = `Tabelle ${t.id}`; const actual = parentExists ? await getObject(ctx, tablePath(table.id), label) : null;
const actual = parentExists ? await getObject(ctx, `/tablesdb/${DB_ID}/tables/${t.id}`, label) : null;
if (actual) { if (actual) {
const deviations = compareTable(actual, t); const deviations = compareTable(actual, table);
if (deviations.length) ctx.deviations(t.id, deviations); if (deviations.length) ctx.deviations(table.id, deviations);
else ctx.ok(label); else ctx.ok(label);
return true; return true;
} }
@@ -234,31 +312,39 @@ export async function ensureTable(ctx, parentExists) {
await createObject( await createObject(
ctx, ctx,
`/tablesdb/${DB_ID}/tables`, `/tablesdb/${DB_ID}/tables`,
{ tableId: t.id, name: t.name, permissions: t.permissions, rowSecurity: t.rowSecurity }, { tableId: table.id, name: table.name, permissions: table.permissions, rowSecurity: table.rowSecurity },
label, label,
); );
ctx.created(label); ctx.created(label);
return true; return true;
} }
/** Spalten und Indizes entstehen asynchron (`processing`) und werden über ihren eigenen Pfad abgefragt. */
const CHILD_KINDS = {
column: { noun: 'Spalte', plural: 'Spalte(n)', path: columnPath },
index: { noun: 'Index', plural: 'Index(e)', path: indexPath },
};
/** /**
* Wartet (bis `ctx.timeoutMs`), bis alle genannten Spalten `available` sind. * Wartet (bis `ctx.timeoutMs`), bis alle genannten Spalten (`kind` = 'column')
* Gibt eine Map Schlüssel → aktueller Spaltenzustand zurück. * bzw. Indizes (`kind` = 'index') der Tabelle `available` sind.
* Gibt eine Map Schlüssel → aktueller Zustand zurück.
*/ */
export async function waitAvailable(ctx, tableId, keys) { export async function waitAvailable(ctx, tableId, keys, kind = 'column') {
const { noun, plural, path } = CHILD_KINDS[kind];
const deadline = Date.now() + ctx.timeoutMs; const deadline = Date.now() + ctx.timeoutMs;
const done = new Map(); const done = new Map();
let pending = [...keys]; let pending = [...keys];
for (;;) { for (;;) {
const still = []; const still = [];
for (const key of pending) { for (const key of pending) {
const label = `Spalte ${tableId}.${key}`; const label = `${noun} ${tableId}.${key}`;
const actual = await getObject(ctx, columnPath(key), label); const actual = await getObject(ctx, path(tableId, key), label);
if (!actual) throw new SetupError(`${label} ist beim Warten auf „available“ verschwunden.`); if (!actual) throw new SetupError(`${label} ist beim Warten auf „available“ verschwunden.`);
if (actual.status === 'available') { if (actual.status === 'available') {
done.set(key, actual); done.set(key, actual);
} else if (actual.status === 'failed' || actual.status === 'stuck') { } else if (actual.status === 'failed' || actual.status === 'stuck') {
throw new SetupError(`${label}: Status „${actual.status}“. Spalte in der Console prüfen, ggf. löschen, und das Skript erneut starten.`); throw new SetupError(`${label}: Status „${actual.status}“. ${noun} in der Console prüfen, ggf. löschen, und das Skript erneut starten.`);
} else { } else {
still.push(key); still.push(key);
} }
@@ -266,7 +352,7 @@ export async function waitAvailable(ctx, tableId, keys) {
if (!still.length) return done; if (!still.length) return done;
if (Date.now() >= deadline) { if (Date.now() >= deadline) {
throw new SetupError( throw new SetupError(
`Spalte(n) ${still.map((key) => `${tableId}.${key}`).join(', ')} nicht innerhalb von ${ctx.timeoutMs / 1000} s verfügbar. Das Skript später erneut starten.`, `${plural} ${still.map((key) => `${tableId}.${key}`).join(', ')} nicht innerhalb von ${ctx.timeoutMs / 1000} s verfügbar. Das Skript später erneut starten.`,
); );
} }
pending = still; pending = still;
@@ -274,49 +360,102 @@ export async function waitAvailable(ctx, tableId, keys) {
} }
} }
/** REST-Pfad und Body zum Anlegen einer Spalte (je Typ eine eigene Route). */
function columnRequest(table, col) {
const path = `${tablePath(table.id)}/columns/${col.type}`;
switch (col.type) {
case 'string':
return { path, body: { key: col.key, size: col.size, required: col.required } };
case 'boolean':
return { path, body: { key: col.key, required: col.required, default: col.default } };
case 'integer':
return { path, body: { key: col.key, required: col.required, default: col.default, min: col.min } };
case 'enum':
return { path, body: { key: col.key, elements: col.elements, required: col.required } };
default:
throw new SetupError(`Unbekannter Spaltentyp „${col.type}“ für ${table.id}.${col.key}.`);
}
}
/** /**
* Spalten nacheinander anlegen, danach auf `available` warten – damit sind * Gemeinsamer Ablauf für Spalten und Indizes einer Tabelle: vorhandene lesen,
* sie bereit, bevor jemand schreibt. `parentExists`: Tabelle vorhanden. * fehlende nacheinander anlegen, danach auf `available` warten (nur ohne
* --check) und alles mit dem Soll vergleichen. Gibt die Einträge zurück
* (`actual` ist dort gesetzt, wo das Objekt existiert).
* `canCreate: false` → fehlende Objekte werden nur gemeldet, auch ohne --check.
*/ */
export async function ensureColumns(ctx, parentExists) { async function ensureChildren(ctx, table, parentExists, { kind, items, request, compare, canCreate = true }) {
const t = DESIRED.table; const { noun, path } = CHILD_KINDS[kind];
const entries = []; const entries = [];
for (const col of t.columns) { for (const item of items) {
const label = `Spalte ${t.id}.${col.key}`; const label = `${noun} ${table.id}.${item.key}`;
const actual = parentExists ? await getObject(ctx, columnPath(col.key), label) : null; const actual = parentExists ? await getObject(ctx, path(table.id, item.key), label) : null;
if (actual) { if (actual) {
entries.push({ col, label, actual, created: false }); entries.push({ item, label, actual, created: false });
} else if (ctx.check) { } else if (ctx.check || !canCreate) {
ctx.missing(label); ctx.missing(label);
} else { } else {
await createObject( const { path: createPath, body } = request(item);
ctx, await createObject(ctx, createPath, body, label);
`/tablesdb/${DB_ID}/tables/${t.id}/columns/string`,
{ key: col.key, size: col.size, required: col.required },
label,
);
ctx.created(label); ctx.created(label);
entries.push({ col, label, actual: null, created: true }); entries.push({ item, label, actual: null, created: true });
} }
} }
if (!ctx.check) { if (!ctx.check) {
const waiting = entries.filter((e) => e.created || e.actual.status === 'processing'); const waiting = entries.filter((e) => e.created || e.actual.status === 'processing');
if (waiting.length) { if (waiting.length) {
const ready = await waitAvailable(ctx, t.id, waiting.map((e) => e.col.key)); const ready = await waitAvailable(ctx, table.id, waiting.map((e) => e.item.key), kind);
for (const e of waiting) e.actual = ready.get(e.col.key); for (const e of waiting) e.actual = ready.get(e.item.key);
} }
} }
for (const e of entries) { for (const e of entries) {
const deviations = compareColumn(e.actual, e.col); const deviations = compare(e.actual, e.item);
if (deviations.length) ctx.deviations(`${t.id}.${e.col.key}`, deviations); if (deviations.length) ctx.deviations(`${table.id}.${e.item.key}`, deviations);
else if (!e.created) ctx.ok(e.label); else if (!e.created) ctx.ok(e.label);
} }
return entries;
} }
export async function ensureBucket(ctx) { /**
const b = DESIRED.bucket; * Spalten nacheinander anlegen, danach auf `available` warten – damit sind
* sie bereit, bevor jemand schreibt (und bevor Indizes angelegt werden).
* `parentExists`: Tabelle vorhanden. Gibt zurück, ob alle Spalten vorhanden
* und `available` sind (Voraussetzung für die Indizes).
*/
export async function ensureColumns(ctx, table, parentExists) {
const entries = await ensureChildren(ctx, table, parentExists, {
kind: 'column',
items: table.columns,
request: (col) => columnRequest(table, col),
compare: compareColumn,
});
return entries.length === table.columns.length && entries.every((e) => e.actual.status === 'available');
}
/**
* Indizes der Tabelle sicherstellen. Angelegt wird erst, wenn alle Spalten
* `available` sind (`columnsReady`), sonst wird der Index nur als fehlend
* gemeldet (die Spalte steht dann schon als ABWEICHUNG/FEHLT in der Ausgabe).
*/
export async function ensureIndexes(ctx, table, parentExists, columnsReady = true) {
const indexes = table.indexes ?? [];
if (!indexes.length) return;
await ensureChildren(ctx, table, parentExists, {
kind: 'index',
items: indexes,
request: (idx) => ({
path: `${tablePath(table.id)}/indexes`,
body: { key: idx.key, type: idx.type, columns: idx.columns },
}),
compare: compareIndex,
canCreate: columnsReady,
});
}
export async function ensureBucket(ctx, bucket) {
const b = bucket;
const label = `Bucket ${b.id}`; const label = `Bucket ${b.id}`;
const actual = await getObject(ctx, `/storage/buckets/${b.id}`, label); const actual = await getObject(ctx, `/storage/buckets/${b.id}`, label);
if (actual) { if (actual) {
@@ -391,9 +530,12 @@ export async function main({
log(`Appwrite ${endpoint}, Projekt ${project}, ${check ? 'nur prüfen (--check)' : 'prüfen und fehlende Objekte anlegen'}`); log(`Appwrite ${endpoint}, Projekt ${project}, ${check ? 'nur prüfen (--check)' : 'prüfen und fehlende Objekte anlegen'}`);
try { try {
const databaseExists = await ensureDatabase(ctx); const databaseExists = await ensureDatabase(ctx);
const tableExists = await ensureTable(ctx, databaseExists); for (const table of DESIRED.tables) {
await ensureColumns(ctx, tableExists); const tableExists = await ensureTable(ctx, table, databaseExists);
await ensureBucket(ctx); const columnsReady = await ensureColumns(ctx, table, tableExists);
await ensureIndexes(ctx, table, tableExists, columnsReady);
}
for (const bucket of DESIRED.buckets) await ensureBucket(ctx, bucket);
} catch (err) { } catch (err) {
if (!(err instanceof SetupError)) throw err; if (!(err instanceof SetupError)) throw err;
log(`ABBRUCH: ${err.message}`); log(`ABBRUCH: ${err.message}`);
+553 -54
View File
@@ -14,6 +14,7 @@ import {
isMissing, isMissing,
compareTable, compareTable,
compareColumn, compareColumn,
compareIndex,
compareBucket, compareBucket,
main, main,
} from './appwrite-setup.mjs'; } from './appwrite-setup.mjs';
@@ -25,14 +26,24 @@ const KEY = 'test-key-0123456789abcdef-geheim';
// Fake-Appwrite (1.8-ähnliche REST-Formen, alles im Speicher) // Fake-Appwrite (1.8-ähnliche REST-Formen, alles im Speicher)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function createFakeAppwrite({ project = '6ac2130a00289343d6d5', key = KEY, stuckColumns = false, inject = null } = {}) { const HUGE_MIN = -9223372036854775808;
const HUGE_MAX = 9223372036854775807;
function createFakeAppwrite({
project = '6ac2130a00289343d6d5', key = KEY, stuckColumns = false, stuckIndexes = false, failedIndexes = false, inject = null,
} = {}) {
const state = { const state = {
databases: new Map(), databases: new Map(),
tables: new Map(), // "db/table" tables: new Map(), // "db/table"
columns: new Map(), // "db/table/key" columns: new Map(), // "db/table/key"
indexes: new Map(), // "db/table/key"
buckets: new Map(), buckets: new Map(),
requests: [], requests: [],
// Bei jedem Index-POST: waren zu diesem Zeitpunkt ALLE Spalten der Tabelle schon als `available` gemeldet?
indexPosts: [],
}; };
const columnAvailable = (col) => !stuckColumns && col.gets >= 2;
const invalid = (res, message) => fail(res, 400, 'general_argument_invalid', message);
const send = (res, status, body) => { const send = (res, status, body) => {
res.writeHead(status, { 'content-type': 'application/json' }); res.writeHead(status, { 'content-type': 'application/json' });
@@ -90,12 +101,33 @@ function createFakeAppwrite({ project = '6ac2130a00289343d6d5', key = KEY, stuck
const table = state.tables.get(`${seg[1]}/${seg[3]}`); const table = state.tables.get(`${seg[1]}/${seg[3]}`);
return table ? send(res, 200, table) : fail(res, 404, 'table_not_found', 'Table not found'); return table ? send(res, 200, table) : fail(res, 404, 'table_not_found', 'Table not found');
} }
// POST /tablesdb/:db/tables/:t/columns/string // POST /tablesdb/:db/tables/:t/columns/{string,boolean,integer,enum}
if (m === 'POST' && seg.length === 6 && seg[2] === 'tables' && seg[4] === 'columns' && seg[5] === 'string') { if (m === 'POST' && seg.length === 6 && seg[2] === 'tables' && seg[4] === 'columns' && ['string', 'boolean', 'integer', 'enum'].includes(seg[5])) {
if (!state.tables.has(`${seg[1]}/${seg[3]}`)) return fail(res, 404, 'table_not_found', 'Table not found'); if (!state.tables.has(`${seg[1]}/${seg[3]}`)) return fail(res, 404, 'table_not_found', 'Table not found');
const kind = seg[5];
if (typeof body.key !== 'string' || typeof body.required !== 'boolean') return invalid(res, 'key und required sind Pflicht');
if (body.required && body.default !== undefined && body.default !== null) {
return fail(res, 400, 'column_default_unsupported', 'Cannot set default value for required column');
}
const id = `${seg[1]}/${seg[3]}/${body.key}`; const id = `${seg[1]}/${seg[3]}/${body.key}`;
if (state.columns.has(id)) return fail(res, 409, 'column_already_exists', 'exists'); if (state.columns.has(id)) return fail(res, 409, 'column_already_exists', 'exists');
const col = { key: body.key, type: 'string', size: body.size, required: body.required, array: false, status: 'processing', gets: 0 }; const base = { key: body.key, required: body.required, array: false, status: 'processing', gets: 0 };
let col;
if (kind === 'string') {
if (!Number.isInteger(body.size)) return invalid(res, 'size fehlt');
col = { ...base, type: 'string', size: body.size, default: body.default ?? null };
} else if (kind === 'boolean') {
col = { ...base, type: 'boolean', default: body.default ?? null };
} else if (kind === 'integer') {
// echte Antworten tragen riesige Standard-min/max – das Skript darf sie nicht vergleichen
col = { ...base, type: 'integer', min: HUGE_MIN, max: HUGE_MAX, default: body.default ?? null };
} else {
if (!Array.isArray(body.elements) || !body.elements.length) return invalid(res, 'elements fehlt');
col = {
...base, type: 'string', format: 'enum', elements: body.elements,
size: Math.max(...body.elements.map((e) => e.length)), default: body.default ?? null,
};
}
state.columns.set(id, col); state.columns.set(id, col);
const { gets, ...out } = col; const { gets, ...out } = col;
return send(res, 202, out); return send(res, 202, out);
@@ -106,10 +138,44 @@ function createFakeAppwrite({ project = '6ac2130a00289343d6d5', key = KEY, stuck
const col = state.columns.get(`${seg[1]}/${seg[3]}/${seg[5]}`); const col = state.columns.get(`${seg[1]}/${seg[3]}/${seg[5]}`);
if (!col) return fail(res, 404, 'column_not_found', 'Column not found'); if (!col) return fail(res, 404, 'column_not_found', 'Column not found');
col.gets += 1; col.gets += 1;
const status = stuckColumns ? 'processing' : col.gets >= 2 ? 'available' : 'processing'; const status = col.forceStatus ?? (columnAvailable(col) ? 'available' : 'processing');
const { gets, ...out } = col; const { gets, ...out } = col;
return send(res, 200, { ...out, status }); return send(res, 200, { ...out, status });
} }
// POST /tablesdb/:db/tables/:t/indexes
if (m === 'POST' && seg.length === 5 && seg[2] === 'tables' && seg[4] === 'indexes') {
if (!state.tables.has(`${seg[1]}/${seg[3]}`)) return fail(res, 404, 'table_not_found', 'Table not found');
if (typeof body.key !== 'string' || !['key', 'fulltext', 'unique'].includes(body.type)
|| !Array.isArray(body.columns) || !body.columns.length) {
return invalid(res, 'key, type und columns sind Pflicht');
}
if (body.type === 'fulltext' && body.columns.length !== 1) return invalid(res, 'Fulltext-Index nur über genau eine Spalte');
const id = `${seg[1]}/${seg[3]}/${body.key}`;
if (state.indexes.has(id)) return fail(res, 409, 'index_already_exists', 'exists');
const tableCols = [...state.columns.entries()].filter(([k]) => k.startsWith(`${seg[1]}/${seg[3]}/`)).map(([, c]) => c);
for (const name of body.columns) {
const col = state.columns.get(`${seg[1]}/${seg[3]}/${name}`);
if (!col) return fail(res, 404, 'attribute_not_found', `Column ${name} not found`);
if (!columnAvailable(col)) return fail(res, 400, 'attribute_not_available', `Column ${name} is not available`);
}
state.indexPosts.push({ table: seg[3], key: body.key, allColumnsAvailable: tableCols.every(columnAvailable) });
const index = { key: body.key, type: body.type, columns: body.columns, lengths: [], orders: [], status: 'processing', gets: 0 };
state.indexes.set(id, index);
const { gets, ...out } = index;
return send(res, 202, out);
}
// GET /tablesdb/:db/tables/:t/indexes/:key
if (m === 'GET' && seg.length === 6 && seg[2] === 'tables' && seg[4] === 'indexes') {
if (!state.tables.has(`${seg[1]}/${seg[3]}`)) return fail(res, 404, 'table_not_found', 'Table not found');
const index = state.indexes.get(`${seg[1]}/${seg[3]}/${seg[5]}`);
if (!index) return fail(res, 404, 'index_not_found', 'Index not found');
index.gets += 1;
let status = index.gets >= 2 ? 'available' : 'processing';
if (stuckIndexes) status = 'processing';
if (failedIndexes) status = 'failed';
const { gets, ...out } = index;
return send(res, 200, { ...out, status });
}
// POST /storage/buckets // POST /storage/buckets
if (m === 'POST' && path === '/v1/storage/buckets') { if (m === 'POST' && path === '/v1/storage/buckets') {
if (state.buckets.has(body.bucketId)) return fail(res, 409, 'storage_bucket_already_exists', 'exists'); if (state.buckets.has(body.bucketId)) return fail(res, 409, 'storage_bucket_already_exists', 'exists');
@@ -190,17 +256,24 @@ function runCli(args, env) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe('DESIRED', () => { describe('DESIRED', () => {
test('entspricht dem Schema aus der Spec', () => { const table = (id) => DESIRED.tables.find((t) => t.id === id);
const bucket = (id) => DESIRED.buckets.find((b) => b.id === id);
const str = (key, size, required) => ({ key, type: 'string', size, required });
test('Reihenfolge: cards zuerst, dann gallery, reports; Buckets card-images, gallery-images', () => {
assert.deepEqual(DESIRED.database, { id: 'aircard', name: 'AirCard' }); assert.deepEqual(DESIRED.database, { id: 'aircard', name: 'AirCard' });
assert.equal(DESIRED.table.id, 'cards'); assert.deepEqual(DESIRED.tables.map((t) => t.id), ['cards', 'gallery', 'reports']);
assert.equal(DESIRED.table.name, 'Cards'); assert.deepEqual(DESIRED.buckets.map((b) => b.id), ['card-images', 'gallery-images']);
assert.deepEqual(DESIRED.table.permissions, ['create("users")']); });
assert.equal(DESIRED.table.rowSecurity, true);
assert.deepEqual( test('cards entspricht dem bisherigen Schema (unverändert)', () => {
DESIRED.table.columns.map((c) => [c.key, c.size, c.required]), const t = table('cards');
[['name', 80, true], ['config', 2000, true], ['imageFileId', 36, true], ['previewFileId', 36, true]], assert.equal(t.name, 'Cards');
); assert.deepEqual(t.permissions, ['create("users")']);
assert.deepEqual(DESIRED.bucket, { assert.equal(t.rowSecurity, true);
assert.deepEqual(t.columns, [str('name', 80, true), str('config', 2000, true), str('imageFileId', 36, true), str('previewFileId', 36, true)]);
assert.deepEqual(t.indexes ?? [], []);
assert.deepEqual(bucket('card-images'), {
id: 'card-images', id: 'card-images',
name: 'Card Images', name: 'Card Images',
permissions: ['create("users")'], permissions: ['create("users")'],
@@ -212,6 +285,67 @@ describe('DESIRED', () => {
}); });
}); });
test('gallery entspricht Spec §7', () => {
const t = table('gallery');
assert.equal(t.name, 'Gallery');
assert.deepEqual(t.permissions, []);
assert.equal(t.rowSecurity, true);
assert.deepEqual(t.columns, [
str('title', 60, true),
str('issuer', 40, false),
str('tags', 120, false),
str('displayName', 30, false),
str('imageFileId', 36, true),
str('previewFileId', 36, true),
str('sourceCardId', 36, false),
str('ownerId', 36, true),
{ key: 'hidden', type: 'boolean', required: false, default: false },
{ key: 'reportCount', type: 'integer', required: false, default: 0, min: 0 },
str('searchText', 300, false),
]);
assert.deepEqual(t.indexes, [
{ key: 'search_text', type: 'fulltext', columns: ['searchText'] },
{ key: 'owner', type: 'key', columns: ['ownerId'] },
{ key: 'hidden', type: 'key', columns: ['hidden'] },
]);
});
test('reports entspricht Spec §7', () => {
const t = table('reports');
assert.equal(t.name, 'Reports');
assert.deepEqual(t.permissions, ['read("label:admin")']);
assert.equal(t.rowSecurity, false);
assert.deepEqual(t.columns, [
str('galleryId', 36, true),
{ key: 'reason', type: 'enum', elements: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true },
str('reporterHash', 64, true),
]);
assert.deepEqual(t.indexes, [{ key: 'gallery', type: 'key', columns: ['galleryId'] }]);
});
test('gallery-images entspricht Spec §7', () => {
assert.deepEqual(bucket('gallery-images'), {
id: 'gallery-images',
name: 'Gallery Images',
permissions: ['create("users")'],
fileSecurity: true,
maximumFileSize: 5242880,
allowedFileExtensions: ['png', 'webp'],
compression: 'none',
antivirus: false,
});
});
test('Fulltext-Indizes decken genau eine Spalte ab, alle Index-Spalten existieren', () => {
for (const t of DESIRED.tables) {
const keys = t.columns.map((c) => c.key);
for (const idx of t.indexes ?? []) {
if (idx.type === 'fulltext') assert.equal(idx.columns.length, 1, `${t.id}.${idx.key}`);
for (const col of idx.columns) assert.ok(keys.includes(col), `${t.id}.${idx.key}: Spalte ${col} fehlt`);
}
}
});
test('Standard-Endpoint ist der Server von WEBklar', () => { test('Standard-Endpoint ist der Server von WEBklar', () => {
assert.equal(DEFAULT_ENDPOINT, 'https://appwrite.webklar.com/v1'); assert.equal(DEFAULT_ENDPOINT, 'https://appwrite.webklar.com/v1');
}); });
@@ -239,59 +373,175 @@ describe('isMissing', () => {
}); });
describe('compareTable', () => { describe('compareTable', () => {
const CARDS = DESIRED.tables[0];
const ok = () => ({ $id: 'cards', name: 'Cards', $permissions: ['create("users")'], rowSecurity: true }); const ok = () => ({ $id: 'cards', name: 'Cards', $permissions: ['create("users")'], rowSecurity: true });
test('passende Tabelle: keine Abweichung', () => { test('passende Tabelle: keine Abweichung', () => {
assert.deepEqual(compareTable(ok(), DESIRED.table), []); assert.deepEqual(compareTable(ok(), CARDS), []);
}); });
test('Rechte werden unabhängig von der Reihenfolge verglichen', () => { test('Rechte werden unabhängig von der Reihenfolge verglichen', () => {
const desired = { ...DESIRED.table, permissions: ['create("users")', 'read("any")'] }; const desired = { ...CARDS, permissions: ['create("users")', 'read("any")'] };
const actual = { ...ok(), $permissions: ['read("any")', 'create("users")'] }; const actual = { ...ok(), $permissions: ['read("any")', 'create("users")'] };
assert.deepEqual(compareTable(actual, desired), []); assert.deepEqual(compareTable(actual, desired), []);
}); });
test('abweichende oder zusätzliche Rechte werden gemeldet', () => { test('abweichende oder zusätzliche Rechte werden gemeldet', () => {
const wrong = compareTable({ ...ok(), $permissions: ['read("any")'] }, DESIRED.table); const wrong = compareTable({ ...ok(), $permissions: ['read("any")'] }, CARDS);
assert.equal(wrong.length, 1); assert.equal(wrong.length, 1);
assert.match(wrong[0], /^\$permissions ist \[read\("any"\)\], erwartet \[create\("users"\)\]$/); assert.match(wrong[0], /^\$permissions ist \[read\("any"\)\], erwartet \[create\("users"\)\]$/);
const extra = compareTable({ ...ok(), $permissions: ['create("users")', 'read("any")'] }, DESIRED.table); const extra = compareTable({ ...ok(), $permissions: ['create("users")', 'read("any")'] }, CARDS);
assert.equal(extra.length, 1); assert.equal(extra.length, 1);
assert.match(extra[0], /^\$permissions /); assert.match(extra[0], /^\$permissions /);
}); });
test('rowSecurity aus wird gemeldet', () => { test('rowSecurity aus wird gemeldet', () => {
assert.deepEqual(compareTable({ ...ok(), rowSecurity: false }, DESIRED.table), [ assert.deepEqual(compareTable({ ...ok(), rowSecurity: false }, CARDS), [
'rowSecurity ist false, erwartet true', 'rowSecurity ist false, erwartet true',
]); ]);
}); });
test('mehrere Abweichungen ergeben mehrere Meldungen', () => { test('mehrere Abweichungen ergeben mehrere Meldungen', () => {
assert.equal(compareTable({ ...ok(), rowSecurity: false, $permissions: [] }, DESIRED.table).length, 2); assert.equal(compareTable({ ...ok(), rowSecurity: false, $permissions: [] }, CARDS).length, 2);
});
test('gallery (rowSecurity an, keine Tabellenrechte) und reports (Tabellenrecht für Admins)', () => {
const [, gallery, reports] = DESIRED.tables;
assert.deepEqual(compareTable({ $permissions: [], rowSecurity: true }, gallery), []);
assert.deepEqual(compareTable({ $permissions: ['read("any")'], rowSecurity: true }, gallery), [
'$permissions ist [read("any")], erwartet []',
]);
assert.deepEqual(compareTable({ $permissions: ['read("label:admin")'], rowSecurity: false }, reports), []);
assert.deepEqual(compareTable({ $permissions: ['read("label:admin")'], rowSecurity: true }, reports), [
'rowSecurity ist true, erwartet false',
]);
}); });
}); });
describe('compareColumn', () => { describe('compareColumn', () => {
const desired = DESIRED.table.columns[0]; // name, 80, required const col = (id, key) => DESIRED.tables.find((t) => t.id === id).columns.find((c) => c.key === key);
const desired = col('cards', 'name'); // string 80, Pflicht
const ok = () => ({ key: 'name', type: 'string', size: 80, required: true, status: 'available' }); const ok = () => ({ key: 'name', type: 'string', size: 80, required: true, status: 'available' });
test('passende Spalte: keine Abweichung', () => { test('passende Spalte: keine Abweichung', () => {
assert.deepEqual(compareColumn(ok(), desired), []); assert.deepEqual(compareColumn(ok(), desired), []);
}); });
test('size, required und status werden gemeldet', () => { test('string: size, required und status werden gemeldet', () => {
assert.deepEqual(compareColumn({ ...ok(), size: 100 }, desired), ['size ist 100, erwartet 80']); assert.deepEqual(compareColumn({ ...ok(), size: 100 }, desired), ['size ist 100, erwartet 80']);
assert.deepEqual(compareColumn({ ...ok(), required: false }, desired), ['required ist false, erwartet true']); assert.deepEqual(compareColumn({ ...ok(), required: false }, desired), ['required ist false, erwartet true']);
assert.deepEqual(compareColumn({ ...ok(), status: 'processing' }, desired), ['status ist processing, erwartet available']); assert.deepEqual(compareColumn({ ...ok(), status: 'processing' }, desired), ['status ist processing, erwartet available']);
}); });
test('fehlende Felder (z. B. falscher Spaltentyp ohne size) werden gemeldet', () => { test('string: der Standardwert wird nicht verglichen', () => {
const [msg] = compareColumn({ key: 'name', type: 'integer', required: true, status: 'available' }, desired); assert.deepEqual(compareColumn({ ...ok(), default: 'x' }, desired), []);
assert.equal(msg, 'size ist undefined, erwartet 80'); });
test('falscher Spaltentyp wird gemeldet (zusätzlich fehlende Felder)', () => {
assert.deepEqual(compareColumn({ key: 'name', type: 'integer', required: true, status: 'available' }, desired), [
'type ist integer, erwartet string',
'size ist undefined, erwartet 80',
]);
});
describe('boolean', () => {
const hidden = col('gallery', 'hidden');
const okBool = () => ({ key: 'hidden', type: 'boolean', required: false, default: false, array: false, status: 'available' });
test('passende Spalte: keine Abweichung', () => {
assert.deepEqual(compareColumn(okBool(), hidden), []);
});
test('Standardwert, required, Typ und status werden gemeldet', () => {
assert.deepEqual(compareColumn({ ...okBool(), default: true }, hidden), ['default ist true, erwartet false']);
assert.deepEqual(compareColumn({ ...okBool(), default: null }, hidden), ['default ist null, erwartet false']);
assert.deepEqual(compareColumn({ ...okBool(), required: true }, hidden), ['required ist true, erwartet false']);
assert.deepEqual(compareColumn({ ...okBool(), type: 'string' }, hidden), ['type ist string, erwartet boolean']);
assert.deepEqual(compareColumn({ ...okBool(), status: 'failed' }, hidden), ['status ist failed, erwartet available']);
});
});
describe('integer', () => {
const reportCount = col('gallery', 'reportCount');
// so liefert Appwrite Integer-Spalten: riesige Standard-min/max
const okInt = () => ({
key: 'reportCount', type: 'integer', required: false, default: 0, array: false,
min: -9223372036854775808, max: 9223372036854775807, status: 'available',
});
test('passende Spalte: keine Abweichung, min/max der Antwort werden NICHT verglichen', () => {
assert.deepEqual(compareColumn(okInt(), reportCount), []);
assert.deepEqual(compareColumn({ ...okInt(), min: 0, max: 5 }, reportCount), []);
});
test('Standardwert, required und Typ werden gemeldet', () => {
assert.deepEqual(compareColumn({ ...okInt(), default: 1 }, reportCount), ['default ist 1, erwartet 0']);
assert.deepEqual(compareColumn({ ...okInt(), default: null }, reportCount), ['default ist null, erwartet 0']);
assert.deepEqual(compareColumn({ ...okInt(), required: true }, reportCount), ['required ist true, erwartet false']);
assert.deepEqual(compareColumn({ ...okInt(), type: 'boolean' }, reportCount), ['type ist boolean, erwartet integer']);
});
});
describe('enum', () => {
const reason = col('reports', 'reason');
const okEnum = () => ({
key: 'reason', type: 'string', format: 'enum', required: true, array: false, size: 12, default: null,
elements: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], status: 'available',
});
test('passende Spalte: keine Abweichung, Elemente werden sortiert verglichen', () => {
assert.deepEqual(compareColumn(okEnum(), reason), []);
assert.deepEqual(compareColumn({ ...okEnum(), elements: ['spam', 'sonstiges', 'urheberrecht', 'kartendaten', 'unangemessen'] }, reason), []);
});
test('fehlende/zusätzliche Elemente, required und status werden gemeldet (Elemente sortiert ausgegeben)', () => {
assert.deepEqual(compareColumn({ ...okEnum(), elements: ['spam', 'sonstiges'] }, reason), [
'elements ist [sonstiges, spam], erwartet [kartendaten, sonstiges, spam, unangemessen, urheberrecht]',
]);
assert.equal(compareColumn({ ...okEnum(), elements: [...okEnum().elements, 'gewalt'] }, reason).length, 1);
assert.deepEqual(compareColumn({ ...okEnum(), required: false }, reason), ['required ist false, erwartet true']);
assert.deepEqual(compareColumn({ ...okEnum(), status: 'processing' }, reason), ['status ist processing, erwartet available']);
});
test('eine normale String-Spalte (ohne elements) ist keine Enum-Spalte', () => {
const { elements, format, ...plain } = okEnum();
assert.equal(compareColumn(plain, reason).length, 1);
assert.match(compareColumn(plain, reason)[0], /^elements ist \[\], erwartet \[/);
});
});
});
describe('compareIndex', () => {
const desired = { key: 'search_text', type: 'fulltext', columns: ['searchText'] };
const ok = () => ({ key: 'search_text', type: 'fulltext', columns: ['searchText'], lengths: [], orders: [], status: 'available' });
test('passender Index: keine Abweichung', () => {
assert.deepEqual(compareIndex(ok(), desired), []);
});
test('Index-Typ, Spalten und status werden gemeldet', () => {
assert.deepEqual(compareIndex({ ...ok(), type: 'key' }, desired), ['type ist key, erwartet fulltext']);
assert.deepEqual(compareIndex({ ...ok(), columns: ['title'] }, desired), ['columns ist [title], erwartet [searchText]']);
assert.deepEqual(compareIndex({ ...ok(), columns: ['title', 'searchText'] }, desired), [
'columns ist [title, searchText], erwartet [searchText]',
]);
assert.deepEqual(compareIndex({ ...ok(), status: 'processing' }, desired), ['status ist processing, erwartet available']);
});
test('ältere Antwortform mit `attributes` statt `columns` wird akzeptiert', () => {
const { columns, ...legacy } = ok();
assert.deepEqual(compareIndex({ ...legacy, attributes: ['searchText'] }, desired), []);
assert.deepEqual(compareIndex({ ...legacy, attributes: ['title'] }, desired), ['columns ist [title], erwartet [searchText]']);
});
test('ganz ohne Spaltenangabe wird gemeldet', () => {
const { columns, ...none } = ok();
assert.deepEqual(compareIndex(none, desired), ['columns ist [], erwartet [searchText]']);
}); });
}); });
describe('compareBucket', () => { describe('compareBucket', () => {
const CARD_IMAGES = DESIRED.buckets[0];
const ok = () => ({ const ok = () => ({
$id: 'card-images', name: 'Card Images', $permissions: ['create("users")'], $id: 'card-images', name: 'Card Images', $permissions: ['create("users")'],
fileSecurity: true, maximumFileSize: 5242880, fileSecurity: true, maximumFileSize: 5242880,
@@ -299,32 +549,44 @@ describe('compareBucket', () => {
}); });
test('passender Bucket: keine Abweichung', () => { test('passender Bucket: keine Abweichung', () => {
assert.deepEqual(compareBucket(ok(), DESIRED.bucket), []); assert.deepEqual(compareBucket(ok(), CARD_IMAGES), []);
}); });
test('Endungen werden sortiert verglichen', () => { test('Endungen werden sortiert verglichen', () => {
assert.deepEqual(compareBucket({ ...ok(), allowedFileExtensions: ['webp', 'jpeg', 'png', 'jpg'] }, DESIRED.bucket), []); assert.deepEqual(compareBucket({ ...ok(), allowedFileExtensions: ['webp', 'jpeg', 'png', 'jpg'] }, CARD_IMAGES), []);
}); });
test('fehlende oder zusätzliche Endungen werden gemeldet (sortiert ausgegeben)', () => { test('fehlende oder zusätzliche Endungen werden gemeldet (sortiert ausgegeben)', () => {
assert.deepEqual(compareBucket({ ...ok(), allowedFileExtensions: ['png', 'webp'] }, DESIRED.bucket), [ assert.deepEqual(compareBucket({ ...ok(), allowedFileExtensions: ['png', 'webp'] }, CARD_IMAGES), [
'allowedFileExtensions ist [png, webp], erwartet [jpeg, jpg, png, webp]', 'allowedFileExtensions ist [png, webp], erwartet [jpeg, jpg, png, webp]',
]); ]);
assert.equal(compareBucket({ ...ok(), allowedFileExtensions: [] }, DESIRED.bucket).length, 1); assert.equal(compareBucket({ ...ok(), allowedFileExtensions: [] }, CARD_IMAGES).length, 1);
assert.equal(compareBucket({ ...ok(), allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp', 'gif'] }, DESIRED.bucket).length, 1); assert.equal(compareBucket({ ...ok(), allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp', 'gif'] }, CARD_IMAGES).length, 1);
}); });
test('fileSecurity und maximumFileSize werden gemeldet', () => { test('fileSecurity und maximumFileSize werden gemeldet', () => {
assert.deepEqual(compareBucket({ ...ok(), fileSecurity: false }, DESIRED.bucket), ['fileSecurity ist false, erwartet true']); assert.deepEqual(compareBucket({ ...ok(), fileSecurity: false }, CARD_IMAGES), ['fileSecurity ist false, erwartet true']);
assert.deepEqual(compareBucket({ ...ok(), maximumFileSize: 30000000 }, DESIRED.bucket), [ assert.deepEqual(compareBucket({ ...ok(), maximumFileSize: 30000000 }, CARD_IMAGES), [
'maximumFileSize ist 30000000, erwartet 5242880', 'maximumFileSize ist 30000000, erwartet 5242880',
]); ]);
}); });
test('Rechte werden unabhängig von der Reihenfolge verglichen und Abweichungen gemeldet', () => { test('Rechte werden unabhängig von der Reihenfolge verglichen und Abweichungen gemeldet', () => {
const desired = { ...DESIRED.bucket, permissions: ['create("users")', 'read("any")'] }; const desired = { ...CARD_IMAGES, permissions: ['create("users")', 'read("any")'] };
assert.deepEqual(compareBucket({ ...ok(), $permissions: ['read("any")', 'create("users")'] }, desired), []); assert.deepEqual(compareBucket({ ...ok(), $permissions: ['read("any")', 'create("users")'] }, desired), []);
assert.equal(compareBucket({ ...ok(), $permissions: ['read("any")'] }, DESIRED.bucket).length, 1); assert.equal(compareBucket({ ...ok(), $permissions: ['read("any")'] }, CARD_IMAGES).length, 1);
});
test('gallery-images: nur png/webp erlaubt', () => {
const gallery = DESIRED.buckets[1];
const okGallery = {
$id: 'gallery-images', name: 'Gallery Images', $permissions: ['create("users")'],
fileSecurity: true, maximumFileSize: 5242880, allowedFileExtensions: ['webp', 'png'], compression: 'none', antivirus: false,
};
assert.deepEqual(compareBucket(okGallery, gallery), []);
assert.deepEqual(compareBucket({ ...okGallery, allowedFileExtensions: ['png', 'webp', 'jpg'] }, gallery), [
'allowedFileExtensions ist [jpg, png, webp], erwartet [png, webp]',
]);
}); });
}); });
@@ -340,15 +602,82 @@ describe('main gegen Fake-Server', () => {
'Spalte cards.config', 'Spalte cards.config',
'Spalte cards.imageFileId', 'Spalte cards.imageFileId',
'Spalte cards.previewFileId', 'Spalte cards.previewFileId',
'Tabelle gallery',
'Spalte gallery.title',
'Spalte gallery.issuer',
'Spalte gallery.tags',
'Spalte gallery.displayName',
'Spalte gallery.imageFileId',
'Spalte gallery.previewFileId',
'Spalte gallery.sourceCardId',
'Spalte gallery.ownerId',
'Spalte gallery.hidden',
'Spalte gallery.reportCount',
'Spalte gallery.searchText',
'Index gallery.search_text',
'Index gallery.owner',
'Index gallery.hidden',
'Tabelle reports',
'Spalte reports.galleryId',
'Spalte reports.reason',
'Spalte reports.reporterHash',
'Index reports.gallery',
'Bucket card-images', 'Bucket card-images',
'Bucket gallery-images',
];
const NEW_OBJECTS = EXPECTED_OBJECTS.filter((o) => /gallery|reports/.test(o));
const T = '/v1/tablesdb/aircard/tables';
const str = (table, key, size, required) => [`${T}/${table}/columns/string`, { key, size, required }];
const EXPECTED_POSTS = [
['/v1/tablesdb', { databaseId: 'aircard', name: 'AirCard' }],
[T, { tableId: 'cards', name: 'Cards', permissions: ['create("users")'], rowSecurity: true }],
str('cards', 'name', 80, true),
str('cards', 'config', 2000, true),
str('cards', 'imageFileId', 36, true),
str('cards', 'previewFileId', 36, true),
[T, { tableId: 'gallery', name: 'Gallery', permissions: [], rowSecurity: true }],
str('gallery', 'title', 60, true),
str('gallery', 'issuer', 40, false),
str('gallery', 'tags', 120, false),
str('gallery', 'displayName', 30, false),
str('gallery', 'imageFileId', 36, true),
str('gallery', 'previewFileId', 36, true),
str('gallery', 'sourceCardId', 36, false),
str('gallery', 'ownerId', 36, true),
[`${T}/gallery/columns/boolean`, { key: 'hidden', required: false, default: false }],
[`${T}/gallery/columns/integer`, { key: 'reportCount', required: false, default: 0, min: 0 }],
str('gallery', 'searchText', 300, false),
[`${T}/gallery/indexes`, { key: 'search_text', type: 'fulltext', columns: ['searchText'] }],
[`${T}/gallery/indexes`, { key: 'owner', type: 'key', columns: ['ownerId'] }],
[`${T}/gallery/indexes`, { key: 'hidden', type: 'key', columns: ['hidden'] }],
[T, { tableId: 'reports', name: 'Reports', permissions: ['read("label:admin")'], rowSecurity: false }],
str('reports', 'galleryId', 36, true),
[`${T}/reports/columns/enum`, {
key: 'reason', elements: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true,
}],
str('reports', 'reporterHash', 64, true),
[`${T}/reports/indexes`, { key: 'gallery', type: 'key', columns: ['galleryId'] }],
['/v1/storage/buckets', {
bucketId: 'card-images', name: 'Card Images', permissions: ['create("users")'], fileSecurity: true,
maximumFileSize: 5242880, allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'],
compression: 'none', antivirus: false,
}],
['/v1/storage/buckets', {
bucketId: 'gallery-images', name: 'Gallery Images', permissions: ['create("users")'], fileSecurity: true,
maximumFileSize: 5242880, allowedFileExtensions: ['png', 'webp'],
compression: 'none', antivirus: false,
}],
]; ];
test('--check auf leerem Projekt: Exit 1, alle 7 Objekte FEHLT, nichts angelegt', async () => { test('--check auf leerem Projekt: Exit 1, alle 28 Objekte FEHLT, nichts angelegt', async () => {
await withFake({}, async (fake) => { await withFake({}, async (fake) => {
const { code, lines } = await run(fake, { check: true }); const { code, lines } = await run(fake, { check: true });
assert.equal(code, 1); assert.equal(code, 1);
assert.equal(EXPECTED_OBJECTS.length, 28);
assert.deepEqual(startsWith(lines, 'FEHLT'), EXPECTED_OBJECTS.map((o) => `FEHLT: ${o}`)); assert.deepEqual(startsWith(lines, 'FEHLT'), EXPECTED_OBJECTS.map((o) => `FEHLT: ${o}`));
assert.equal(startsWith(lines, 'OK').length, 0); assert.equal(startsWith(lines, 'OK').length, 0);
assert.ok(lines.includes('UNVOLLSTÄNDIG: 28 fehlend.'), lines.join('\n'));
assert.equal(fake.posts().length, 0); assert.equal(fake.posts().length, 0);
assert.equal(fake.otherWrites().length, 0); assert.equal(fake.otherWrites().length, 0);
}); });
@@ -358,29 +687,14 @@ describe('main gegen Fake-Server', () => {
await withFake({}, async (fake) => { await withFake({}, async (fake) => {
// Anlegen // Anlegen
const first = await run(fake); const first = await run(fake);
assert.equal(first.code, 0); assert.equal(first.code, 0, first.lines.join('\n'));
assert.deepEqual(startsWith(first.lines, 'ANGELEGT'), EXPECTED_OBJECTS.map((o) => `ANGELEGT: ${o}`)); assert.deepEqual(startsWith(first.lines, 'ANGELEGT'), EXPECTED_OBJECTS.map((o) => `ANGELEGT: ${o}`));
assert.equal(startsWith(first.lines, 'FEHLT').length, 0); assert.equal(startsWith(first.lines, 'FEHLT').length, 0);
assert.equal(startsWith(first.lines, 'ABWEICHUNG').length, 0); assert.equal(startsWith(first.lines, 'ABWEICHUNG').length, 0);
assert.ok(first.lines.includes('OK: Schema vollständig')); assert.ok(first.lines.includes('OK: Schema vollständig'));
// Bodies und Reihenfolge exakt wie im Brief // Bodies und Reihenfolge exakt wie im Brief
assert.deepEqual( assert.deepEqual(fake.posts().map((r) => [r.path, r.body]), EXPECTED_POSTS);
fake.posts().map((r) => [r.path, r.body]),
[
['/v1/tablesdb', { databaseId: 'aircard', name: 'AirCard' }],
['/v1/tablesdb/aircard/tables', { tableId: 'cards', name: 'Cards', permissions: ['create("users")'], rowSecurity: true }],
['/v1/tablesdb/aircard/tables/cards/columns/string', { key: 'name', size: 80, required: true }],
['/v1/tablesdb/aircard/tables/cards/columns/string', { key: 'config', size: 2000, required: true }],
['/v1/tablesdb/aircard/tables/cards/columns/string', { key: 'imageFileId', size: 36, required: true }],
['/v1/tablesdb/aircard/tables/cards/columns/string', { key: 'previewFileId', size: 36, required: true }],
['/v1/storage/buckets', {
bucketId: 'card-images', name: 'Card Images', permissions: ['create("users")'], fileSecurity: true,
maximumFileSize: 5242880, allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'],
compression: 'none', antivirus: false,
}],
],
);
// Jede Anfrage trägt die drei Header // Jede Anfrage trägt die drei Header
for (const r of fake.state.requests) { for (const r of fake.state.requests) {
@@ -391,8 +705,15 @@ describe('main gegen Fake-Server', () => {
// Der Key taucht nie in der Ausgabe auf // Der Key taucht nie in der Ausgabe auf
assert.ok(!first.lines.join('\n').includes(KEY)); assert.ok(!first.lines.join('\n').includes(KEY));
// Alle Spalten wurden vor dem Ende ausgewartet (Status available) // Alle Spalten und Indizes wurden vor dem Ende ausgewartet (Status available)
assert.equal(fake.state.columns.size, 18);
assert.equal(fake.state.indexes.size, 4);
for (const col of fake.state.columns.values()) assert.ok(col.gets >= 2, `Spalte ${col.key} nicht bis available geprüft`); for (const col of fake.state.columns.values()) assert.ok(col.gets >= 2, `Spalte ${col.key} nicht bis available geprüft`);
for (const idx of fake.state.indexes.values()) assert.ok(idx.gets >= 2, `Index ${idx.key} nicht bis available geprüft`);
// Indizes wurden erst angelegt, nachdem ALLE Spalten der Tabelle available waren
assert.deepEqual(fake.state.indexPosts.map((p) => `${p.table}.${p.key}`), ['gallery.search_text', 'gallery.owner', 'gallery.hidden', 'reports.gallery']);
assert.ok(fake.state.indexPosts.every((p) => p.allColumnsAvailable), JSON.stringify(fake.state.indexPosts));
// Zweite Prüfung // Zweite Prüfung
const postsBefore = fake.posts().length; const postsBefore = fake.posts().length;
@@ -411,6 +732,75 @@ describe('main gegen Fake-Server', () => {
}); });
}); });
test('Bestehendes Schema (nur cards + card-images) wird ergänzt, Bestehendes nicht angefasst', async () => {
await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0);
// Zustand vor der Erweiterung nachstellen
for (const map of [fake.state.tables, fake.state.columns, fake.state.indexes]) {
for (const id of [...map.keys()]) if (/^aircard\/(gallery|reports)(\/|$)/.test(id)) map.delete(id);
}
fake.state.buckets.delete('gallery-images');
const requestsBefore = fake.state.requests.length;
const check = await run(fake, { check: true });
assert.equal(check.code, 1);
assert.deepEqual(startsWith(check.lines, 'FEHLT'), NEW_OBJECTS.map((o) => `FEHLT: ${o}`));
assert.equal(fake.posts().length, EXPECTED_POSTS.length);
const apply = await run(fake);
assert.equal(apply.code, 0, apply.lines.join('\n'));
assert.deepEqual(startsWith(apply.lines, 'ANGELEGT'), NEW_OBJECTS.map((o) => `ANGELEGT: ${o}`));
assert.ok(apply.lines.includes('OK: Tabelle cards'));
assert.ok(apply.lines.includes('OK: Bucket card-images'));
const newPosts = fake.state.requests.slice(requestsBefore).filter((r) => r.method === 'POST');
assert.deepEqual(newPosts.map((r) => [r.path, r.body]), EXPECTED_POSTS.filter(([path, body]) => (
/\/(gallery|reports)(\/|$)/.test(path) || ['gallery', 'reports', 'gallery-images'].includes(body.tableId ?? body.bucketId)
)));
assert.ok(fake.state.indexPosts.every((p) => p.allColumnsAvailable));
});
});
test('Fehlende Indizes bei vorhandener Tabelle: --check meldet FEHLT, Anlegen legt nur sie an', async () => {
await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0);
fake.state.indexes.delete('aircard/gallery/owner');
fake.state.indexes.delete('aircard/reports/gallery');
const postsBefore = fake.posts().length;
const check = await run(fake, { check: true });
assert.equal(check.code, 1);
assert.deepEqual(startsWith(check.lines, 'FEHLT'), ['FEHLT: Index gallery.owner', 'FEHLT: Index reports.gallery']);
assert.equal(fake.posts().length, postsBefore);
const apply = await run(fake);
assert.equal(apply.code, 0);
assert.deepEqual(startsWith(apply.lines, 'ANGELEGT'), ['ANGELEGT: Index gallery.owner', 'ANGELEGT: Index reports.gallery']);
assert.deepEqual(
fake.posts().slice(postsBefore).map((r) => [r.path, r.body]),
[
[`${T}/gallery/indexes`, { key: 'owner', type: 'key', columns: ['ownerId'] }],
[`${T}/reports/indexes`, { key: 'gallery', type: 'key', columns: ['galleryId'] }],
],
);
});
});
test('Spalte nicht available (failed): Index wird nicht angelegt, nur als FEHLT gemeldet', async () => {
await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0);
fake.state.indexes.delete('aircard/gallery/owner');
fake.state.columns.get('aircard/gallery/title').forceStatus = 'failed';
const postsBefore = fake.posts().length;
const { code, lines } = await run(fake);
assert.equal(code, 1);
assert.deepEqual(startsWith(lines, 'ABWEICHUNG'), ['ABWEICHUNG: gallery.title.status ist failed, erwartet available']);
assert.deepEqual(startsWith(lines, 'FEHLT'), ['FEHLT: Index gallery.owner']);
assert.equal(fake.posts().length, postsBefore);
assert.ok(!lines.includes('OK: Schema vollständig'));
});
});
test('Abweichungen werden gemeldet, aber nicht verändert (Exit 1)', async () => { test('Abweichungen werden gemeldet, aber nicht verändert (Exit 1)', async () => {
await withFake({}, async (fake) => { await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0); assert.equal((await run(fake)).code, 0);
@@ -442,6 +832,71 @@ describe('main gegen Fake-Server', () => {
}); });
}); });
test('Abweichender Index-Typ oder -Spalten: ABWEICHUNG, nichts wird geändert (Exit 1)', async () => {
await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0);
fake.state.indexes.get('aircard/gallery/search_text').type = 'key';
fake.state.indexes.get('aircard/gallery/owner').type = 'unique';
fake.state.indexes.get('aircard/gallery/hidden').columns = ['reportCount'];
const requestsBefore = fake.state.requests.length;
for (const check of [true, false]) {
const { code, lines } = await run(fake, { check });
assert.equal(code, 1);
assert.deepEqual(startsWith(lines, 'ABWEICHUNG'), [
'ABWEICHUNG: gallery.search_text.type ist key, erwartet fulltext',
'ABWEICHUNG: gallery.owner.type ist unique, erwartet key',
'ABWEICHUNG: gallery.hidden.columns ist [reportCount], erwartet [hidden]',
]);
assert.ok(lines.includes('OK: Index reports.gallery'));
assert.ok(!lines.includes('OK: Schema vollständig'));
assert.equal(startsWith(lines, 'ANGELEGT').length, 0);
}
assert.ok(fake.state.requests.slice(requestsBefore).every((r) => r.method === 'GET'));
});
});
test('Abweichende neue Spalten (Standardwert, Typ, Enum-Elemente, Pflicht) und Buckets werden gemeldet', async () => {
await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0);
fake.state.columns.get('aircard/gallery/hidden').default = true;
fake.state.columns.get('aircard/gallery/reportCount').default = 5;
fake.state.columns.get('aircard/gallery/title').size = 100;
fake.state.columns.get('aircard/gallery/ownerId').required = false;
fake.state.columns.get('aircard/reports/reason').elements = ['spam', 'sonstiges'];
fake.state.columns.get('aircard/reports/galleryId').type = 'integer';
fake.state.tables.get('aircard/gallery').$permissions = ['read("any")'];
fake.state.tables.get('aircard/reports').rowSecurity = true;
fake.state.buckets.get('gallery-images').allowedFileExtensions = ['png', 'webp', 'svg'];
const requestsBefore = fake.state.requests.length;
const { code, lines } = await run(fake, { check: true });
assert.equal(code, 1);
assert.deepEqual(startsWith(lines, 'ABWEICHUNG'), [
'ABWEICHUNG: gallery.$permissions ist [read("any")], erwartet []',
'ABWEICHUNG: gallery.title.size ist 100, erwartet 60',
'ABWEICHUNG: gallery.ownerId.required ist false, erwartet true',
'ABWEICHUNG: gallery.hidden.default ist true, erwartet false',
'ABWEICHUNG: gallery.reportCount.default ist 5, erwartet 0',
'ABWEICHUNG: reports.rowSecurity ist true, erwartet false',
'ABWEICHUNG: reports.galleryId.type ist integer, erwartet string',
'ABWEICHUNG: reports.reason.elements ist [sonstiges, spam], erwartet [kartendaten, sonstiges, spam, unangemessen, urheberrecht]',
'ABWEICHUNG: gallery-images.allowedFileExtensions ist [png, svg, webp], erwartet [png, webp]',
]);
assert.ok(fake.state.requests.slice(requestsBefore).every((r) => r.method === 'GET'));
});
});
test('Integer-Spalte mit riesigen Standard-min/max der Antwort gilt als in Ordnung', async () => {
await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0);
const col = fake.state.columns.get('aircard/gallery/reportCount');
assert.equal(col.min, -9223372036854775808);
assert.equal(col.max, 9223372036854775807);
assert.equal((await run(fake, { check: true })).code, 0);
});
});
test('404 project_not_found bricht klar ab und meldet nichts als fehlend', async () => { test('404 project_not_found bricht klar ab und meldet nichts als fehlend', async () => {
await withFake({ project: 'anderes-projekt' }, async (fake) => { await withFake({ project: 'anderes-projekt' }, async (fake) => {
for (const check of [true, false]) { for (const check of [true, false]) {
@@ -516,6 +971,46 @@ describe('main gegen Fake-Server', () => {
}); });
}); });
test('Index wird nicht verfügbar: Abbruch nach Wartezeit, danach nichts mehr angelegt', async () => {
await withFake({ stuckIndexes: true }, async (fake) => {
const { code, lines } = await run(fake, { timeoutMs: 60 });
assert.equal(code, 2);
const abort = startsWith(lines, 'ABBRUCH');
assert.equal(abort.length, 1);
assert.match(abort[0], /gallery\.search_text/);
assert.match(abort[0], /nicht .*verfügbar/);
assert.equal(fake.state.tables.has('aircard/reports'), false);
assert.equal(fake.state.buckets.size, 0);
});
});
test('Index mit Status failed: Abbruch mit klarer Meldung', async () => {
await withFake({ failedIndexes: true }, async (fake) => {
const { code, lines } = await run(fake);
assert.equal(code, 2);
const abort = startsWith(lines, 'ABBRUCH');
assert.equal(abort.length, 1);
assert.match(abort[0], /Index gallery\.search_text/);
assert.match(abort[0], /failed/);
assert.equal(fake.state.buckets.size, 0);
});
});
test('Zugriff verweigert beim Anlegen eines Index: Abbruch (Exit 2) ohne den Key auszugeben', async () => {
const inject = (method, path) => (method === 'POST' && path.endsWith('/gallery/indexes')
? { status: 401, type: 'general_unauthorized_scope', message: 'missing scope (tables.write)' }
: null);
await withFake({ inject }, async (fake) => {
const { code, lines } = await run(fake);
assert.equal(code, 2);
const abort = startsWith(lines, 'ABBRUCH');
assert.equal(abort.length, 1);
assert.match(abort[0], /Index gallery\.search_text/);
assert.match(abort[0], /401/);
assert.ok(!lines.join('\n').includes(KEY));
});
});
test('Server nicht erreichbar: Abbruch mit klarer Meldung', async () => { test('Server nicht erreichbar: Abbruch mit klarer Meldung', async () => {
const probe = http.createServer(); const probe = http.createServer();
await new Promise((r) => probe.listen(0, '127.0.0.1', r)); await new Promise((r) => probe.listen(0, '127.0.0.1', r));
@@ -570,6 +1065,10 @@ describe('CLI', () => {
assert.equal(code, 1); assert.equal(code, 1);
assert.match(stdout, /FEHLT: Datenbank aircard/); assert.match(stdout, /FEHLT: Datenbank aircard/);
assert.match(stdout, /FEHLT: Bucket card-images/); assert.match(stdout, /FEHLT: Bucket card-images/);
assert.match(stdout, /FEHLT: Tabelle gallery/);
assert.match(stdout, /FEHLT: Index gallery\.search_text/);
assert.match(stdout, /FEHLT: Tabelle reports/);
assert.match(stdout, /FEHLT: Bucket gallery-images/);
assert.ok(!(stdout + stderr).includes(KEY)); assert.ok(!(stdout + stderr).includes(KEY));
assert.equal(fake.posts().length, 0); assert.equal(fake.posts().length, 0);
}); });