feat(appwrite): Schema für Galerie, Meldungen und Galerie-Bilder

Setup-Skript um Tabellen gallery und reports (Spalten boolean/integer/enum,
Volltext- und Key-Indizes) sowie den Bucket gallery-images erweitert.
DESIRED auf tables/buckets umgebaut, Vergleich je Spaltentyp und Index,
Indizes erst nach available-Spalten. Console-Anleitung um Abschnitt
Community (JWT an, Label admin, Key aircard-api) ergänzt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 13:50:54 +00:00
1 parent 25ff496548
commit 9e4f4ffacc
3 files changed
+803 -127

No files matched your search

+553 -54
View File
@@ -14,6 +14,7 @@ import {
isMissing,
compareTable,
compareColumn,
compareIndex,
compareBucket,
main,
} from './appwrite-setup.mjs';
@@ -25,14 +26,24 @@ const KEY = 'test-key-0123456789abcdef-geheim';
// Fake-Appwrite (1.8-ähnliche REST-Formen, alles im Speicher)
// ---------------------------------------------------------------------------
function createFakeAppwrite({ project = '6ac2130a00289343d6d5', key = KEY, stuckColumns = false, inject = null } = {}) {
const HUGE_MIN = -9223372036854775808;
const HUGE_MAX = 9223372036854775807;
function createFakeAppwrite({
project = '6ac2130a00289343d6d5', key = KEY, stuckColumns = false, stuckIndexes = false, failedIndexes = false, inject = null,
} = {}) {
const state = {
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' });
@@ -90,12 +101,33 @@ function createFakeAppwrite({ project = '6ac2130a00289343d6d5', key = KEY, stuck
const table = state.tables.get(`${seg[1]}/${seg[3]}`);
return table ? send(res, 200, table) : fail(res, 404, 'table_not_found', 'Table not found');
}
// POST /tablesdb/:db/tables/:t/columns/string
if (m === 'POST' && seg.length === 6 && seg[2] === 'tables' && seg[4] === 'columns' && seg[5] === 'string') {
// 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 col = { key: body.key, type: 'string', size: body.size, required: body.required, array: false, status: 'processing', gets: 0 };
const base = { key: body.key, required: body.required, array: false, status: 'processing', gets: 0 };
let col;
if (kind === 'string') {
if (!Number.isInteger(body.size)) return invalid(res, 'size fehlt');
col = { ...base, type: 'string', size: body.size, default: body.default ?? null };
} else if (kind === 'boolean') {
col = { ...base, type: 'boolean', default: body.default ?? null };
} else if (kind === 'integer') {
// echte Antworten tragen riesige Standard-min/max – das Skript darf sie nicht vergleichen
col = { ...base, type: 'integer', min: HUGE_MIN, max: HUGE_MAX, default: body.default ?? null };
} else {
if (!Array.isArray(body.elements) || !body.elements.length) return invalid(res, 'elements fehlt');
col = {
...base, type: 'string', format: 'enum', elements: body.elements,
size: Math.max(...body.elements.map((e) => e.length)), default: body.default ?? null,
};
}
state.columns.set(id, col);
const { gets, ...out } = col;
return send(res, 202, out);
@@ -106,10 +138,44 @@ function createFakeAppwrite({ project = '6ac2130a00289343d6d5', key = KEY, stuck
const col = state.columns.get(`${seg[1]}/${seg[3]}/${seg[5]}`);
if (!col) return fail(res, 404, 'column_not_found', 'Column not found');
col.gets += 1;
const status = stuckColumns ? 'processing' : col.gets >= 2 ? 'available' : 'processing';
const 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');
@@ -190,17 +256,24 @@ function runCli(args, env) {
// ---------------------------------------------------------------------------
describe('DESIRED', () => {
test('entspricht dem Schema aus der Spec', () => {
const table = (id) => DESIRED.tables.find((t) => t.id === id);
const bucket = (id) => DESIRED.buckets.find((b) => b.id === id);
const str = (key, size, required) => ({ key, type: 'string', size, required });
test('Reihenfolge: cards zuerst, dann gallery, reports; Buckets card-images, gallery-images', () => {
assert.deepEqual(DESIRED.database, { id: 'aircard', name: 'AirCard' });
assert.equal(DESIRED.table.id, 'cards');
assert.equal(DESIRED.table.name, 'Cards');
assert.deepEqual(DESIRED.table.permissions, ['create("users")']);
assert.equal(DESIRED.table.rowSecurity, true);
assert.deepEqual(
DESIRED.table.columns.map((c) => [c.key, c.size, c.required]),
[['name', 80, true], ['config', 2000, true], ['imageFileId', 36, true], ['previewFileId', 36, true]],
);
assert.deepEqual(DESIRED.bucket, {
assert.deepEqual(DESIRED.tables.map((t) => t.id), ['cards', 'gallery', 'reports']);
assert.deepEqual(DESIRED.buckets.map((b) => b.id), ['card-images', 'gallery-images']);
});
test('cards entspricht dem bisherigen Schema (unverändert)', () => {
const t = table('cards');
assert.equal(t.name, 'Cards');
assert.deepEqual(t.permissions, ['create("users")']);
assert.equal(t.rowSecurity, true);
assert.deepEqual(t.columns, [str('name', 80, true), str('config', 2000, true), str('imageFileId', 36, true), str('previewFileId', 36, true)]);
assert.deepEqual(t.indexes ?? [], []);
assert.deepEqual(bucket('card-images'), {
id: 'card-images',
name: 'Card Images',
permissions: ['create("users")'],
@@ -212,6 +285,67 @@ describe('DESIRED', () => {
});
});
test('gallery entspricht Spec §7', () => {
const t = table('gallery');
assert.equal(t.name, 'Gallery');
assert.deepEqual(t.permissions, []);
assert.equal(t.rowSecurity, true);
assert.deepEqual(t.columns, [
str('title', 60, true),
str('issuer', 40, false),
str('tags', 120, false),
str('displayName', 30, false),
str('imageFileId', 36, true),
str('previewFileId', 36, true),
str('sourceCardId', 36, false),
str('ownerId', 36, true),
{ key: 'hidden', type: 'boolean', required: false, default: false },
{ key: 'reportCount', type: 'integer', required: false, default: 0, min: 0 },
str('searchText', 300, false),
]);
assert.deepEqual(t.indexes, [
{ key: 'search_text', type: 'fulltext', columns: ['searchText'] },
{ key: 'owner', type: 'key', columns: ['ownerId'] },
{ key: 'hidden', type: 'key', columns: ['hidden'] },
]);
});
test('reports entspricht Spec §7', () => {
const t = table('reports');
assert.equal(t.name, 'Reports');
assert.deepEqual(t.permissions, ['read("label:admin")']);
assert.equal(t.rowSecurity, false);
assert.deepEqual(t.columns, [
str('galleryId', 36, true),
{ key: 'reason', type: 'enum', elements: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true },
str('reporterHash', 64, true),
]);
assert.deepEqual(t.indexes, [{ key: 'gallery', type: 'key', columns: ['galleryId'] }]);
});
test('gallery-images entspricht Spec §7', () => {
assert.deepEqual(bucket('gallery-images'), {
id: 'gallery-images',
name: 'Gallery Images',
permissions: ['create("users")'],
fileSecurity: true,
maximumFileSize: 5242880,
allowedFileExtensions: ['png', 'webp'],
compression: 'none',
antivirus: false,
});
});
test('Fulltext-Indizes decken genau eine Spalte ab, alle Index-Spalten existieren', () => {
for (const t of DESIRED.tables) {
const keys = t.columns.map((c) => c.key);
for (const idx of t.indexes ?? []) {
if (idx.type === 'fulltext') assert.equal(idx.columns.length, 1, `${t.id}.${idx.key}`);
for (const col of idx.columns) assert.ok(keys.includes(col), `${t.id}.${idx.key}: Spalte ${col} fehlt`);
}
}
});
test('Standard-Endpoint ist der Server von WEBklar', () => {
assert.equal(DEFAULT_ENDPOINT, 'https://appwrite.webklar.com/v1');
});
@@ -239,59 +373,175 @@ describe('isMissing', () => {
});
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(), DESIRED.table), []);
assert.deepEqual(compareTable(ok(), CARDS), []);
});
test('Rechte werden unabhängig von der Reihenfolge verglichen', () => {
const desired = { ...DESIRED.table, permissions: ['create("users")', 'read("any")'] };
const desired = { ...CARDS, permissions: ['create("users")', 'read("any")'] };
const actual = { ...ok(), $permissions: ['read("any")', 'create("users")'] };
assert.deepEqual(compareTable(actual, desired), []);
});
test('abweichende oder zusätzliche Rechte werden gemeldet', () => {
const wrong = compareTable({ ...ok(), $permissions: ['read("any")'] }, DESIRED.table);
const wrong = compareTable({ ...ok(), $permissions: ['read("any")'] }, CARDS);
assert.equal(wrong.length, 1);
assert.match(wrong[0], /^\$permissions ist \[read\("any"\)\], erwartet \[create\("users"\)\]$/);
const extra = compareTable({ ...ok(), $permissions: ['create("users")', 'read("any")'] }, DESIRED.table);
const extra = compareTable({ ...ok(), $permissions: ['create("users")', 'read("any")'] }, CARDS);
assert.equal(extra.length, 1);
assert.match(extra[0], /^\$permissions /);
});
test('rowSecurity aus wird gemeldet', () => {
assert.deepEqual(compareTable({ ...ok(), rowSecurity: false }, DESIRED.table), [
assert.deepEqual(compareTable({ ...ok(), rowSecurity: false }, CARDS), [
'rowSecurity ist false, erwartet true',
]);
});
test('mehrere Abweichungen ergeben mehrere Meldungen', () => {
assert.equal(compareTable({ ...ok(), rowSecurity: false, $permissions: [] }, DESIRED.table).length, 2);
assert.equal(compareTable({ ...ok(), rowSecurity: false, $permissions: [] }, CARDS).length, 2);
});
test('gallery (rowSecurity an, keine Tabellenrechte) und reports (Tabellenrecht für Admins)', () => {
const [, gallery, reports] = DESIRED.tables;
assert.deepEqual(compareTable({ $permissions: [], rowSecurity: true }, gallery), []);
assert.deepEqual(compareTable({ $permissions: ['read("any")'], rowSecurity: true }, gallery), [
'$permissions ist [read("any")], erwartet []',
]);
assert.deepEqual(compareTable({ $permissions: ['read("label:admin")'], rowSecurity: false }, reports), []);
assert.deepEqual(compareTable({ $permissions: ['read("label:admin")'], rowSecurity: true }, reports), [
'rowSecurity ist true, erwartet false',
]);
});
});
describe('compareColumn', () => {
const desired = DESIRED.table.columns[0]; // name, 80, required
const col = (id, key) => DESIRED.tables.find((t) => t.id === id).columns.find((c) => c.key === key);
const desired = col('cards', 'name'); // string 80, Pflicht
const ok = () => ({ key: 'name', type: 'string', size: 80, required: true, status: 'available' });
test('passende Spalte: keine Abweichung', () => {
assert.deepEqual(compareColumn(ok(), desired), []);
});
test('size, required und status werden gemeldet', () => {
test('string: size, required und status werden gemeldet', () => {
assert.deepEqual(compareColumn({ ...ok(), size: 100 }, desired), ['size ist 100, erwartet 80']);
assert.deepEqual(compareColumn({ ...ok(), required: false }, desired), ['required ist false, erwartet true']);
assert.deepEqual(compareColumn({ ...ok(), status: 'processing' }, desired), ['status ist processing, erwartet available']);
});
test('fehlende Felder (z. B. falscher Spaltentyp ohne size) werden gemeldet', () => {
const [msg] = compareColumn({ key: 'name', type: 'integer', required: true, status: 'available' }, desired);
assert.equal(msg, 'size ist undefined, erwartet 80');
test('string: der Standardwert wird nicht verglichen', () => {
assert.deepEqual(compareColumn({ ...ok(), default: 'x' }, desired), []);
});
test('falscher Spaltentyp wird gemeldet (zusätzlich fehlende Felder)', () => {
assert.deepEqual(compareColumn({ key: 'name', type: 'integer', required: true, status: 'available' }, desired), [
'type ist integer, erwartet string',
'size ist undefined, erwartet 80',
]);
});
describe('boolean', () => {
const hidden = col('gallery', 'hidden');
const okBool = () => ({ key: 'hidden', type: 'boolean', required: false, default: false, array: false, status: 'available' });
test('passende Spalte: keine Abweichung', () => {
assert.deepEqual(compareColumn(okBool(), hidden), []);
});
test('Standardwert, required, Typ und status werden gemeldet', () => {
assert.deepEqual(compareColumn({ ...okBool(), default: true }, hidden), ['default ist true, erwartet false']);
assert.deepEqual(compareColumn({ ...okBool(), default: null }, hidden), ['default ist null, erwartet false']);
assert.deepEqual(compareColumn({ ...okBool(), required: true }, hidden), ['required ist true, erwartet false']);
assert.deepEqual(compareColumn({ ...okBool(), type: 'string' }, hidden), ['type ist string, erwartet boolean']);
assert.deepEqual(compareColumn({ ...okBool(), status: 'failed' }, hidden), ['status ist failed, erwartet available']);
});
});
describe('integer', () => {
const reportCount = col('gallery', 'reportCount');
// so liefert Appwrite Integer-Spalten: riesige Standard-min/max
const okInt = () => ({
key: 'reportCount', type: 'integer', required: false, default: 0, array: false,
min: -9223372036854775808, max: 9223372036854775807, status: 'available',
});
test('passende Spalte: keine Abweichung, min/max der Antwort werden NICHT verglichen', () => {
assert.deepEqual(compareColumn(okInt(), reportCount), []);
assert.deepEqual(compareColumn({ ...okInt(), min: 0, max: 5 }, reportCount), []);
});
test('Standardwert, required und Typ werden gemeldet', () => {
assert.deepEqual(compareColumn({ ...okInt(), default: 1 }, reportCount), ['default ist 1, erwartet 0']);
assert.deepEqual(compareColumn({ ...okInt(), default: null }, reportCount), ['default ist null, erwartet 0']);
assert.deepEqual(compareColumn({ ...okInt(), required: true }, reportCount), ['required ist true, erwartet false']);
assert.deepEqual(compareColumn({ ...okInt(), type: 'boolean' }, reportCount), ['type ist boolean, erwartet integer']);
});
});
describe('enum', () => {
const reason = col('reports', 'reason');
const okEnum = () => ({
key: 'reason', type: 'string', format: 'enum', required: true, array: false, size: 12, default: null,
elements: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], status: 'available',
});
test('passende Spalte: keine Abweichung, Elemente werden sortiert verglichen', () => {
assert.deepEqual(compareColumn(okEnum(), reason), []);
assert.deepEqual(compareColumn({ ...okEnum(), elements: ['spam', 'sonstiges', 'urheberrecht', 'kartendaten', 'unangemessen'] }, reason), []);
});
test('fehlende/zusätzliche Elemente, required und status werden gemeldet (Elemente sortiert ausgegeben)', () => {
assert.deepEqual(compareColumn({ ...okEnum(), elements: ['spam', 'sonstiges'] }, reason), [
'elements ist [sonstiges, spam], erwartet [kartendaten, sonstiges, spam, unangemessen, urheberrecht]',
]);
assert.equal(compareColumn({ ...okEnum(), elements: [...okEnum().elements, 'gewalt'] }, reason).length, 1);
assert.deepEqual(compareColumn({ ...okEnum(), required: false }, reason), ['required ist false, erwartet true']);
assert.deepEqual(compareColumn({ ...okEnum(), status: 'processing' }, reason), ['status ist processing, erwartet available']);
});
test('eine normale String-Spalte (ohne elements) ist keine Enum-Spalte', () => {
const { elements, format, ...plain } = okEnum();
assert.equal(compareColumn(plain, reason).length, 1);
assert.match(compareColumn(plain, reason)[0], /^elements ist \[\], erwartet \[/);
});
});
});
describe('compareIndex', () => {
const desired = { key: 'search_text', type: 'fulltext', columns: ['searchText'] };
const ok = () => ({ key: 'search_text', type: 'fulltext', columns: ['searchText'], lengths: [], orders: [], status: 'available' });
test('passender Index: keine Abweichung', () => {
assert.deepEqual(compareIndex(ok(), desired), []);
});
test('Index-Typ, Spalten und status werden gemeldet', () => {
assert.deepEqual(compareIndex({ ...ok(), type: 'key' }, desired), ['type ist key, erwartet fulltext']);
assert.deepEqual(compareIndex({ ...ok(), columns: ['title'] }, desired), ['columns ist [title], erwartet [searchText]']);
assert.deepEqual(compareIndex({ ...ok(), columns: ['title', 'searchText'] }, desired), [
'columns ist [title, searchText], erwartet [searchText]',
]);
assert.deepEqual(compareIndex({ ...ok(), status: 'processing' }, desired), ['status ist processing, erwartet available']);
});
test('ältere Antwortform mit `attributes` statt `columns` wird akzeptiert', () => {
const { columns, ...legacy } = ok();
assert.deepEqual(compareIndex({ ...legacy, attributes: ['searchText'] }, desired), []);
assert.deepEqual(compareIndex({ ...legacy, attributes: ['title'] }, desired), ['columns ist [title], erwartet [searchText]']);
});
test('ganz ohne Spaltenangabe wird gemeldet', () => {
const { columns, ...none } = ok();
assert.deepEqual(compareIndex(none, desired), ['columns ist [], erwartet [searchText]']);
});
});
describe('compareBucket', () => {
const CARD_IMAGES = DESIRED.buckets[0];
const ok = () => ({
$id: 'card-images', name: 'Card Images', $permissions: ['create("users")'],
fileSecurity: true, maximumFileSize: 5242880,
@@ -299,32 +549,44 @@ describe('compareBucket', () => {
});
test('passender Bucket: keine Abweichung', () => {
assert.deepEqual(compareBucket(ok(), DESIRED.bucket), []);
assert.deepEqual(compareBucket(ok(), CARD_IMAGES), []);
});
test('Endungen werden sortiert verglichen', () => {
assert.deepEqual(compareBucket({ ...ok(), allowedFileExtensions: ['webp', 'jpeg', 'png', 'jpg'] }, DESIRED.bucket), []);
assert.deepEqual(compareBucket({ ...ok(), allowedFileExtensions: ['webp', 'jpeg', 'png', 'jpg'] }, CARD_IMAGES), []);
});
test('fehlende oder zusätzliche Endungen werden gemeldet (sortiert ausgegeben)', () => {
assert.deepEqual(compareBucket({ ...ok(), allowedFileExtensions: ['png', 'webp'] }, DESIRED.bucket), [
assert.deepEqual(compareBucket({ ...ok(), allowedFileExtensions: ['png', 'webp'] }, CARD_IMAGES), [
'allowedFileExtensions ist [png, webp], erwartet [jpeg, jpg, png, webp]',
]);
assert.equal(compareBucket({ ...ok(), allowedFileExtensions: [] }, DESIRED.bucket).length, 1);
assert.equal(compareBucket({ ...ok(), allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp', 'gif'] }, DESIRED.bucket).length, 1);
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 }, DESIRED.bucket), ['fileSecurity ist false, erwartet true']);
assert.deepEqual(compareBucket({ ...ok(), maximumFileSize: 30000000 }, DESIRED.bucket), [
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 = { ...DESIRED.bucket, permissions: ['create("users")', 'read("any")'] };
const desired = { ...CARD_IMAGES, permissions: ['create("users")', 'read("any")'] };
assert.deepEqual(compareBucket({ ...ok(), $permissions: ['read("any")', 'create("users")'] }, desired), []);
assert.equal(compareBucket({ ...ok(), $permissions: ['read("any")'] }, DESIRED.bucket).length, 1);
assert.equal(compareBucket({ ...ok(), $permissions: ['read("any")'] }, CARD_IMAGES).length, 1);
});
test('gallery-images: nur png/webp erlaubt', () => {
const gallery = DESIRED.buckets[1];
const okGallery = {
$id: 'gallery-images', name: 'Gallery Images', $permissions: ['create("users")'],
fileSecurity: true, maximumFileSize: 5242880, allowedFileExtensions: ['webp', 'png'], compression: 'none', antivirus: false,
};
assert.deepEqual(compareBucket(okGallery, gallery), []);
assert.deepEqual(compareBucket({ ...okGallery, allowedFileExtensions: ['png', 'webp', 'jpg'] }, gallery), [
'allowedFileExtensions ist [jpg, png, webp], erwartet [png, webp]',
]);
});
});
@@ -340,15 +602,82 @@ describe('main gegen Fake-Server', () => {
'Spalte cards.config',
'Spalte cards.imageFileId',
'Spalte cards.previewFileId',
'Tabelle gallery',
'Spalte gallery.title',
'Spalte gallery.issuer',
'Spalte gallery.tags',
'Spalte gallery.displayName',
'Spalte gallery.imageFileId',
'Spalte gallery.previewFileId',
'Spalte gallery.sourceCardId',
'Spalte gallery.ownerId',
'Spalte gallery.hidden',
'Spalte gallery.reportCount',
'Spalte gallery.searchText',
'Index gallery.search_text',
'Index gallery.owner',
'Index gallery.hidden',
'Tabelle reports',
'Spalte reports.galleryId',
'Spalte reports.reason',
'Spalte reports.reporterHash',
'Index reports.gallery',
'Bucket card-images',
'Bucket gallery-images',
];
const NEW_OBJECTS = EXPECTED_OBJECTS.filter((o) => /gallery|reports/.test(o));
const T = '/v1/tablesdb/aircard/tables';
const str = (table, key, size, required) => [`${T}/${table}/columns/string`, { key, size, required }];
const EXPECTED_POSTS = [
['/v1/tablesdb', { databaseId: 'aircard', name: 'AirCard' }],
[T, { tableId: 'cards', name: 'Cards', permissions: ['create("users")'], rowSecurity: true }],
str('cards', 'name', 80, true),
str('cards', 'config', 2000, true),
str('cards', 'imageFileId', 36, true),
str('cards', 'previewFileId', 36, true),
[T, { tableId: 'gallery', name: 'Gallery', permissions: [], rowSecurity: true }],
str('gallery', 'title', 60, true),
str('gallery', 'issuer', 40, false),
str('gallery', 'tags', 120, false),
str('gallery', 'displayName', 30, false),
str('gallery', 'imageFileId', 36, true),
str('gallery', 'previewFileId', 36, true),
str('gallery', 'sourceCardId', 36, false),
str('gallery', 'ownerId', 36, true),
[`${T}/gallery/columns/boolean`, { key: 'hidden', required: false, default: false }],
[`${T}/gallery/columns/integer`, { key: 'reportCount', required: false, default: 0, min: 0 }],
str('gallery', 'searchText', 300, false),
[`${T}/gallery/indexes`, { key: 'search_text', type: 'fulltext', columns: ['searchText'] }],
[`${T}/gallery/indexes`, { key: 'owner', type: 'key', columns: ['ownerId'] }],
[`${T}/gallery/indexes`, { key: 'hidden', type: 'key', columns: ['hidden'] }],
[T, { tableId: 'reports', name: 'Reports', permissions: ['read("label:admin")'], rowSecurity: false }],
str('reports', 'galleryId', 36, true),
[`${T}/reports/columns/enum`, {
key: 'reason', elements: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true,
}],
str('reports', 'reporterHash', 64, true),
[`${T}/reports/indexes`, { key: 'gallery', type: 'key', columns: ['galleryId'] }],
['/v1/storage/buckets', {
bucketId: 'card-images', name: 'Card Images', permissions: ['create("users")'], fileSecurity: true,
maximumFileSize: 5242880, allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'],
compression: 'none', antivirus: false,
}],
['/v1/storage/buckets', {
bucketId: 'gallery-images', name: 'Gallery Images', permissions: ['create("users")'], fileSecurity: true,
maximumFileSize: 5242880, allowedFileExtensions: ['png', 'webp'],
compression: 'none', antivirus: false,
}],
];
test('--check auf leerem Projekt: Exit 1, alle 7 Objekte FEHLT, nichts angelegt', async () => {
test('--check auf leerem Projekt: Exit 1, alle 28 Objekte FEHLT, nichts angelegt', async () => {
await withFake({}, async (fake) => {
const { code, lines } = await run(fake, { check: true });
assert.equal(code, 1);
assert.equal(EXPECTED_OBJECTS.length, 28);
assert.deepEqual(startsWith(lines, 'FEHLT'), EXPECTED_OBJECTS.map((o) => `FEHLT: ${o}`));
assert.equal(startsWith(lines, 'OK').length, 0);
assert.ok(lines.includes('UNVOLLSTÄNDIG: 28 fehlend.'), lines.join('\n'));
assert.equal(fake.posts().length, 0);
assert.equal(fake.otherWrites().length, 0);
});
@@ -358,29 +687,14 @@ describe('main gegen Fake-Server', () => {
await withFake({}, async (fake) => {
// Anlegen
const first = await run(fake);
assert.equal(first.code, 0);
assert.equal(first.code, 0, first.lines.join('\n'));
assert.deepEqual(startsWith(first.lines, 'ANGELEGT'), EXPECTED_OBJECTS.map((o) => `ANGELEGT: ${o}`));
assert.equal(startsWith(first.lines, 'FEHLT').length, 0);
assert.equal(startsWith(first.lines, 'ABWEICHUNG').length, 0);
assert.ok(first.lines.includes('OK: Schema vollständig'));
// Bodies und Reihenfolge exakt wie im Brief
assert.deepEqual(
fake.posts().map((r) => [r.path, r.body]),
[
['/v1/tablesdb', { databaseId: 'aircard', name: 'AirCard' }],
['/v1/tablesdb/aircard/tables', { tableId: 'cards', name: 'Cards', permissions: ['create("users")'], rowSecurity: true }],
['/v1/tablesdb/aircard/tables/cards/columns/string', { key: 'name', size: 80, required: true }],
['/v1/tablesdb/aircard/tables/cards/columns/string', { key: 'config', size: 2000, required: true }],
['/v1/tablesdb/aircard/tables/cards/columns/string', { key: 'imageFileId', size: 36, required: true }],
['/v1/tablesdb/aircard/tables/cards/columns/string', { key: 'previewFileId', size: 36, required: true }],
['/v1/storage/buckets', {
bucketId: 'card-images', name: 'Card Images', permissions: ['create("users")'], fileSecurity: true,
maximumFileSize: 5242880, allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'],
compression: 'none', antivirus: false,
}],
],
);
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) {
@@ -391,8 +705,15 @@ describe('main gegen Fake-Server', () => {
// Der Key taucht nie in der Ausgabe auf
assert.ok(!first.lines.join('\n').includes(KEY));
// Alle Spalten wurden vor dem Ende ausgewartet (Status available)
// Alle Spalten und Indizes wurden vor dem Ende ausgewartet (Status available)
assert.equal(fake.state.columns.size, 18);
assert.equal(fake.state.indexes.size, 4);
for (const col of fake.state.columns.values()) assert.ok(col.gets >= 2, `Spalte ${col.key} nicht bis available geprüft`);
for (const idx of fake.state.indexes.values()) assert.ok(idx.gets >= 2, `Index ${idx.key} nicht bis available geprüft`);
// Indizes wurden erst angelegt, nachdem ALLE Spalten der Tabelle available waren
assert.deepEqual(fake.state.indexPosts.map((p) => `${p.table}.${p.key}`), ['gallery.search_text', 'gallery.owner', 'gallery.hidden', 'reports.gallery']);
assert.ok(fake.state.indexPosts.every((p) => p.allColumnsAvailable), JSON.stringify(fake.state.indexPosts));
// Zweite Prüfung
const postsBefore = fake.posts().length;
@@ -411,6 +732,75 @@ describe('main gegen Fake-Server', () => {
});
});
test('Bestehendes Schema (nur cards + card-images) wird ergänzt, Bestehendes nicht angefasst', async () => {
await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0);
// Zustand vor der Erweiterung nachstellen
for (const map of [fake.state.tables, fake.state.columns, fake.state.indexes]) {
for (const id of [...map.keys()]) if (/^aircard\/(gallery|reports)(\/|$)/.test(id)) map.delete(id);
}
fake.state.buckets.delete('gallery-images');
const requestsBefore = fake.state.requests.length;
const check = await run(fake, { check: true });
assert.equal(check.code, 1);
assert.deepEqual(startsWith(check.lines, 'FEHLT'), NEW_OBJECTS.map((o) => `FEHLT: ${o}`));
assert.equal(fake.posts().length, EXPECTED_POSTS.length);
const apply = await run(fake);
assert.equal(apply.code, 0, apply.lines.join('\n'));
assert.deepEqual(startsWith(apply.lines, 'ANGELEGT'), NEW_OBJECTS.map((o) => `ANGELEGT: ${o}`));
assert.ok(apply.lines.includes('OK: Tabelle cards'));
assert.ok(apply.lines.includes('OK: Bucket card-images'));
const newPosts = fake.state.requests.slice(requestsBefore).filter((r) => r.method === 'POST');
assert.deepEqual(newPosts.map((r) => [r.path, r.body]), EXPECTED_POSTS.filter(([path, body]) => (
/\/(gallery|reports)(\/|$)/.test(path) || ['gallery', 'reports', 'gallery-images'].includes(body.tableId ?? body.bucketId)
)));
assert.ok(fake.state.indexPosts.every((p) => p.allColumnsAvailable));
});
});
test('Fehlende Indizes bei vorhandener Tabelle: --check meldet FEHLT, Anlegen legt nur sie an', async () => {
await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0);
fake.state.indexes.delete('aircard/gallery/owner');
fake.state.indexes.delete('aircard/reports/gallery');
const postsBefore = fake.posts().length;
const check = await run(fake, { check: true });
assert.equal(check.code, 1);
assert.deepEqual(startsWith(check.lines, 'FEHLT'), ['FEHLT: Index gallery.owner', 'FEHLT: Index reports.gallery']);
assert.equal(fake.posts().length, postsBefore);
const apply = await run(fake);
assert.equal(apply.code, 0);
assert.deepEqual(startsWith(apply.lines, 'ANGELEGT'), ['ANGELEGT: Index gallery.owner', 'ANGELEGT: Index reports.gallery']);
assert.deepEqual(
fake.posts().slice(postsBefore).map((r) => [r.path, r.body]),
[
[`${T}/gallery/indexes`, { key: 'owner', type: 'key', columns: ['ownerId'] }],
[`${T}/reports/indexes`, { key: 'gallery', type: 'key', columns: ['galleryId'] }],
],
);
});
});
test('Spalte nicht available (failed): Index wird nicht angelegt, nur als FEHLT gemeldet', async () => {
await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0);
fake.state.indexes.delete('aircard/gallery/owner');
fake.state.columns.get('aircard/gallery/title').forceStatus = 'failed';
const postsBefore = fake.posts().length;
const { code, lines } = await run(fake);
assert.equal(code, 1);
assert.deepEqual(startsWith(lines, 'ABWEICHUNG'), ['ABWEICHUNG: gallery.title.status ist failed, erwartet available']);
assert.deepEqual(startsWith(lines, 'FEHLT'), ['FEHLT: Index gallery.owner']);
assert.equal(fake.posts().length, postsBefore);
assert.ok(!lines.includes('OK: Schema vollständig'));
});
});
test('Abweichungen werden gemeldet, aber nicht verändert (Exit 1)', async () => {
await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0);
@@ -442,6 +832,71 @@ describe('main gegen Fake-Server', () => {
});
});
test('Abweichender Index-Typ oder -Spalten: ABWEICHUNG, nichts wird geändert (Exit 1)', async () => {
await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0);
fake.state.indexes.get('aircard/gallery/search_text').type = 'key';
fake.state.indexes.get('aircard/gallery/owner').type = 'unique';
fake.state.indexes.get('aircard/gallery/hidden').columns = ['reportCount'];
const requestsBefore = fake.state.requests.length;
for (const check of [true, false]) {
const { code, lines } = await run(fake, { check });
assert.equal(code, 1);
assert.deepEqual(startsWith(lines, 'ABWEICHUNG'), [
'ABWEICHUNG: gallery.search_text.type ist key, erwartet fulltext',
'ABWEICHUNG: gallery.owner.type ist unique, erwartet key',
'ABWEICHUNG: gallery.hidden.columns ist [reportCount], erwartet [hidden]',
]);
assert.ok(lines.includes('OK: Index reports.gallery'));
assert.ok(!lines.includes('OK: Schema vollständig'));
assert.equal(startsWith(lines, 'ANGELEGT').length, 0);
}
assert.ok(fake.state.requests.slice(requestsBefore).every((r) => r.method === 'GET'));
});
});
test('Abweichende neue Spalten (Standardwert, Typ, Enum-Elemente, Pflicht) und Buckets werden gemeldet', async () => {
await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0);
fake.state.columns.get('aircard/gallery/hidden').default = true;
fake.state.columns.get('aircard/gallery/reportCount').default = 5;
fake.state.columns.get('aircard/gallery/title').size = 100;
fake.state.columns.get('aircard/gallery/ownerId').required = false;
fake.state.columns.get('aircard/reports/reason').elements = ['spam', 'sonstiges'];
fake.state.columns.get('aircard/reports/galleryId').type = 'integer';
fake.state.tables.get('aircard/gallery').$permissions = ['read("any")'];
fake.state.tables.get('aircard/reports').rowSecurity = true;
fake.state.buckets.get('gallery-images').allowedFileExtensions = ['png', 'webp', 'svg'];
const requestsBefore = fake.state.requests.length;
const { code, lines } = await run(fake, { check: true });
assert.equal(code, 1);
assert.deepEqual(startsWith(lines, 'ABWEICHUNG'), [
'ABWEICHUNG: gallery.$permissions ist [read("any")], erwartet []',
'ABWEICHUNG: gallery.title.size ist 100, erwartet 60',
'ABWEICHUNG: gallery.ownerId.required ist false, erwartet true',
'ABWEICHUNG: gallery.hidden.default ist true, erwartet false',
'ABWEICHUNG: gallery.reportCount.default ist 5, erwartet 0',
'ABWEICHUNG: reports.rowSecurity ist true, erwartet false',
'ABWEICHUNG: reports.galleryId.type ist integer, erwartet string',
'ABWEICHUNG: reports.reason.elements ist [sonstiges, spam], erwartet [kartendaten, sonstiges, spam, unangemessen, urheberrecht]',
'ABWEICHUNG: gallery-images.allowedFileExtensions ist [png, svg, webp], erwartet [png, webp]',
]);
assert.ok(fake.state.requests.slice(requestsBefore).every((r) => r.method === 'GET'));
});
});
test('Integer-Spalte mit riesigen Standard-min/max der Antwort gilt als in Ordnung', async () => {
await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0);
const col = fake.state.columns.get('aircard/gallery/reportCount');
assert.equal(col.min, -9223372036854775808);
assert.equal(col.max, 9223372036854775807);
assert.equal((await run(fake, { check: true })).code, 0);
});
});
test('404 project_not_found bricht klar ab und meldet nichts als fehlend', async () => {
await withFake({ project: 'anderes-projekt' }, async (fake) => {
for (const check of [true, false]) {
@@ -516,6 +971,46 @@ describe('main gegen Fake-Server', () => {
});
});
test('Index wird nicht verfügbar: Abbruch nach Wartezeit, danach nichts mehr angelegt', async () => {
await withFake({ stuckIndexes: true }, async (fake) => {
const { code, lines } = await run(fake, { timeoutMs: 60 });
assert.equal(code, 2);
const abort = startsWith(lines, 'ABBRUCH');
assert.equal(abort.length, 1);
assert.match(abort[0], /gallery\.search_text/);
assert.match(abort[0], /nicht .*verfügbar/);
assert.equal(fake.state.tables.has('aircard/reports'), false);
assert.equal(fake.state.buckets.size, 0);
});
});
test('Index mit Status failed: Abbruch mit klarer Meldung', async () => {
await withFake({ failedIndexes: true }, async (fake) => {
const { code, lines } = await run(fake);
assert.equal(code, 2);
const abort = startsWith(lines, 'ABBRUCH');
assert.equal(abort.length, 1);
assert.match(abort[0], /Index gallery\.search_text/);
assert.match(abort[0], /failed/);
assert.equal(fake.state.buckets.size, 0);
});
});
test('Zugriff verweigert beim Anlegen eines Index: Abbruch (Exit 2) ohne den Key auszugeben', async () => {
const inject = (method, path) => (method === 'POST' && path.endsWith('/gallery/indexes')
? { status: 401, type: 'general_unauthorized_scope', message: 'missing scope (tables.write)' }
: null);
await withFake({ inject }, async (fake) => {
const { code, lines } = await run(fake);
assert.equal(code, 2);
const abort = startsWith(lines, 'ABBRUCH');
assert.equal(abort.length, 1);
assert.match(abort[0], /Index gallery\.search_text/);
assert.match(abort[0], /401/);
assert.ok(!lines.join('\n').includes(KEY));
});
});
test('Server nicht erreichbar: Abbruch mit klarer Meldung', async () => {
const probe = http.createServer();
await new Promise((r) => probe.listen(0, '127.0.0.1', r));
@@ -570,6 +1065,10 @@ describe('CLI', () => {
assert.equal(code, 1);
assert.match(stdout, /FEHLT: Datenbank aircard/);
assert.match(stdout, /FEHLT: Bucket card-images/);
assert.match(stdout, /FEHLT: Tabelle gallery/);
assert.match(stdout, /FEHLT: Index gallery\.search_text/);
assert.match(stdout, /FEHLT: Tabelle reports/);
assert.match(stdout, /FEHLT: Bucket gallery-images/);
assert.ok(!(stdout + stderr).includes(KEY));
assert.equal(fake.posts().length, 0);
});