- Schema: Tabelle `profiles` (Zeilen-ID = Konto-ID, `username` und `usernameKey`
je 20 Zeichen, Unique-Index `username_key` auf `usernameKey`, rowSecurity an,
keine Tabellenrechte) im Setup-Skript.
- Neues Modul `deploy/api/profiles.js`: `POST /api/profil` legt das Profil an
oder ändert es (Zeilenrecht genau read("any"), Sperre je Konto). Muster,
Sperrliste und Schlüssel (`usernameKey`) stehen in `common.js`; vergeben ->
409 `usernameTaken`, auch wenn erst der Unique-Index bei einem Rennen
entscheidet. Dazu die MSG-Texte `usernameInvalid`, `usernameReserved`,
`usernameTaken`, `usernameRequired`.
- `POST /api/logos/hochladen` und `POST /api/veroeffentlichen` antworten 403
`usernameRequired`, wenn das Konto kein Profil hat (`hasProfile`, nach der
Anmeldung und vor allen anderen Prüfungen).
- Fake-Appwrite: Tabelle `profiles` mit Unique-Index (409), `addProfile`,
`holdNext`. API-Dienst besteht jetzt aus sechs Dateien (READMEs).
- Client: die vier API-Texte in `API_MESSAGES` und `en.ts` (Gleichlauf mit MSG).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1424 lines
69 KiB
JavaScript
1424 lines
69 KiB
JavaScript
// 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, profiles; 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', 'profiles']);
|
||
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('profiles entspricht Benutzernamen-Spec §3 (Unique-Index username_key auf usernameKey)', () => {
|
||
const t = table('profiles');
|
||
assert.equal(t.name, 'Profiles');
|
||
assert.deepEqual(t.permissions, []);
|
||
assert.equal(t.rowSecurity, true);
|
||
assert.deepEqual(t.columns, [str('username', 20, true), str('usernameKey', 20, true)]);
|
||
// unique: nur so kann bei Gleichzeitigkeit kein Name doppelt vergeben werden
|
||
assert.deepEqual(t.indexes, [{ key: 'username_key', type: 'unique', columns: ['usernameKey'] }]);
|
||
});
|
||
|
||
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('compareIndex (unique)', () => {
|
||
const usernameKey = DESIRED.tables.find((t) => t.id === 'profiles').indexes[0];
|
||
const ok = () => ({ key: 'username_key', type: 'unique', columns: ['usernameKey'], lengths: [], orders: [], status: 'available' });
|
||
|
||
test('Unique-Index profiles.username_key: Typ, Spalte und Status werden verglichen', () => {
|
||
assert.deepEqual(compareIndex(ok(), usernameKey), []);
|
||
// ein normaler Index verhindert keine doppelten Namen: das muss auffallen
|
||
assert.deepEqual(compareIndex({ ...ok(), type: 'key' }, usernameKey), ['type ist key, erwartet unique']);
|
||
assert.deepEqual(compareIndex({ ...ok(), columns: ['username'] }, usernameKey), ['columns ist [username], erwartet [usernameKey]']);
|
||
assert.deepEqual(compareIndex({ ...ok(), status: 'processing' }, usernameKey), ['status ist processing, erwartet available']);
|
||
});
|
||
});
|
||
|
||
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',
|
||
'Tabelle profiles',
|
||
'Spalte profiles.username',
|
||
'Spalte profiles.usernameKey',
|
||
'Index profiles.username_key',
|
||
'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));
|
||
// Erweiterung um die Benutzernamen (profiles)
|
||
const PROFILE_OBJECTS = EXPECTED_OBJECTS.filter((o) => /^(Tabelle|Spalte|Index) profiles(\.|$)/.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'] }],
|
||
[T, { tableId: 'profiles', name: 'Profiles', permissions: [], rowSecurity: true }],
|
||
str('profiles', 'username', 20, true),
|
||
str('profiles', 'usernameKey', 20, true),
|
||
// unique: das Skript legt den Index mit dem Typ `unique` an (Appwrite meldet dann 409 statt eine zweite Zeile mit demselben Schlüssel anzulegen)
|
||
[`${T}/profiles/indexes`, { key: 'username_key', type: 'unique', columns: ['usernameKey'] }],
|
||
['/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 ?? '');
|
||
const isProfilePost = ([path, body]) => /\/tables\/profiles(\/|$)/.test(path) || body.tableId === 'profiles';
|
||
|
||
test('--check auf leerem Projekt: Exit 1, alle 55 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, 55);
|
||
assert.deepEqual(startsWith(lines, 'FEHLT'), EXPECTED_OBJECTS.map((o) => `FEHLT: ${o}`));
|
||
assert.equal(startsWith(lines, 'OK').length, 0);
|
||
assert.ok(lines.includes('UNVOLLSTÄNDIG: 55 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, 33);
|
||
assert.equal(fake.state.indexes.size, 11);
|
||
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',
|
||
'profiles.username_key',
|
||
]);
|
||
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, 32);
|
||
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, 33);
|
||
assert.equal(fake.state.indexes.size, 11);
|
||
|
||
// danach ist das Schema vollständig
|
||
const again = await run(fake, { check: true });
|
||
assert.equal(again.code, 0, again.lines.join('\n'));
|
||
});
|
||
});
|
||
|
||
test('Bestehendes Schema ohne `profiles` wird um die Tabelle samt Unique-Index ergänzt, alles Bestehende bleibt unberührt', async () => {
|
||
await withFake({}, async (fake) => {
|
||
assert.equal((await run(fake)).code, 0);
|
||
// Stand vor den Benutzernamen nachstellen: alles außer profiles
|
||
dropFromFake(fake, { tables: ['profiles'] });
|
||
const kept = EXPECTED_OBJECTS.filter((o) => !PROFILE_OBJECTS.includes(o));
|
||
assert.equal(PROFILE_OBJECTS.length, 4);
|
||
assert.equal(kept.length, 51);
|
||
const requestsBefore = fake.state.requests.length;
|
||
const postsBefore = fake.posts().length;
|
||
|
||
// --check: genau die vier neuen Objekte fehlen, nichts wird angelegt
|
||
const check = await run(fake, { check: true });
|
||
assert.equal(check.code, 1);
|
||
assert.deepEqual(startsWith(check.lines, 'FEHLT'), PROFILE_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: 4 fehlend.'), check.lines.join('\n'));
|
||
assert.equal(fake.posts().length, postsBefore);
|
||
|
||
// Anlegen: Tabelle, beide Spalten, dann der Unique-Index (erst wenn die Spalten available sind)
|
||
const apply = await run(fake);
|
||
assert.equal(apply.code, 0, apply.lines.join('\n'));
|
||
assert.deepEqual(startsWith(apply.lines, 'ANGELEGT'), PROFILE_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(isProfilePost));
|
||
assert.deepEqual(newPosts.at(-1).body, { key: 'username_key', type: 'unique', columns: ['usernameKey'] });
|
||
assert.equal(fake.state.indexes.get('aircard/profiles/username_key').type, 'unique');
|
||
assert.ok(fake.state.indexPosts.every((p) => p.allColumnsAvailable));
|
||
assert.equal(fake.otherWrites().length, 0);
|
||
|
||
assert.equal((await run(fake, { check: true })).code, 0);
|
||
});
|
||
});
|
||
|
||
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('Abweichende Benutzernamen-Tabelle (Rechte, Länge, Pflicht, Index nur `key` statt `unique`) wird gemeldet, aber nicht verändert (Exit 1)', async () => {
|
||
await withFake({}, async (fake) => {
|
||
assert.equal((await run(fake)).code, 0);
|
||
fake.state.tables.get('aircard/profiles').$permissions = ['read("any")'];
|
||
fake.state.tables.get('aircard/profiles').rowSecurity = false;
|
||
fake.state.columns.get('aircard/profiles/username').size = 30;
|
||
fake.state.columns.get('aircard/profiles/usernameKey').required = false;
|
||
fake.state.indexes.get('aircard/profiles/username_key').type = 'key';
|
||
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: profiles.$permissions ist [read("any")], erwartet []',
|
||
'ABWEICHUNG: profiles.rowSecurity ist false, erwartet true',
|
||
'ABWEICHUNG: profiles.username.size ist 30, erwartet 20',
|
||
'ABWEICHUNG: profiles.usernameKey.required ist false, erwartet true',
|
||
'ABWEICHUNG: profiles.username_key.type ist key, erwartet unique',
|
||
]);
|
||
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: Tabelle profiles/);
|
||
assert.match(stdout, /FEHLT: Index profiles\.username_key/);
|
||
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/);
|
||
});
|
||
});
|
||
});
|