feat(appwrite): Setup-Skript und Console-Anleitung
web/scripts/appwrite-setup.mjs legt Datenbank aircard, Tabelle cards (4 Spalten) und Bucket card-images idempotent per fetch an, wartet auf Spalten-Status available und meldet Abweichungen, ohne sie zu ändern (--check: nichts anlegen, Exit 1 bei Fehlendem/Abweichendem). docs/appwrite-console.md ist die Klickliste für Projekt, Plattformen, Auth, Dienste und API-Key. Tests laufen mit node --test gegen einen Fake-Appwrite; Vitest nimmt nur src/**/*.test.ts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
906bdf1033
commit
d1d4d621a7
4 files changed
+1100
No files matched your search
@@ -0,0 +1,71 @@
|
||||
# Appwrite-Console: Projekt „AirCard“ einrichten
|
||||
|
||||
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.
|
||||
|
||||
## 1. Projekt anlegen
|
||||
|
||||
1. In der Console anmelden und die bestehende Organisation öffnen.
|
||||
2. **Create project** klicken.
|
||||
3. Name: `AirCard`.
|
||||
4. Beim Feld **Project ID** auf das Stift-Symbol (bzw. „Edit“) klicken und `aircard` eintragen. Die ID lässt sich später nicht mehr ändern; sie muss genau `aircard` heißen.
|
||||
5. **Create** klicken.
|
||||
|
||||
## 2. Web-Plattformen eintragen
|
||||
|
||||
Projekt `AirCard` → **Overview** → **Integrations** → **Platforms** → **Add platform** → **Web**.
|
||||
|
||||
1. Name `AirCard Website`, Hostname `aircard-ios.org` → **Create**.
|
||||
2. Noch einmal **Add platform** → **Web**: Name `Entwicklung`, Hostname `localhost` → **Create**.
|
||||
|
||||
## 3. Auth-Einstellungen
|
||||
|
||||
Linke Leiste → **Auth** → **Settings**.
|
||||
|
||||
1. Bei **Auth methods** nur **Email/Password** eingeschaltet lassen. Ausschalten: **Magic URL**, **Email OTP**, **Phone**, **Anonymous**, **JWT** (steht dort auch **Invites**: ebenfalls aus).
|
||||
2. **Session duration**: `30` Tage → **Update**.
|
||||
3. **Max sessions** (Sitzungen pro Nutzer): `5` → **Update**.
|
||||
4. **Limit** (Nutzerlimit): `2000` → **Update**.
|
||||
5. **Password dictionary**: einschalten.
|
||||
|
||||
Keine E-Mail-Bestätigung nötig, Mindestlänge 8 Zeichen ist Standard – dafür ist nichts einzustellen. Die „Passwort vergessen“-Mail geht über den globalen Server-SMTP (Absender WEBklar), auch dafür ist nichts einzustellen.
|
||||
|
||||
## 4. Nicht benötigte Dienste abschalten
|
||||
|
||||
Linke Leiste → **Settings** → Reiter **Services**.
|
||||
|
||||
1. Ausschalten: **Functions**, **Sites**, **Messaging**, **Teams**, **GraphQL**.
|
||||
2. Alles andere (Account, Avatars, Databases, Locale, Storage …) bleibt an.
|
||||
|
||||
## 5. API-Key `aircard-provision` anlegen
|
||||
|
||||
Dieser Key gehört nur dem Setup-Skript. Die Website selbst braucht ihn nicht.
|
||||
|
||||
1. **Overview** → **Integrations** → **API keys** → **Create API key**.
|
||||
2. Name: `aircard-provision`. Ablauf (Expiration): **Never**.
|
||||
3. Unter **Scopes** genau diese acht ankreuzen (Gruppe Database bzw. Storage):
|
||||
- `databases.read` und `databases.write`
|
||||
- `tables.read` und `tables.write`
|
||||
- `columns.read` und `columns.write`
|
||||
- `buckets.read` und `buckets.write`
|
||||
4. **Create** klicken.
|
||||
5. Auf der Seite des neuen Keys den **Secret**-Wert kopieren (Auge-Symbol zum Einblenden, dann kopieren).
|
||||
|
||||
## 6. Key auf dem Server eintragen
|
||||
|
||||
Der Key gehört in die Datei `/home/webklar/apps/.env`, nirgendwo sonst.
|
||||
|
||||
1. Auf dem Server die Datei öffnen, z. B. `nano /home/webklar/apps/.env`.
|
||||
2. Ans Ende springen und eine neue Zeile anfügen, ohne Anführungszeichen und ohne Leerzeichen um das `=`:
|
||||
|
||||
```
|
||||
AIRCARD_APPWRITE_API_KEY=<hier den kopierten Secret-Wert einfügen>
|
||||
```
|
||||
|
||||
3. Speichern (in `nano`: Strg+O, Enter, Strg+X). Andere Zeilen nicht verändern.
|
||||
4. Den Key **nicht im Chat teilen**, nicht in Screenshots zeigen und nirgends einchecken. Claude liest ihn nie selbst, das Skript bekommt ihn über `node --env-file=/home/webklar/apps/.env`.
|
||||
|
||||
## 7. Bescheid geben
|
||||
|
||||
Schreib Claude „Console fertig“. Danach legt Claude Datenbank `aircard`, Tabelle `cards` und Bucket `card-images` mit dem Skript an und prüft sie.
|
||||
@@ -0,0 +1,448 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* AirCard – Appwrite-Schema-Setup (idempotent)
|
||||
*
|
||||
* Legt im selbst gehosteten Appwrite (Projekt `aircard`) Datenbank, Tabelle
|
||||
* `cards` (TablesDB) mit vier Spalten und den Bucket `card-images` an.
|
||||
* Reines `fetch`, keine Abhängigkeiten, Node 22+.
|
||||
*
|
||||
* Aufruf (vom Repo-Wurzelverzeichnis):
|
||||
* node --env-file=/home/webklar/apps/.env web/scripts/appwrite-setup.mjs [--check]
|
||||
*
|
||||
* Umgebung:
|
||||
* AIRCARD_APPWRITE_API_KEY Projekt-API-Key `aircard-provision` (Pflicht)
|
||||
* AIRCARD_APPWRITE_ENDPOINT optional, Standard https://appwrite.webklar.com/v1
|
||||
*
|
||||
* Ohne --check: fehlende Objekte werden angelegt. Bestehende Objekte werden
|
||||
* nur verglichen – Abweichungen werden gemeldet, nie geändert.
|
||||
* Mit --check: es wird nichts angelegt; Exit 1, wenn etwas fehlt oder abweicht.
|
||||
*
|
||||
* Ausgabe: eine Zeile je Objekt –
|
||||
* OK: … FEHLT: … ANGELEGT: … ABWEICHUNG: <feld> ist <x>, erwartet <y>
|
||||
*
|
||||
* Exit-Codes: 0 = Schema vollständig, 1 = fehlt/weicht ab, 2 = Abbruch
|
||||
* (fehlender Key, ungültiger Aufruf, Server-/Zugriffsfehler).
|
||||
*
|
||||
* Der API-Key wird nie ausgegeben (weder Wert noch Header).
|
||||
*/
|
||||
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
export const DEFAULT_ENDPOINT = 'https://appwrite.webklar.com/v1';
|
||||
const PROJECT = 'aircard';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Soll-Zustand
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const DESIRED = {
|
||||
database: { id: 'aircard', name: 'AirCard' },
|
||||
table: {
|
||||
id: 'cards',
|
||||
name: 'Cards',
|
||||
permissions: ['create("users")'],
|
||||
rowSecurity: true,
|
||||
columns: [
|
||||
{ key: 'name', size: 80, required: true },
|
||||
{ key: 'config', size: 2000, required: true },
|
||||
{ key: 'imageFileId', size: 36, required: true },
|
||||
{ key: 'previewFileId', size: 36, required: true },
|
||||
],
|
||||
},
|
||||
bucket: {
|
||||
id: 'card-images',
|
||||
name: 'Card Images',
|
||||
permissions: ['create("users")'],
|
||||
fileSecurity: true,
|
||||
maximumFileSize: 5242880,
|
||||
allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'],
|
||||
compression: 'none',
|
||||
antivirus: false,
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reine Helfer (ohne Netzwerk, einzeln testbar)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 404 heißt „Objekt fehlt" – außer der ganze Projekt-Kontext fehlt (project_not_found). */
|
||||
export function isMissing(status, body) {
|
||||
return status === 404 && body?.type !== 'project_not_found';
|
||||
}
|
||||
|
||||
const fmt = (v) => {
|
||||
if (typeof v === 'string') return v;
|
||||
if (Array.isArray(v)) return `[${v.join(', ')}]`;
|
||||
return JSON.stringify(v) ?? 'undefined';
|
||||
};
|
||||
|
||||
const sorted = (list) => [...(list ?? [])].sort();
|
||||
const sameList = (a, b) => JSON.stringify(sorted(a)) === JSON.stringify(sorted(b));
|
||||
|
||||
function mismatch(field, actual, expected) {
|
||||
return `${field} ist ${fmt(actual)}, erwartet ${fmt(expected)}`;
|
||||
}
|
||||
|
||||
/** Vergleich der Tabelle: `$permissions` (Reihenfolge egal) und `rowSecurity`. */
|
||||
export function compareTable(actual, desired) {
|
||||
const out = [];
|
||||
if (!sameList(actual.$permissions, desired.permissions)) {
|
||||
out.push(mismatch('$permissions', sorted(actual.$permissions), sorted(desired.permissions)));
|
||||
}
|
||||
if (actual.rowSecurity !== desired.rowSecurity) {
|
||||
out.push(mismatch('rowSecurity', actual.rowSecurity, desired.rowSecurity));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Vergleich einer Spalte: `size`, `required`, `status` (erwartet `available`). */
|
||||
export function compareColumn(actual, desired) {
|
||||
const out = [];
|
||||
if (actual.size !== desired.size) out.push(mismatch('size', actual.size, desired.size));
|
||||
if (actual.required !== desired.required) out.push(mismatch('required', actual.required, desired.required));
|
||||
if (actual.status !== 'available') out.push(mismatch('status', actual.status, 'available'));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Vergleich des Buckets: Rechte, `fileSecurity`, `maximumFileSize`, `allowedFileExtensions` (sortiert). */
|
||||
export function compareBucket(actual, desired) {
|
||||
const out = [];
|
||||
if (!sameList(actual.$permissions, desired.permissions)) {
|
||||
out.push(mismatch('$permissions', sorted(actual.$permissions), sorted(desired.permissions)));
|
||||
}
|
||||
if (actual.fileSecurity !== desired.fileSecurity) {
|
||||
out.push(mismatch('fileSecurity', actual.fileSecurity, desired.fileSecurity));
|
||||
}
|
||||
if (actual.maximumFileSize !== desired.maximumFileSize) {
|
||||
out.push(mismatch('maximumFileSize', actual.maximumFileSize, desired.maximumFileSize));
|
||||
}
|
||||
if (!sameList(actual.allowedFileExtensions, desired.allowedFileExtensions)) {
|
||||
out.push(mismatch('allowedFileExtensions', sorted(actual.allowedFileExtensions), sorted(desired.allowedFileExtensions)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zugriff auf die Appwrite-REST-API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 20_000;
|
||||
|
||||
/** Abbruch mit verständlicher Meldung (Exit 2). Enthält nie den Key. */
|
||||
class SetupError extends Error {}
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/** `api(method, path, body?)` → `{ status, body }` für Aufrufe gegen Endpoint + Pfad. */
|
||||
function createApi({ endpoint, key, project }) {
|
||||
const base = endpoint.replace(/\/+$/, '');
|
||||
return async function api(method, path, body) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${base}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Appwrite-Project': project,
|
||||
'X-Appwrite-Key': key,
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
} catch (err) {
|
||||
// Nur Fehlercode bzw. -name ausgeben, nie das Request-Objekt.
|
||||
throw new SetupError(`Appwrite nicht erreichbar (${base}): ${err?.cause?.code ?? err?.name ?? 'unbekannter Fehler'}.`);
|
||||
}
|
||||
const text = await res.text();
|
||||
let json;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : undefined;
|
||||
} catch {
|
||||
json = { message: text.replace(/\s+/g, ' ').slice(0, 200) };
|
||||
}
|
||||
if (typeof json?.message === 'string') json.message = json.message.split(key).join('***');
|
||||
return { status: res.status, body: json };
|
||||
};
|
||||
}
|
||||
|
||||
/** Klare deutsche Meldung für eine unerwartete Antwort. */
|
||||
function describeFailure(status, body, what) {
|
||||
const detail = [body?.type, body?.message].filter((part) => typeof part === 'string' && part).join(': ');
|
||||
const tail = detail ? ` Server: ${detail}` : '';
|
||||
if (status === 404 && body?.type === 'project_not_found') {
|
||||
return `Das Projekt „${PROJECT}“ existiert auf dem Server nicht. Bitte zuerst docs/appwrite-console.md abarbeiten (Projekt mit der ID „${PROJECT}“ anlegen).`;
|
||||
}
|
||||
if (status === 401 || status === 403) {
|
||||
return `${what}: Zugriff verweigert (HTTP ${status}). API-Key und Scopes prüfen (databases, tables, columns, buckets – jeweils read/write).${tail}`;
|
||||
}
|
||||
if (status >= 500) return `${what}: Serverfehler (HTTP ${status}).${tail}`;
|
||||
return `${what}: unerwartete Antwort (HTTP ${status}).${tail}`;
|
||||
}
|
||||
|
||||
/** GET: Objekt oder `null` (fehlt). Jeder andere Fehler bricht ab. */
|
||||
async function getObject(ctx, path, what) {
|
||||
const res = await ctx.api('GET', path);
|
||||
if (res.status === 200) return res.body;
|
||||
if (isMissing(res.status, res.body)) return null;
|
||||
throw new SetupError(describeFailure(res.status, res.body, what));
|
||||
}
|
||||
|
||||
async function createObject(ctx, path, body, what) {
|
||||
const res = await ctx.api('POST', path, body);
|
||||
if (res.status >= 200 && res.status < 300) return res.body;
|
||||
throw new SetupError(describeFailure(res.status, res.body, what));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Objekte sicherstellen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DB_ID = DESIRED.database.id;
|
||||
const columnPath = (key) => `/tablesdb/${DB_ID}/tables/${DESIRED.table.id}/columns/${key}`;
|
||||
|
||||
/** Gibt zurück, ob die Datenbank (jetzt) existiert. */
|
||||
export async function ensureDatabase(ctx) {
|
||||
const { id, name } = DESIRED.database;
|
||||
const label = `Datenbank ${id}`;
|
||||
if (await getObject(ctx, `/tablesdb/${id}`, label)) {
|
||||
ctx.ok(label);
|
||||
return true;
|
||||
}
|
||||
if (ctx.check) {
|
||||
ctx.missing(label);
|
||||
return false;
|
||||
}
|
||||
await createObject(ctx, '/tablesdb', { databaseId: id, name }, label);
|
||||
ctx.created(label);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Gibt zurück, ob die Tabelle (jetzt) existiert. `parentExists`: Datenbank vorhanden. */
|
||||
export async function ensureTable(ctx, parentExists) {
|
||||
const t = DESIRED.table;
|
||||
const label = `Tabelle ${t.id}`;
|
||||
const actual = parentExists ? await getObject(ctx, `/tablesdb/${DB_ID}/tables/${t.id}`, label) : null;
|
||||
if (actual) {
|
||||
const deviations = compareTable(actual, t);
|
||||
if (deviations.length) ctx.deviations(t.id, deviations);
|
||||
else ctx.ok(label);
|
||||
return true;
|
||||
}
|
||||
if (ctx.check) {
|
||||
ctx.missing(label);
|
||||
return false;
|
||||
}
|
||||
await createObject(
|
||||
ctx,
|
||||
`/tablesdb/${DB_ID}/tables`,
|
||||
{ tableId: t.id, name: t.name, permissions: t.permissions, rowSecurity: t.rowSecurity },
|
||||
label,
|
||||
);
|
||||
ctx.created(label);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wartet (bis `ctx.timeoutMs`), bis alle genannten Spalten `available` sind.
|
||||
* Gibt eine Map Schlüssel → aktueller Spaltenzustand zurück.
|
||||
*/
|
||||
export async function waitAvailable(ctx, tableId, keys) {
|
||||
const deadline = Date.now() + ctx.timeoutMs;
|
||||
const done = new Map();
|
||||
let pending = [...keys];
|
||||
for (;;) {
|
||||
const still = [];
|
||||
for (const key of pending) {
|
||||
const label = `Spalte ${tableId}.${key}`;
|
||||
const actual = await getObject(ctx, columnPath(key), label);
|
||||
if (!actual) throw new SetupError(`${label} ist beim Warten auf „available“ verschwunden.`);
|
||||
if (actual.status === 'available') {
|
||||
done.set(key, actual);
|
||||
} 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.`);
|
||||
} else {
|
||||
still.push(key);
|
||||
}
|
||||
}
|
||||
if (!still.length) return done;
|
||||
if (Date.now() >= deadline) {
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
pending = still;
|
||||
await sleep(ctx.pollMs);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spalten nacheinander anlegen, danach auf `available` warten – damit sind
|
||||
* sie bereit, bevor jemand schreibt. `parentExists`: Tabelle vorhanden.
|
||||
*/
|
||||
export async function ensureColumns(ctx, parentExists) {
|
||||
const t = DESIRED.table;
|
||||
const entries = [];
|
||||
for (const col of t.columns) {
|
||||
const label = `Spalte ${t.id}.${col.key}`;
|
||||
const actual = parentExists ? await getObject(ctx, columnPath(col.key), label) : null;
|
||||
if (actual) {
|
||||
entries.push({ col, label, actual, created: false });
|
||||
} else if (ctx.check) {
|
||||
ctx.missing(label);
|
||||
} else {
|
||||
await createObject(
|
||||
ctx,
|
||||
`/tablesdb/${DB_ID}/tables/${t.id}/columns/string`,
|
||||
{ key: col.key, size: col.size, required: col.required },
|
||||
label,
|
||||
);
|
||||
ctx.created(label);
|
||||
entries.push({ col, label, actual: null, created: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (!ctx.check) {
|
||||
const waiting = entries.filter((e) => e.created || e.actual.status === 'processing');
|
||||
if (waiting.length) {
|
||||
const ready = await waitAvailable(ctx, t.id, waiting.map((e) => e.col.key));
|
||||
for (const e of waiting) e.actual = ready.get(e.col.key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const e of entries) {
|
||||
const deviations = compareColumn(e.actual, e.col);
|
||||
if (deviations.length) ctx.deviations(`${t.id}.${e.col.key}`, deviations);
|
||||
else if (!e.created) ctx.ok(e.label);
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureBucket(ctx) {
|
||||
const b = DESIRED.bucket;
|
||||
const label = `Bucket ${b.id}`;
|
||||
const actual = await getObject(ctx, `/storage/buckets/${b.id}`, label);
|
||||
if (actual) {
|
||||
const deviations = compareBucket(actual, b);
|
||||
if (deviations.length) ctx.deviations(b.id, deviations);
|
||||
else ctx.ok(label);
|
||||
return;
|
||||
}
|
||||
if (ctx.check) {
|
||||
ctx.missing(label);
|
||||
return;
|
||||
}
|
||||
await createObject(
|
||||
ctx,
|
||||
'/storage/buckets',
|
||||
{
|
||||
bucketId: b.id,
|
||||
name: b.name,
|
||||
permissions: b.permissions,
|
||||
fileSecurity: b.fileSecurity,
|
||||
maximumFileSize: b.maximumFileSize,
|
||||
allowedFileExtensions: b.allowedFileExtensions,
|
||||
compression: b.compression,
|
||||
antivirus: b.antivirus,
|
||||
},
|
||||
label,
|
||||
);
|
||||
ctx.created(label);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ablauf
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Führt das Setup (oder mit `check` nur die Prüfung) aus und gibt den Exit-Code
|
||||
* zurück: 0 vollständig, 1 fehlt/weicht ab, 2 Abbruch.
|
||||
*/
|
||||
export async function main({
|
||||
endpoint = DEFAULT_ENDPOINT,
|
||||
key,
|
||||
project = PROJECT,
|
||||
check = false,
|
||||
log = console.log,
|
||||
pollMs = 1000,
|
||||
timeoutMs = 30_000,
|
||||
} = {}) {
|
||||
const stats = { missing: 0, deviations: 0 };
|
||||
const ctx = {
|
||||
api: createApi({ endpoint, key, project }),
|
||||
check,
|
||||
pollMs,
|
||||
timeoutMs,
|
||||
ok: (label) => log(`OK: ${label}`),
|
||||
created: (label) => log(`ANGELEGT: ${label}`),
|
||||
missing: (label) => {
|
||||
stats.missing += 1;
|
||||
log(`FEHLT: ${label}`);
|
||||
},
|
||||
deviations: (prefix, list) => {
|
||||
for (const text of list) {
|
||||
stats.deviations += 1;
|
||||
log(`ABWEICHUNG: ${prefix}.${text}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
log(`Appwrite ${endpoint}, Projekt ${project}, ${check ? 'nur prüfen (--check)' : 'prüfen und fehlende Objekte anlegen'}`);
|
||||
try {
|
||||
const databaseExists = await ensureDatabase(ctx);
|
||||
const tableExists = await ensureTable(ctx, databaseExists);
|
||||
await ensureColumns(ctx, tableExists);
|
||||
await ensureBucket(ctx);
|
||||
} catch (err) {
|
||||
if (!(err instanceof SetupError)) throw err;
|
||||
log(`ABBRUCH: ${err.message}`);
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (stats.missing || stats.deviations) {
|
||||
const parts = [];
|
||||
if (stats.missing) parts.push(`${stats.missing} fehlend`);
|
||||
if (stats.deviations) parts.push(`${stats.deviations} Abweichung(en)`);
|
||||
log(`UNVOLLSTÄNDIG: ${parts.join(', ')}.`);
|
||||
if (stats.missing) log('Zum Anlegen das Skript ohne --check starten.');
|
||||
if (stats.deviations) log('Abweichungen werden nie automatisch geändert – bitte in der Console korrigieren.');
|
||||
return 1;
|
||||
}
|
||||
log('OK: Schema vollständig');
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Kommandozeile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function cli() {
|
||||
const args = process.argv.slice(2);
|
||||
const unknown = args.filter((arg) => arg !== '--check');
|
||||
if (unknown.length) {
|
||||
console.error(`Unbekannte Option: ${unknown.join(' ')}`);
|
||||
console.error('Aufruf: node --env-file=/home/webklar/apps/.env web/scripts/appwrite-setup.mjs [--check]');
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
const key = process.env.AIRCARD_APPWRITE_API_KEY?.trim();
|
||||
if (!key) {
|
||||
console.error('Fehler: AIRCARD_APPWRITE_API_KEY fehlt.');
|
||||
console.error('Den API-Key „aircard-provision“ als Zeile AIRCARD_APPWRITE_API_KEY=… in /home/webklar/apps/.env eintragen (siehe docs/appwrite-console.md)');
|
||||
console.error('und das Skript mit --env-file=/home/webklar/apps/.env starten.');
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
process.exitCode = await main({
|
||||
endpoint: process.env.AIRCARD_APPWRITE_ENDPOINT || DEFAULT_ENDPOINT,
|
||||
key,
|
||||
check: args.includes('--check'),
|
||||
});
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
try {
|
||||
await cli();
|
||||
} catch (err) {
|
||||
console.error(`Unerwarteter Fehler: ${err?.message ?? err}`);
|
||||
process.exitCode = 2;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
// Tests für web/scripts/appwrite-setup.mjs. Aufruf: node --test web/scripts/appwrite-setup.test.mjs
|
||||
// Nur Node-Bordmittel. Der "Appwrite-Server" ist ein Fake auf Port 0 (Speicher im Prozess),
|
||||
// es wird nie ein echter Server oder ein echter Key benutzt.
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
DESIRED,
|
||||
DEFAULT_ENDPOINT,
|
||||
isMissing,
|
||||
compareTable,
|
||||
compareColumn,
|
||||
compareBucket,
|
||||
main,
|
||||
} from './appwrite-setup.mjs';
|
||||
|
||||
const SCRIPT = fileURLToPath(new URL('./appwrite-setup.mjs', import.meta.url));
|
||||
const KEY = 'test-key-0123456789abcdef-geheim';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake-Appwrite (1.8-ähnliche REST-Formen, alles im Speicher)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createFakeAppwrite({ project = 'aircard', key = KEY, stuckColumns = false, inject = null } = {}) {
|
||||
const state = {
|
||||
databases: new Map(),
|
||||
tables: new Map(), // "db/table"
|
||||
columns: new Map(), // "db/table/key"
|
||||
buckets: new Map(),
|
||||
requests: [],
|
||||
};
|
||||
|
||||
const send = (res, status, body) => {
|
||||
res.writeHead(status, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify(body));
|
||||
};
|
||||
const fail = (res, status, type, message) => send(res, status, { message, code: status, type, version: '1.8.1' });
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const chunks = [];
|
||||
for await (const chunk of req) chunks.push(chunk);
|
||||
const raw = Buffer.concat(chunks).toString();
|
||||
const body = raw ? JSON.parse(raw) : undefined;
|
||||
const path = new URL(req.url, 'http://fake').pathname;
|
||||
state.requests.push({ method: req.method, path, body, headers: req.headers });
|
||||
|
||||
if (req.headers['x-appwrite-project'] !== project) {
|
||||
return fail(res, 404, 'project_not_found', 'Project with the requested ID could not be found.');
|
||||
}
|
||||
if (req.headers['x-appwrite-key'] !== key) {
|
||||
return fail(res, 401, 'user_unauthorized', 'The current user is not authorized to perform the requested action.');
|
||||
}
|
||||
const injected = inject?.(req.method, path);
|
||||
if (injected) return fail(res, injected.status, injected.type, injected.message ?? 'injected');
|
||||
|
||||
const seg = path.replace(/^\/v1/, '').split('/').filter(Boolean);
|
||||
const m = req.method;
|
||||
|
||||
// POST /tablesdb
|
||||
if (m === 'POST' && seg.length === 1 && seg[0] === 'tablesdb') {
|
||||
if (state.databases.has(body.databaseId)) return fail(res, 409, 'database_already_exists', 'exists');
|
||||
const db = { $id: body.databaseId, name: body.name, enabled: true };
|
||||
state.databases.set(db.$id, db);
|
||||
return send(res, 201, db);
|
||||
}
|
||||
// GET /tablesdb/:db
|
||||
if (m === 'GET' && seg.length === 2 && seg[0] === 'tablesdb') {
|
||||
const db = state.databases.get(seg[1]);
|
||||
return db ? send(res, 200, db) : fail(res, 404, 'database_not_found', 'Database not found');
|
||||
}
|
||||
// POST /tablesdb/:db/tables
|
||||
if (m === 'POST' && seg.length === 3 && seg[0] === 'tablesdb' && seg[2] === 'tables') {
|
||||
if (!state.databases.has(seg[1])) return fail(res, 404, 'database_not_found', 'Database not found');
|
||||
const id = `${seg[1]}/${body.tableId}`;
|
||||
if (state.tables.has(id)) return fail(res, 409, 'table_already_exists', 'exists');
|
||||
const table = {
|
||||
$id: body.tableId, name: body.name, $permissions: body.permissions ?? [],
|
||||
rowSecurity: body.rowSecurity ?? true, enabled: true,
|
||||
};
|
||||
state.tables.set(id, table);
|
||||
return send(res, 201, table);
|
||||
}
|
||||
// GET /tablesdb/:db/tables/:t
|
||||
if (m === 'GET' && seg.length === 4 && seg[0] === 'tablesdb' && seg[2] === 'tables') {
|
||||
if (!state.databases.has(seg[1])) return fail(res, 404, 'database_not_found', 'Database not found');
|
||||
const table = state.tables.get(`${seg[1]}/${seg[3]}`);
|
||||
return table ? send(res, 200, table) : fail(res, 404, 'table_not_found', 'Table not found');
|
||||
}
|
||||
// POST /tablesdb/:db/tables/:t/columns/string
|
||||
if (m === 'POST' && seg.length === 6 && seg[2] === 'tables' && seg[4] === 'columns' && seg[5] === 'string') {
|
||||
if (!state.tables.has(`${seg[1]}/${seg[3]}`)) return fail(res, 404, 'table_not_found', 'Table not found');
|
||||
const id = `${seg[1]}/${seg[3]}/${body.key}`;
|
||||
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 };
|
||||
state.columns.set(id, col);
|
||||
const { gets, ...out } = col;
|
||||
return send(res, 202, out);
|
||||
}
|
||||
// GET /tablesdb/:db/tables/:t/columns/:key
|
||||
if (m === 'GET' && seg.length === 6 && seg[2] === 'tables' && seg[4] === 'columns') {
|
||||
if (!state.tables.has(`${seg[1]}/${seg[3]}`)) return fail(res, 404, 'table_not_found', 'Table not found');
|
||||
const col = state.columns.get(`${seg[1]}/${seg[3]}/${seg[5]}`);
|
||||
if (!col) return fail(res, 404, 'column_not_found', 'Column not found');
|
||||
col.gets += 1;
|
||||
const status = stuckColumns ? 'processing' : col.gets >= 2 ? 'available' : 'processing';
|
||||
const { gets, ...out } = col;
|
||||
return send(res, 200, { ...out, status });
|
||||
}
|
||||
// POST /storage/buckets
|
||||
if (m === 'POST' && path === '/v1/storage/buckets') {
|
||||
if (state.buckets.has(body.bucketId)) return fail(res, 409, 'storage_bucket_already_exists', 'exists');
|
||||
const bucket = {
|
||||
$id: body.bucketId, name: body.name, $permissions: body.permissions ?? [],
|
||||
fileSecurity: body.fileSecurity, maximumFileSize: body.maximumFileSize,
|
||||
allowedFileExtensions: body.allowedFileExtensions, compression: body.compression,
|
||||
antivirus: body.antivirus, enabled: true,
|
||||
};
|
||||
state.buckets.set(bucket.$id, bucket);
|
||||
return send(res, 201, bucket);
|
||||
}
|
||||
// GET /storage/buckets/:id
|
||||
if (m === 'GET' && seg.length === 3 && seg[0] === 'storage' && seg[1] === 'buckets') {
|
||||
const bucket = state.buckets.get(seg[2]);
|
||||
return bucket ? send(res, 200, bucket) : fail(res, 404, 'storage_bucket_not_found', 'Bucket not found');
|
||||
}
|
||||
return fail(res, 404, 'general_route_not_found', 'Route not found');
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const { port } = server.address();
|
||||
resolve({
|
||||
state,
|
||||
endpoint: `http://127.0.0.1:${port}/v1`,
|
||||
posts: () => state.requests.filter((r) => r.method === 'POST'),
|
||||
otherWrites: () => state.requests.filter((r) => !['GET', 'POST'].includes(r.method)),
|
||||
close: () => new Promise((r) => { server.closeAllConnections?.(); server.close(r); }),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function withFake(opts, fn) {
|
||||
const fake = await createFakeAppwrite(opts);
|
||||
try {
|
||||
return await fn(fake);
|
||||
} finally {
|
||||
await fake.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function run(fake, opts = {}) {
|
||||
const lines = [];
|
||||
const code = await main({
|
||||
endpoint: fake.endpoint,
|
||||
key: KEY,
|
||||
project: 'aircard',
|
||||
check: false,
|
||||
log: (line) => lines.push(line),
|
||||
pollMs: 1,
|
||||
timeoutMs: 2000,
|
||||
...opts,
|
||||
});
|
||||
return { code, lines };
|
||||
}
|
||||
|
||||
const startsWith = (lines, prefix) => lines.filter((l) => l.startsWith(prefix));
|
||||
|
||||
function runCli(args, env) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [SCRIPT, ...args], {
|
||||
env: { PATH: process.env.PATH, ...env },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (d) => { stdout += d; });
|
||||
child.stderr.on('data', (d) => { stderr += d; });
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => resolve({ code, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reine Helfer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('DESIRED', () => {
|
||||
test('entspricht dem Schema aus der Spec', () => {
|
||||
assert.deepEqual(DESIRED.database, { id: 'aircard', name: 'AirCard' });
|
||||
assert.equal(DESIRED.table.id, 'cards');
|
||||
assert.equal(DESIRED.table.name, 'Cards');
|
||||
assert.deepEqual(DESIRED.table.permissions, ['create("users")']);
|
||||
assert.equal(DESIRED.table.rowSecurity, true);
|
||||
assert.deepEqual(
|
||||
DESIRED.table.columns.map((c) => [c.key, c.size, c.required]),
|
||||
[['name', 80, true], ['config', 2000, true], ['imageFileId', 36, true], ['previewFileId', 36, true]],
|
||||
);
|
||||
assert.deepEqual(DESIRED.bucket, {
|
||||
id: 'card-images',
|
||||
name: 'Card Images',
|
||||
permissions: ['create("users")'],
|
||||
fileSecurity: true,
|
||||
maximumFileSize: 5242880,
|
||||
allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'],
|
||||
compression: 'none',
|
||||
antivirus: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('Standard-Endpoint ist der Server von WEBklar', () => {
|
||||
assert.equal(DEFAULT_ENDPOINT, 'https://appwrite.webklar.com/v1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMissing', () => {
|
||||
test('404 mit anderem Typ gilt als fehlend', () => {
|
||||
assert.equal(isMissing(404, { type: 'database_not_found' }), true);
|
||||
assert.equal(isMissing(404, { type: 'table_not_found' }), true);
|
||||
assert.equal(isMissing(404, { type: 'column_not_found' }), true);
|
||||
assert.equal(isMissing(404, { message: 'kein JSON' }), true);
|
||||
assert.equal(isMissing(404, undefined), true);
|
||||
});
|
||||
|
||||
test('404 project_not_found ist NICHT "fehlt"', () => {
|
||||
assert.equal(isMissing(404, { type: 'project_not_found' }), false);
|
||||
});
|
||||
|
||||
test('alle anderen Status sind nicht "fehlt"', () => {
|
||||
assert.equal(isMissing(200, {}), false);
|
||||
assert.equal(isMissing(401, { type: 'user_unauthorized' }), false);
|
||||
assert.equal(isMissing(403, {}), false);
|
||||
assert.equal(isMissing(500, { type: 'general_server_error' }), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareTable', () => {
|
||||
const ok = () => ({ $id: 'cards', name: 'Cards', $permissions: ['create("users")'], rowSecurity: true });
|
||||
|
||||
test('passende Tabelle: keine Abweichung', () => {
|
||||
assert.deepEqual(compareTable(ok(), DESIRED.table), []);
|
||||
});
|
||||
|
||||
test('Rechte werden unabhängig von der Reihenfolge verglichen', () => {
|
||||
const desired = { ...DESIRED.table, permissions: ['create("users")', 'read("any")'] };
|
||||
const actual = { ...ok(), $permissions: ['read("any")', 'create("users")'] };
|
||||
assert.deepEqual(compareTable(actual, desired), []);
|
||||
});
|
||||
|
||||
test('abweichende oder zusätzliche Rechte werden gemeldet', () => {
|
||||
const wrong = compareTable({ ...ok(), $permissions: ['read("any")'] }, DESIRED.table);
|
||||
assert.equal(wrong.length, 1);
|
||||
assert.match(wrong[0], /^\$permissions ist \[read\("any"\)\], erwartet \[create\("users"\)\]$/);
|
||||
const extra = compareTable({ ...ok(), $permissions: ['create("users")', 'read("any")'] }, DESIRED.table);
|
||||
assert.equal(extra.length, 1);
|
||||
assert.match(extra[0], /^\$permissions /);
|
||||
});
|
||||
|
||||
test('rowSecurity aus wird gemeldet', () => {
|
||||
assert.deepEqual(compareTable({ ...ok(), rowSecurity: false }, DESIRED.table), [
|
||||
'rowSecurity ist false, erwartet true',
|
||||
]);
|
||||
});
|
||||
|
||||
test('mehrere Abweichungen ergeben mehrere Meldungen', () => {
|
||||
assert.equal(compareTable({ ...ok(), rowSecurity: false, $permissions: [] }, DESIRED.table).length, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareColumn', () => {
|
||||
const desired = DESIRED.table.columns[0]; // name, 80, required
|
||||
const ok = () => ({ key: 'name', type: 'string', size: 80, required: true, status: 'available' });
|
||||
|
||||
test('passende Spalte: keine Abweichung', () => {
|
||||
assert.deepEqual(compareColumn(ok(), desired), []);
|
||||
});
|
||||
|
||||
test('size, required und status werden gemeldet', () => {
|
||||
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(), status: 'processing' }, desired), ['status ist processing, erwartet available']);
|
||||
});
|
||||
|
||||
test('fehlende Felder (z. B. falscher Spaltentyp ohne size) werden gemeldet', () => {
|
||||
const [msg] = compareColumn({ key: 'name', type: 'integer', required: true, status: 'available' }, desired);
|
||||
assert.equal(msg, 'size ist undefined, erwartet 80');
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareBucket', () => {
|
||||
const ok = () => ({
|
||||
$id: 'card-images', name: 'Card Images', $permissions: ['create("users")'],
|
||||
fileSecurity: true, maximumFileSize: 5242880,
|
||||
allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'], compression: 'none', antivirus: false,
|
||||
});
|
||||
|
||||
test('passender Bucket: keine Abweichung', () => {
|
||||
assert.deepEqual(compareBucket(ok(), DESIRED.bucket), []);
|
||||
});
|
||||
|
||||
test('Endungen werden sortiert verglichen', () => {
|
||||
assert.deepEqual(compareBucket({ ...ok(), allowedFileExtensions: ['webp', 'jpeg', 'png', 'jpg'] }, DESIRED.bucket), []);
|
||||
});
|
||||
|
||||
test('fehlende oder zusätzliche Endungen werden gemeldet (sortiert ausgegeben)', () => {
|
||||
assert.deepEqual(compareBucket({ ...ok(), allowedFileExtensions: ['png', 'webp'] }, DESIRED.bucket), [
|
||||
'allowedFileExtensions ist [png, webp], erwartet [jpeg, jpg, png, webp]',
|
||||
]);
|
||||
assert.equal(compareBucket({ ...ok(), allowedFileExtensions: [] }, DESIRED.bucket).length, 1);
|
||||
assert.equal(compareBucket({ ...ok(), allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp', 'gif'] }, DESIRED.bucket).length, 1);
|
||||
});
|
||||
|
||||
test('fileSecurity und maximumFileSize werden gemeldet', () => {
|
||||
assert.deepEqual(compareBucket({ ...ok(), fileSecurity: false }, DESIRED.bucket), ['fileSecurity ist false, erwartet true']);
|
||||
assert.deepEqual(compareBucket({ ...ok(), maximumFileSize: 30000000 }, DESIRED.bucket), [
|
||||
'maximumFileSize ist 30000000, erwartet 5242880',
|
||||
]);
|
||||
});
|
||||
|
||||
test('Rechte werden unabhängig von der Reihenfolge verglichen und Abweichungen gemeldet', () => {
|
||||
const desired = { ...DESIRED.bucket, permissions: ['create("users")', 'read("any")'] };
|
||||
assert.deepEqual(compareBucket({ ...ok(), $permissions: ['read("any")', 'create("users")'] }, desired), []);
|
||||
assert.equal(compareBucket({ ...ok(), $permissions: ['read("any")'] }, DESIRED.bucket).length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ablauf gegen den Fake-Server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('main gegen Fake-Server', () => {
|
||||
const EXPECTED_OBJECTS = [
|
||||
'Datenbank aircard',
|
||||
'Tabelle cards',
|
||||
'Spalte cards.name',
|
||||
'Spalte cards.config',
|
||||
'Spalte cards.imageFileId',
|
||||
'Spalte cards.previewFileId',
|
||||
'Bucket card-images',
|
||||
];
|
||||
|
||||
test('--check auf leerem Projekt: Exit 1, alle 7 Objekte FEHLT, nichts angelegt', async () => {
|
||||
await withFake({}, async (fake) => {
|
||||
const { code, lines } = await run(fake, { check: true });
|
||||
assert.equal(code, 1);
|
||||
assert.deepEqual(startsWith(lines, 'FEHLT'), EXPECTED_OBJECTS.map((o) => `FEHLT: ${o}`));
|
||||
assert.equal(startsWith(lines, 'OK').length, 0);
|
||||
assert.equal(fake.posts().length, 0);
|
||||
assert.equal(fake.otherWrites().length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('Anlegen, erneut prüfen, erneut anlegen (idempotent)', async () => {
|
||||
await withFake({}, async (fake) => {
|
||||
// Anlegen
|
||||
const first = await run(fake);
|
||||
assert.equal(first.code, 0);
|
||||
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, 'ABWEICHUNG').length, 0);
|
||||
assert.ok(first.lines.includes('OK: Schema vollständig'));
|
||||
|
||||
// Bodies und Reihenfolge exakt wie im Brief
|
||||
assert.deepEqual(
|
||||
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
|
||||
for (const r of fake.state.requests) {
|
||||
assert.equal(r.headers['content-type'], 'application/json');
|
||||
assert.equal(r.headers['x-appwrite-project'], 'aircard');
|
||||
assert.equal(r.headers['x-appwrite-key'], KEY);
|
||||
}
|
||||
// Der Key taucht nie in der Ausgabe auf
|
||||
assert.ok(!first.lines.join('\n').includes(KEY));
|
||||
|
||||
// Alle Spalten wurden vor dem Ende ausgewartet (Status available)
|
||||
for (const col of fake.state.columns.values()) assert.ok(col.gets >= 2, `Spalte ${col.key} nicht bis available geprüft`);
|
||||
|
||||
// Zweite Prüfung
|
||||
const postsBefore = fake.posts().length;
|
||||
const check = await run(fake, { check: true });
|
||||
assert.equal(check.code, 0);
|
||||
assert.deepEqual(startsWith(check.lines, 'OK: ').filter((l) => l !== 'OK: Schema vollständig'), EXPECTED_OBJECTS.map((o) => `OK: ${o}`));
|
||||
assert.ok(check.lines.includes('OK: Schema vollständig'));
|
||||
|
||||
// Zweites Anlegen legt nichts doppelt an
|
||||
const second = await run(fake);
|
||||
assert.equal(second.code, 0);
|
||||
assert.equal(startsWith(second.lines, 'ANGELEGT').length, 0);
|
||||
assert.ok(second.lines.includes('OK: Schema vollständig'));
|
||||
assert.equal(fake.posts().length, postsBefore);
|
||||
assert.equal(fake.otherWrites().length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('Abweichungen werden gemeldet, aber nicht verändert (Exit 1)', async () => {
|
||||
await withFake({}, async (fake) => {
|
||||
assert.equal((await run(fake)).code, 0);
|
||||
fake.state.tables.get('aircard/cards').rowSecurity = false;
|
||||
fake.state.tables.get('aircard/cards').$permissions = ['read("any")'];
|
||||
fake.state.columns.get('aircard/cards/name').size = 100;
|
||||
fake.state.columns.get('aircard/cards/config').required = false;
|
||||
fake.state.buckets.get('card-images').maximumFileSize = 30000000;
|
||||
fake.state.buckets.get('card-images').allowedFileExtensions = ['png', 'gif'];
|
||||
fake.state.buckets.get('card-images').fileSecurity = false;
|
||||
const requestsBefore = fake.state.requests.length;
|
||||
|
||||
for (const check of [true, false]) {
|
||||
const { code, lines } = await run(fake, { check });
|
||||
assert.equal(code, 1);
|
||||
const dev = startsWith(lines, 'ABWEICHUNG');
|
||||
assert.ok(dev.includes('ABWEICHUNG: cards.rowSecurity ist false, erwartet true'), dev.join('\n'));
|
||||
assert.ok(dev.includes('ABWEICHUNG: cards.name.size ist 100, erwartet 80'), dev.join('\n'));
|
||||
assert.ok(dev.includes('ABWEICHUNG: cards.config.required ist false, erwartet true'), dev.join('\n'));
|
||||
assert.ok(dev.includes('ABWEICHUNG: card-images.maximumFileSize ist 30000000, erwartet 5242880'), dev.join('\n'));
|
||||
assert.ok(dev.includes('ABWEICHUNG: card-images.fileSecurity ist false, erwartet true'), dev.join('\n'));
|
||||
assert.ok(dev.some((l) => l.startsWith('ABWEICHUNG: card-images.allowedFileExtensions ist [gif, png]')), dev.join('\n'));
|
||||
assert.ok(dev.some((l) => l.startsWith('ABWEICHUNG: cards.$permissions ist [read("any")]')), dev.join('\n'));
|
||||
assert.equal(dev.length, 7);
|
||||
assert.ok(!lines.includes('OK: Schema vollständig'));
|
||||
}
|
||||
// Nur lesende Anfragen seit der Manipulation
|
||||
assert.ok(fake.state.requests.slice(requestsBefore).every((r) => r.method === 'GET'));
|
||||
});
|
||||
});
|
||||
|
||||
test('404 project_not_found bricht klar ab und meldet nichts als fehlend', async () => {
|
||||
await withFake({ project: 'anderes-projekt' }, async (fake) => {
|
||||
for (const check of [true, false]) {
|
||||
const { code, lines } = await run(fake, { check });
|
||||
assert.equal(code, 2);
|
||||
assert.equal(startsWith(lines, 'FEHLT').length, 0);
|
||||
assert.equal(startsWith(lines, 'ANGELEGT').length, 0);
|
||||
const abort = startsWith(lines, 'ABBRUCH');
|
||||
assert.equal(abort.length, 1);
|
||||
assert.match(abort[0], /Projekt/);
|
||||
assert.match(abort[0], /aircard/);
|
||||
assert.match(abort[0], /docs\/appwrite-console\.md/);
|
||||
assert.ok(!lines.join('\n').includes(KEY));
|
||||
}
|
||||
assert.equal(fake.posts().length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('401 (falscher Key) bricht ab, ohne den Key auszugeben', async () => {
|
||||
await withFake({ key: 'ein-ganz-anderer-key' }, 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], /401/);
|
||||
assert.match(abort[0], /API-Key/);
|
||||
const all = lines.join('\n');
|
||||
assert.ok(!all.includes(KEY));
|
||||
assert.ok(!all.includes('ein-ganz-anderer-key'));
|
||||
assert.equal(fake.posts().length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('Steht der Key im Servertext, wird er in der Meldung geschwärzt', async () => {
|
||||
const inject = (method, path) => (method === 'GET' && path === '/v1/tablesdb/aircard'
|
||||
? { status: 401, type: 'user_unauthorized', message: `Ungültiger Key ${KEY} abgelehnt` }
|
||||
: null);
|
||||
await withFake({ inject }, async (fake) => {
|
||||
const { code, lines } = await run(fake);
|
||||
assert.equal(code, 2);
|
||||
const all = lines.join('\n');
|
||||
assert.ok(!all.includes(KEY));
|
||||
assert.match(all, /Ungültiger Key \*\*\* abgelehnt/);
|
||||
});
|
||||
});
|
||||
|
||||
test('5xx bricht ab', async () => {
|
||||
const inject = (method, path) => (method === 'GET' && path === '/v1/tablesdb/aircard'
|
||||
? { status: 503, type: 'general_service_unavailable', message: 'Service temporarily unavailable' }
|
||||
: 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], /503/);
|
||||
assert.equal(startsWith(lines, 'FEHLT').length, 0);
|
||||
assert.equal(fake.posts().length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('Spalte wird nicht verfügbar: Abbruch nach Wartezeit', async () => {
|
||||
await withFake({ stuckColumns: 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], /cards\.name/);
|
||||
assert.match(abort[0], /nicht .*verfügbar/);
|
||||
// nach dem Abbruch wurde kein Bucket mehr angelegt
|
||||
assert.equal(fake.state.buckets.size, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('Server nicht erreichbar: Abbruch mit klarer Meldung', async () => {
|
||||
const probe = http.createServer();
|
||||
await new Promise((r) => probe.listen(0, '127.0.0.1', r));
|
||||
const { port } = probe.address();
|
||||
await new Promise((r) => probe.close(r));
|
||||
const lines = [];
|
||||
const code = await main({
|
||||
endpoint: `http://127.0.0.1:${port}/v1`, key: KEY, project: 'aircard', check: true,
|
||||
log: (l) => lines.push(l), pollMs: 1, timeoutMs: 100,
|
||||
});
|
||||
assert.equal(code, 2);
|
||||
assert.match(startsWith(lines, 'ABBRUCH')[0], /nicht erreichbar/);
|
||||
assert.ok(!lines.join('\n').includes(KEY));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Aufruf als Kommandozeile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('CLI', () => {
|
||||
test('ohne AIRCARD_APPWRITE_API_KEY: deutsche Fehlermeldung, Exit 2', async () => {
|
||||
const { code, stdout, stderr } = await runCli([], {});
|
||||
assert.equal(code, 2);
|
||||
assert.equal(stdout, '');
|
||||
assert.match(stderr, /AIRCARD_APPWRITE_API_KEY/);
|
||||
assert.match(stderr, /fehlt/);
|
||||
});
|
||||
|
||||
test('unbekannte Option: Exit 2', async () => {
|
||||
const { code, stderr } = await runCli(['--foo'], { AIRCARD_APPWRITE_API_KEY: KEY });
|
||||
assert.equal(code, 2);
|
||||
assert.match(stderr, /Unbekannte Option/);
|
||||
assert.ok(!stderr.includes(KEY));
|
||||
});
|
||||
|
||||
test('--check gegen Fake-Server über Umgebungsvariablen: Exit 1, Key nicht in der Ausgabe', async () => {
|
||||
await withFake({}, async (fake) => {
|
||||
const { code, stdout, stderr } = await runCli(['--check'], {
|
||||
AIRCARD_APPWRITE_API_KEY: KEY,
|
||||
AIRCARD_APPWRITE_ENDPOINT: fake.endpoint,
|
||||
});
|
||||
assert.equal(code, 1);
|
||||
assert.match(stdout, /FEHLT: Datenbank aircard/);
|
||||
assert.match(stdout, /FEHLT: Bucket card-images/);
|
||||
assert.ok(!(stdout + stderr).includes(KEY));
|
||||
assert.equal(fake.posts().length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('Anlegen und danach --check über die Kommandozeile: Exit 0', async () => {
|
||||
await withFake({}, async (fake) => {
|
||||
const env = { AIRCARD_APPWRITE_API_KEY: KEY, AIRCARD_APPWRITE_ENDPOINT: fake.endpoint };
|
||||
const apply = await runCli([], env);
|
||||
assert.equal(apply.code, 0, apply.stdout + apply.stderr);
|
||||
const check = await runCli(['--check'], env);
|
||||
assert.equal(check.code, 0, check.stdout + check.stderr);
|
||||
assert.match(check.stdout, /OK: Schema vollständig/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,5 +3,7 @@ import { defineConfig } from 'vitest/config';
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
// scripts/*.test.mjs laufen mit `node --test` (siehe web/scripts/appwrite-setup.test.mjs).
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user