Files
AirCardIOS/web/scripts/appwrite-setup.test.mjs
T
ClaudeandClaude Sonnet 5.5 9e4f4ffacc 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>
2026-10-04 13:50:54 +00:00

1088 lines
51 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
compareIndex,
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)
// ---------------------------------------------------------------------------
const HUGE_MIN = -9223372036854775808;
const HUGE_MAX = 9223372036854775807;
function createFakeAppwrite({
project = '6ac2130a00289343d6d5', key = KEY, stuckColumns = false, stuckIndexes = false, failedIndexes = false, inject = null,
} = {}) {
const state = {
databases: new Map(),
tables: new Map(), // "db/table"
columns: new Map(), // "db/table/key"
indexes: new Map(), // "db/table/key"
buckets: new Map(),
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) => {
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,boolean,integer,enum}
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');
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}`;
if (state.columns.has(id)) return fail(res, 409, 'column_already_exists', 'exists');
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);
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 = col.forceStatus ?? (columnAvailable(col) ? 'available' : 'processing');
const { gets, ...out } = col;
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
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: '6ac2130a00289343d6d5',
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', () => {
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.tables.map((t) => t.id), ['cards', 'gallery', 'reports']);
assert.deepEqual(DESIRED.buckets.map((b) => b.id), ['card-images', 'gallery-images']);
});
test('cards entspricht dem bisherigen Schema (unverändert)', () => {
const t = table('cards');
assert.equal(t.name, 'Cards');
assert.deepEqual(t.permissions, ['create("users")']);
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',
name: 'Card Images',
permissions: ['create("users")'],
fileSecurity: true,
maximumFileSize: 5242880,
allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'],
compression: 'none',
antivirus: false,
});
});
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', () => {
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 CARDS = DESIRED.tables[0];
const ok = () => ({ $id: 'cards', name: 'Cards', $permissions: ['create("users")'], rowSecurity: true });
test('passende Tabelle: keine Abweichung', () => {
assert.deepEqual(compareTable(ok(), CARDS), []);
});
test('Rechte werden unabhängig von der Reihenfolge verglichen', () => {
const desired = { ...CARDS, 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")'] }, CARDS);
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")'] }, CARDS);
assert.equal(extra.length, 1);
assert.match(extra[0], /^\$permissions /);
});
test('rowSecurity aus wird gemeldet', () => {
assert.deepEqual(compareTable({ ...ok(), rowSecurity: false }, CARDS), [
'rowSecurity ist false, erwartet true',
]);
});
test('mehrere Abweichungen ergeben mehrere Meldungen', () => {
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', () => {
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' });
test('passende Spalte: keine Abweichung', () => {
assert.deepEqual(compareColumn(ok(), desired), []);
});
test('string: 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('string: der Standardwert wird nicht verglichen', () => {
assert.deepEqual(compareColumn({ ...ok(), default: 'x' }, desired), []);
});
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', () => {
const CARD_IMAGES = DESIRED.buckets[0];
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(), CARD_IMAGES), []);
});
test('Endungen werden sortiert verglichen', () => {
assert.deepEqual(compareBucket({ ...ok(), allowedFileExtensions: ['webp', 'jpeg', 'png', 'jpg'] }, CARD_IMAGES), []);
});
test('fehlende oder zusätzliche Endungen werden gemeldet (sortiert ausgegeben)', () => {
assert.deepEqual(compareBucket({ ...ok(), allowedFileExtensions: ['png', 'webp'] }, CARD_IMAGES), [
'allowedFileExtensions ist [png, webp], erwartet [jpeg, jpg, png, webp]',
]);
assert.equal(compareBucket({ ...ok(), allowedFileExtensions: [] }, CARD_IMAGES).length, 1);
assert.equal(compareBucket({ ...ok(), allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp', 'gif'] }, CARD_IMAGES).length, 1);
});
test('fileSecurity und maximumFileSize werden gemeldet', () => {
assert.deepEqual(compareBucket({ ...ok(), fileSecurity: false }, CARD_IMAGES), ['fileSecurity ist false, erwartet true']);
assert.deepEqual(compareBucket({ ...ok(), maximumFileSize: 30000000 }, CARD_IMAGES), [
'maximumFileSize ist 30000000, erwartet 5242880',
]);
});
test('Rechte werden unabhängig von der Reihenfolge verglichen und Abweichungen gemeldet', () => {
const desired = { ...CARD_IMAGES, permissions: ['create("users")', 'read("any")'] };
assert.deepEqual(compareBucket({ ...ok(), $permissions: ['read("any")', 'create("users")'] }, desired), []);
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]',
]);
});
});
// ---------------------------------------------------------------------------
// 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',
'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 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 28 Objekte FEHLT, nichts angelegt', async () => {
await withFake({}, async (fake) => {
const { code, lines } = await run(fake, { check: true });
assert.equal(code, 1);
assert.equal(EXPECTED_OBJECTS.length, 28);
assert.deepEqual(startsWith(lines, 'FEHLT'), EXPECTED_OBJECTS.map((o) => `FEHLT: ${o}`));
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.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, first.lines.join('\n'));
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]), EXPECTED_POSTS);
// 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'], '6ac2130a00289343d6d5');
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 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 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
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('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 () => {
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('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 () => {
await withFake({ project: 'anderes-projekt' }, async (fake) => {
for (const check of [true, false]) {
const { code, lines } = await run(fake, { check, project: '6ac2130a00289343d6d5' });
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], /6ac2130a00289343d6d5/);
assert.match(abort[0], /AIRCARD_APPWRITE_PROJECT_ID/);
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('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 () => {
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([], { AIRCARD_APPWRITE_PROJECT_ID: '6ac2130a00289343d6d5' });
assert.equal(code, 2);
assert.equal(stdout, '');
assert.match(stderr, /AIRCARD_APPWRITE_API_KEY/);
assert.match(stderr, /fehlt/);
});
test('ohne AIRCARD_APPWRITE_PROJECT_ID: deutsche Fehlermeldung, Exit 2', async () => {
const { code, stdout, stderr } = await runCli([], { AIRCARD_APPWRITE_API_KEY: KEY });
assert.equal(code, 2);
assert.equal(stdout, '');
assert.match(stderr, /AIRCARD_APPWRITE_PROJECT_ID/);
assert.match(stderr, /fehlt/);
});
test('unbekannte Option: Exit 2', async () => {
const { code, stderr } = await runCli(['--foo'], { AIRCARD_APPWRITE_API_KEY: KEY, AIRCARD_APPWRITE_PROJECT_ID: '6ac2130a00289343d6d5' });
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_PROJECT_ID: '6ac2130a00289343d6d5',
AIRCARD_APPWRITE_ENDPOINT: fake.endpoint,
});
assert.equal(code, 1);
assert.match(stdout, /FEHLT: Datenbank aircard/);
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.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_PROJECT_ID: '6ac2130a00289343d6d5', 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/);
});
});
});