// 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)); /** Stellt einen älteren Schema-Stand nach: entfernt die Tabellen (samt Spalten und Indizes) und Buckets aus dem Fake. */ function dropFromFake(fake, { tables = [], buckets = [] }) { for (const map of [fake.state.tables, fake.state.columns, fake.state.indexes]) { for (const id of [...map.keys()]) { if (tables.some((t) => id === `aircard/${t}` || id.startsWith(`aircard/${t}/`))) map.delete(id); } } for (const id of buckets) fake.state.buckets.delete(id); } 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, logos, logo_likes, logo_reports; Buckets card-images, gallery-images, logo-images', () => { assert.deepEqual(DESIRED.database, { id: 'aircard', name: 'AirCard' }); assert.deepEqual(DESIRED.tables.map((t) => t.id), ['cards', 'gallery', 'reports', 'logos', 'logo_likes', 'logo_reports']); assert.deepEqual(DESIRED.buckets.map((b) => b.id), ['card-images', 'gallery-images', 'logo-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('logos entspricht Logo-Spec §8', () => { const t = table('logos'); assert.equal(t.name, 'Logos'); assert.deepEqual(t.permissions, []); assert.equal(t.rowSecurity, true); assert.deepEqual(t.columns, [ str('name', 40, true), str('displayName', 30, false), str('fileId', 36, true), str('ownerId', 36, true), { key: 'mono', type: 'boolean', required: false, default: false }, { key: 'likeCount', type: 'integer', required: false, default: 0, min: 0 }, { key: 'hidden', type: 'boolean', required: false, default: false }, { key: 'reportCount', type: 'integer', required: false, default: 0, min: 0 }, ]); assert.deepEqual(t.indexes, [ { key: 'owner', type: 'key', columns: ['ownerId'] }, { key: 'hidden', type: 'key', columns: ['hidden'] }, { key: 'likes', type: 'key', columns: ['likeCount'] }, ]); }); test('logo_likes entspricht Logo-Spec §8 (Mehrspalten-Index user_logo: userId, logoId)', () => { const t = table('logo_likes'); assert.equal(t.name, 'Logo Likes'); assert.deepEqual(t.permissions, []); assert.equal(t.rowSecurity, true); assert.deepEqual(t.columns, [str('logoId', 36, true), str('userId', 36, true)]); assert.deepEqual(t.indexes, [ { key: 'logo', type: 'key', columns: ['logoId'] }, { key: 'user_logo', type: 'key', columns: ['userId', 'logoId'] }, ]); }); test('logo_reports entspricht Logo-Spec §8', () => { const t = table('logo_reports'); assert.equal(t.name, 'Logo Reports'); assert.deepEqual(t.permissions, ['read("label:admin")']); assert.equal(t.rowSecurity, false); assert.deepEqual(t.columns, [ str('logoId', 36, true), { key: 'reason', type: 'enum', elements: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true }, str('reporterHash', 64, true), ]); assert.deepEqual(t.indexes, [{ key: 'logo', type: 'key', columns: ['logoId'] }]); }); test('logo-images entspricht Logo-Spec §8', () => { assert.deepEqual(bucket('logo-images'), { id: 'logo-images', name: 'Logo Images', permissions: ['create("users")'], fileSecurity: true, maximumFileSize: 1048576, allowedFileExtensions: ['png'], 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]']); }); test('Mehrspalten-Index (logo_likes.user_logo): die Reihenfolge der Spalten zählt', () => { const userLogo = DESIRED.tables.find((t) => t.id === 'logo_likes').indexes.find((i) => i.key === 'user_logo'); const okMulti = { key: 'user_logo', type: 'key', columns: ['userId', 'logoId'], lengths: [], orders: [], status: 'available' }; assert.deepEqual(compareIndex(okMulti, userLogo), []); assert.deepEqual(compareIndex({ ...okMulti, columns: ['logoId', 'userId'] }, userLogo), [ 'columns ist [logoId, userId], erwartet [userId, logoId]', ]); assert.deepEqual(compareIndex({ ...okMulti, columns: ['userId'] }, userLogo), [ 'columns ist [userId], erwartet [userId, logoId]', ]); }); }); 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]', ]); }); test('logo-images: nur png, höchstens 1 MiB', () => { const logo = DESIRED.buckets[2]; const okLogo = { $id: 'logo-images', name: 'Logo Images', $permissions: ['create("users")'], fileSecurity: true, maximumFileSize: 1048576, allowedFileExtensions: ['png'], compression: 'none', antivirus: false, }; assert.deepEqual(compareBucket(okLogo, logo), []); assert.deepEqual(compareBucket({ ...okLogo, allowedFileExtensions: ['png', 'webp'] }, logo), [ 'allowedFileExtensions ist [png, webp], erwartet [png]', ]); assert.deepEqual(compareBucket({ ...okLogo, maximumFileSize: 5242880 }, logo), [ 'maximumFileSize ist 5242880, erwartet 1048576', ]); }); }); // --------------------------------------------------------------------------- // 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', 'Tabelle logos', 'Spalte logos.name', 'Spalte logos.displayName', 'Spalte logos.fileId', 'Spalte logos.ownerId', 'Spalte logos.mono', 'Spalte logos.likeCount', 'Spalte logos.hidden', 'Spalte logos.reportCount', 'Index logos.owner', 'Index logos.hidden', 'Index logos.likes', 'Tabelle logo_likes', 'Spalte logo_likes.logoId', 'Spalte logo_likes.userId', 'Index logo_likes.logo', 'Index logo_likes.user_logo', 'Tabelle logo_reports', 'Spalte logo_reports.logoId', 'Spalte logo_reports.reason', 'Spalte logo_reports.reporterHash', 'Index logo_reports.logo', 'Bucket card-images', 'Bucket gallery-images', 'Bucket logo-images', ]; // Erweiterung um die Galerie (gallery, reports, gallery-images) bzw. um die Logos (logos, logo_likes, logo_reports, logo-images) const GALLERY_OBJECTS = EXPECTED_OBJECTS.filter((o) => /^(Tabelle|Spalte|Index) (gallery|reports)(\.|$)|^Bucket gallery-images$/.test(o)); const LOGO_OBJECTS = EXPECTED_OBJECTS.filter((o) => /^(Tabelle|Spalte|Index) logo(s|_likes|_reports)(\.|$)|^Bucket logo-images$/.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'] }], [T, { tableId: 'logos', name: 'Logos', permissions: [], rowSecurity: true }], str('logos', 'name', 40, true), str('logos', 'displayName', 30, false), str('logos', 'fileId', 36, true), str('logos', 'ownerId', 36, true), [`${T}/logos/columns/boolean`, { key: 'mono', required: false, default: false }], [`${T}/logos/columns/integer`, { key: 'likeCount', required: false, default: 0, min: 0 }], [`${T}/logos/columns/boolean`, { key: 'hidden', required: false, default: false }], [`${T}/logos/columns/integer`, { key: 'reportCount', required: false, default: 0, min: 0 }], [`${T}/logos/indexes`, { key: 'owner', type: 'key', columns: ['ownerId'] }], [`${T}/logos/indexes`, { key: 'hidden', type: 'key', columns: ['hidden'] }], [`${T}/logos/indexes`, { key: 'likes', type: 'key', columns: ['likeCount'] }], [T, { tableId: 'logo_likes', name: 'Logo Likes', permissions: [], rowSecurity: true }], str('logo_likes', 'logoId', 36, true), str('logo_likes', 'userId', 36, true), [`${T}/logo_likes/indexes`, { key: 'logo', type: 'key', columns: ['logoId'] }], // Mehrspalten-Index: `columns` geht als Liste in dieser Reihenfolge an Appwrite [`${T}/logo_likes/indexes`, { key: 'user_logo', type: 'key', columns: ['userId', 'logoId'] }], [T, { tableId: 'logo_reports', name: 'Logo Reports', permissions: ['read("label:admin")'], rowSecurity: false }], str('logo_reports', 'logoId', 36, true), [`${T}/logo_reports/columns/enum`, { key: 'reason', elements: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true, }], str('logo_reports', 'reporterHash', 64, true), [`${T}/logo_reports/indexes`, { key: 'logo', type: 'key', columns: ['logoId'] }], ['/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, }], ['/v1/storage/buckets', { bucketId: 'logo-images', name: 'Logo Images', permissions: ['create("users")'], fileSecurity: true, maximumFileSize: 1048576, allowedFileExtensions: ['png'], compression: 'none', antivirus: false, }], ]; const isLogoPost = ([path, body]) => /\/tables\/logo/.test(path) || /^logo/.test(body.tableId ?? body.bucketId ?? ''); test('--check auf leerem Projekt: Exit 1, alle 51 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, 51); assert.deepEqual(startsWith(lines, 'FEHLT'), EXPECTED_OBJECTS.map((o) => `FEHLT: ${o}`)); assert.equal(startsWith(lines, 'OK').length, 0); assert.ok(lines.includes('UNVOLLSTÄNDIG: 51 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, 31); assert.equal(fake.state.indexes.size, 10); 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', 'logos.owner', 'logos.hidden', 'logos.likes', 'logo_likes.logo', 'logo_likes.user_logo', 'logo_reports.logo', ]); 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 ohne gallery, reports, gallery-images wird ergänzt, Bestehendes nicht angefasst', async () => { await withFake({}, async (fake) => { assert.equal((await run(fake)).code, 0); // Zustand vor der Erweiterung nachstellen dropFromFake(fake, { tables: ['gallery', 'reports'], buckets: ['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'), GALLERY_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'), GALLERY_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('Bestehendes Schema ohne die Logo-Objekte wird um sie ergänzt, cards/gallery/reports und die Buckets bleiben unberührt', async () => { await withFake({}, async (fake) => { assert.equal((await run(fake)).code, 0); // Stand vor den Logos nachstellen: cards, gallery, reports, card-images, gallery-images dropFromFake(fake, { tables: ['logos', 'logo_likes', 'logo_reports'], buckets: ['logo-images'] }); const kept = EXPECTED_OBJECTS.filter((o) => !LOGO_OBJECTS.includes(o)); assert.equal(LOGO_OBJECTS.length, 23); assert.equal(kept.length, 28); const requestsBefore = fake.state.requests.length; const postsBefore = fake.posts().length; // --check: jedes fehlende neue Objekt als FEHLT, sonst nichts auffällig, nichts angelegt const check = await run(fake, { check: true }); assert.equal(check.code, 1); assert.deepEqual(startsWith(check.lines, 'FEHLT'), LOGO_OBJECTS.map((o) => `FEHLT: ${o}`)); assert.equal(startsWith(check.lines, 'ABWEICHUNG').length, 0); assert.deepEqual(startsWith(check.lines, 'OK: ').filter((l) => l !== 'OK: Schema vollständig'), kept.map((o) => `OK: ${o}`)); assert.ok(check.lines.includes('UNVOLLSTÄNDIG: 23 fehlend.'), check.lines.join('\n')); assert.equal(fake.posts().length, postsBefore); // Anlegen: genau die Logo-Objekte, samt Mehrspalten-Index, in der Reihenfolge von DESIRED const apply = await run(fake); assert.equal(apply.code, 0, apply.lines.join('\n')); assert.deepEqual(startsWith(apply.lines, 'ANGELEGT'), LOGO_OBJECTS.map((o) => `ANGELEGT: ${o}`)); assert.deepEqual(startsWith(apply.lines, 'OK: ').filter((l) => l !== 'OK: Schema vollständig'), kept.map((o) => `OK: ${o}`)); const newPosts = fake.state.requests.slice(requestsBefore).filter((r) => r.method === 'POST'); assert.deepEqual(newPosts.map((r) => [r.path, r.body]), EXPECTED_POSTS.filter(isLogoPost)); // Mehrspalten-Index: `columns` geht als Liste in dieser Reihenfolge an Appwrite const userLogo = newPosts.find((r) => r.body.key === 'user_logo'); assert.equal(userLogo.path, `${T}/logo_likes/indexes`); assert.deepEqual(userLogo.body.columns, ['userId', 'logoId']); assert.ok(fake.state.indexPosts.every((p) => p.allColumnsAvailable)); // Alles Bestehende wurde nur gelesen assert.equal(fake.otherWrites().length, 0); assert.equal(fake.state.columns.size, 31); assert.equal(fake.state.indexes.size, 10); // danach ist das Schema vollständig const again = await run(fake, { check: true }); assert.equal(again.code, 0, again.lines.join('\n')); }); }); 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('Abweichende Logo-Objekte 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/logos').$permissions = ['read("any")']; fake.state.tables.get('aircard/logo_reports').rowSecurity = true; fake.state.columns.get('aircard/logos/name').size = 80; fake.state.columns.get('aircard/logos/mono').default = true; fake.state.columns.get('aircard/logos/likeCount').default = 1; fake.state.columns.get('aircard/logo_likes/userId').required = false; fake.state.indexes.get('aircard/logo_likes/user_logo').columns = ['logoId', 'userId']; fake.state.buckets.get('logo-images').maximumFileSize = 5242880; fake.state.buckets.get('logo-images').allowedFileExtensions = ['png', 'webp']; 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: logos.$permissions ist [read("any")], erwartet []', 'ABWEICHUNG: logos.name.size ist 80, erwartet 40', 'ABWEICHUNG: logos.mono.default ist true, erwartet false', 'ABWEICHUNG: logos.likeCount.default ist 1, erwartet 0', 'ABWEICHUNG: logo_likes.userId.required ist false, erwartet true', 'ABWEICHUNG: logo_likes.user_logo.columns ist [logoId, userId], erwartet [userId, logoId]', 'ABWEICHUNG: logo_reports.rowSecurity ist true, erwartet false', 'ABWEICHUNG: logo-images.maximumFileSize ist 5242880, erwartet 1048576', 'ABWEICHUNG: logo-images.allowedFileExtensions ist [png, webp], erwartet [png]', ]); assert.equal(startsWith(lines, 'ANGELEGT').length, 0); assert.ok(!lines.includes('OK: Schema vollständig')); } 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.match(stdout, /FEHLT: Tabelle logos/); assert.match(stdout, /FEHLT: Index logo_likes\.user_logo/); assert.match(stdout, /FEHLT: Tabelle logo_reports/); assert.match(stdout, /FEHLT: Bucket logo-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/); }); }); });