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:
1 parent
25ff496548
commit
9e4f4ffacc
3 files changed
+803
-127
No files matched your search
@@ -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
@@ -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}`);
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user