feat(appwrite): Schema für Community-Logos, Herzen und Logo-Meldungen

- Setup-Skript: Tabellen `logos`, `logo_likes` (Mehrspalten-Index `user_logo` über userId, logoId)
  und `logo_reports` sowie der Bucket `logo-images` (nur PNG, höchstens 1 MiB); der Kopfkommentar
  nennt die neuen Objekte. Der Index-Request schickt `columns` schon als Liste, dort war nichts zu ändern.
- Tests: DESIRED mit genau diesen Werten, Anlegen samt Indizes gegen den Fake-Server, --check meldet jedes
  fehlende Objekt (51 statt 28) mit Exit 1, bestehende Tabellen und Buckets bleiben unberührt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 18:51:36 +00:00
1 parent e1fb632cdd
commit 88abc2061d
2 files changed
+315 -20

No files matched your search

+63 -3
View File
@@ -3,8 +3,9 @@
* AirCard – Appwrite-Schema-Setup (idempotent) * AirCard – Appwrite-Schema-Setup (idempotent)
* *
* Legt im selbst gehosteten Appwrite (Projekt `aircard`) die Datenbank `aircard`, * Legt im selbst gehosteten Appwrite (Projekt `aircard`) die Datenbank `aircard`,
* die Tabellen `cards`, `gallery` und `reports` (TablesDB, mit Spalten und * die Tabellen `cards`, `gallery`, `reports`, `logos`, `logo_likes` und
* Indizes) und die Buckets `card-images` und `gallery-images` an. * `logo_reports` (TablesDB, mit Spalten und Indizes) und die Buckets
* `card-images`, `gallery-images` und `logo-images` an.
* Reines `fetch`, keine Abhängigkeiten, Node 22+. * Reines `fetch`, keine Abhängigkeiten, Node 22+.
* *
* Aufruf (vom Repo-Wurzelverzeichnis): * Aufruf (vom Repo-Wurzelverzeichnis):
@@ -40,7 +41,7 @@ const str = (key, size, required) => ({ key, type: 'string', size, required });
export const DESIRED = { export const DESIRED = {
database: { id: 'aircard', name: 'AirCard' }, database: { id: 'aircard', name: 'AirCard' },
// Reihenfolge ist Ablauf: cards, gallery, reports (je Tabelle: Spalten, dann Indizes). // Reihenfolge ist Ablauf: cards, gallery, reports, logos, logo_likes, logo_reports (je Tabelle: Spalten, dann Indizes).
tables: [ tables: [
{ {
id: 'cards', id: 'cards',
@@ -88,6 +89,54 @@ export const DESIRED = {
], ],
indexes: [{ key: 'gallery', type: 'key', columns: ['galleryId'] }], indexes: [{ key: 'gallery', type: 'key', columns: ['galleryId'] }],
}, },
{
// Community-Logos. Wie `gallery`: Zeilenrechte setzt nur die API, Clients dürfen nichts auf Tabellenebene.
id: 'logos',
name: 'Logos',
permissions: [],
rowSecurity: true,
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 },
],
indexes: [
{ key: 'owner', type: 'key', columns: ['ownerId'] },
{ key: 'hidden', type: 'key', columns: ['hidden'] },
{ key: 'likes', type: 'key', columns: ['likeCount'] },
],
},
{
// Ein Herz je Konto und Logo: Zeilen-ID hash32("<logoId>|<userId>"), lesen darf nur das Konto selbst; geschrieben wird nur über die API.
id: 'logo_likes',
name: 'Logo Likes',
permissions: [],
rowSecurity: true,
columns: [str('logoId', 36, true), str('userId', 36, true)],
indexes: [
{ key: 'logo', type: 'key', columns: ['logoId'] },
// Mehrspalten-Index: die Reihenfolge der Spalten zählt (und wird verglichen).
{ key: 'user_logo', type: 'key', columns: ['userId', 'logoId'] },
],
},
{
// Nur Admins lesen (Label `admin`); geschrieben wird ausschließlich über die API.
id: 'logo_reports',
name: 'Logo Reports',
permissions: ['read("label:admin")'],
rowSecurity: false,
columns: [
str('logoId', 36, true),
{ key: 'reason', type: 'enum', elements: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true },
str('reporterHash', 64, true),
],
indexes: [{ key: 'logo', type: 'key', columns: ['logoId'] }],
},
], ],
buckets: [ buckets: [
{ {
@@ -110,6 +159,17 @@ export const DESIRED = {
compression: 'none', compression: 'none',
antivirus: false, antivirus: false,
}, },
{
// Logo-PNG (höchstens 1 MiB); die Dateirechte setzt die API je Datei.
id: 'logo-images',
name: 'Logo Images',
permissions: ['create("users")'],
fileSecurity: true,
maximumFileSize: 1048576,
allowedFileExtensions: ['png'],
compression: 'none',
antivirus: false,
},
], ],
}; };
+252 -17
View File
@@ -236,6 +236,16 @@ async function run(fake, opts = {}) {
const startsWith = (lines, prefix) => lines.filter((l) => l.startsWith(prefix)); 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) { function runCli(args, env) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [SCRIPT, ...args], { const child = spawn(process.execPath, [SCRIPT, ...args], {
@@ -260,10 +270,10 @@ describe('DESIRED', () => {
const bucket = (id) => DESIRED.buckets.find((b) => b.id === id); const bucket = (id) => DESIRED.buckets.find((b) => b.id === id);
const str = (key, size, required) => ({ key, type: 'string', size, required }); const str = (key, size, required) => ({ key, type: 'string', size, required });
test('Reihenfolge: cards zuerst, dann gallery, reports; Buckets card-images, gallery-images', () => { test('Reihenfolge: cards zuerst, dann gallery, reports, logos, logo_likes, logo_reports; Buckets card-images, gallery-images, logo-images', () => {
assert.deepEqual(DESIRED.database, { id: 'aircard', name: 'AirCard' }); assert.deepEqual(DESIRED.database, { id: 'aircard', name: 'AirCard' });
assert.deepEqual(DESIRED.tables.map((t) => t.id), ['cards', 'gallery', 'reports']); assert.deepEqual(DESIRED.tables.map((t) => t.id), ['cards', 'gallery', 'reports', 'logos', 'logo_likes', 'logo_reports']);
assert.deepEqual(DESIRED.buckets.map((b) => b.id), ['card-images', 'gallery-images']); assert.deepEqual(DESIRED.buckets.map((b) => b.id), ['card-images', 'gallery-images', 'logo-images']);
}); });
test('cards entspricht dem bisherigen Schema (unverändert)', () => { test('cards entspricht dem bisherigen Schema (unverändert)', () => {
@@ -336,6 +346,66 @@ describe('DESIRED', () => {
}); });
}); });
test('logos entspricht Logo-Spec §8', () => {
const t = table('logos');
assert.equal(t.name, 'Logos');
assert.deepEqual(t.permissions, []);
assert.equal(t.rowSecurity, true);
assert.deepEqual(t.columns, [
str('name', 40, true),
str('displayName', 30, false),
str('fileId', 36, true),
str('ownerId', 36, true),
{ key: 'mono', type: 'boolean', required: false, default: false },
{ key: 'likeCount', type: 'integer', required: false, default: 0, min: 0 },
{ key: 'hidden', type: 'boolean', required: false, default: false },
{ key: 'reportCount', type: 'integer', required: false, default: 0, min: 0 },
]);
assert.deepEqual(t.indexes, [
{ key: 'owner', type: 'key', columns: ['ownerId'] },
{ key: 'hidden', type: 'key', columns: ['hidden'] },
{ key: 'likes', type: 'key', columns: ['likeCount'] },
]);
});
test('logo_likes entspricht Logo-Spec §8 (Mehrspalten-Index user_logo: userId, logoId)', () => {
const t = table('logo_likes');
assert.equal(t.name, 'Logo Likes');
assert.deepEqual(t.permissions, []);
assert.equal(t.rowSecurity, true);
assert.deepEqual(t.columns, [str('logoId', 36, true), str('userId', 36, true)]);
assert.deepEqual(t.indexes, [
{ key: 'logo', type: 'key', columns: ['logoId'] },
{ key: 'user_logo', type: 'key', columns: ['userId', 'logoId'] },
]);
});
test('logo_reports entspricht Logo-Spec §8', () => {
const t = table('logo_reports');
assert.equal(t.name, 'Logo Reports');
assert.deepEqual(t.permissions, ['read("label:admin")']);
assert.equal(t.rowSecurity, false);
assert.deepEqual(t.columns, [
str('logoId', 36, true),
{ key: 'reason', type: 'enum', elements: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true },
str('reporterHash', 64, true),
]);
assert.deepEqual(t.indexes, [{ key: 'logo', type: 'key', columns: ['logoId'] }]);
});
test('logo-images entspricht Logo-Spec §8', () => {
assert.deepEqual(bucket('logo-images'), {
id: 'logo-images',
name: 'Logo Images',
permissions: ['create("users")'],
fileSecurity: true,
maximumFileSize: 1048576,
allowedFileExtensions: ['png'],
compression: 'none',
antivirus: false,
});
});
test('Fulltext-Indizes decken genau eine Spalte ab, alle Index-Spalten existieren', () => { test('Fulltext-Indizes decken genau eine Spalte ab, alle Index-Spalten existieren', () => {
for (const t of DESIRED.tables) { for (const t of DESIRED.tables) {
const keys = t.columns.map((c) => c.key); const keys = t.columns.map((c) => c.key);
@@ -538,6 +608,18 @@ describe('compareIndex', () => {
const { columns, ...none } = ok(); const { columns, ...none } = ok();
assert.deepEqual(compareIndex(none, desired), ['columns ist [], erwartet [searchText]']); assert.deepEqual(compareIndex(none, desired), ['columns ist [], erwartet [searchText]']);
}); });
test('Mehrspalten-Index (logo_likes.user_logo): die Reihenfolge der Spalten zählt', () => {
const userLogo = DESIRED.tables.find((t) => t.id === 'logo_likes').indexes.find((i) => i.key === 'user_logo');
const okMulti = { key: 'user_logo', type: 'key', columns: ['userId', 'logoId'], lengths: [], orders: [], status: 'available' };
assert.deepEqual(compareIndex(okMulti, userLogo), []);
assert.deepEqual(compareIndex({ ...okMulti, columns: ['logoId', 'userId'] }, userLogo), [
'columns ist [logoId, userId], erwartet [userId, logoId]',
]);
assert.deepEqual(compareIndex({ ...okMulti, columns: ['userId'] }, userLogo), [
'columns ist [userId], erwartet [userId, logoId]',
]);
});
}); });
describe('compareBucket', () => { describe('compareBucket', () => {
@@ -588,6 +670,21 @@ describe('compareBucket', () => {
'allowedFileExtensions ist [jpg, png, webp], erwartet [png, webp]', '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',
]);
});
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -622,10 +719,35 @@ describe('main gegen Fake-Server', () => {
'Spalte reports.reason', 'Spalte reports.reason',
'Spalte reports.reporterHash', 'Spalte reports.reporterHash',
'Index reports.gallery', 'Index reports.gallery',
'Tabelle logos',
'Spalte logos.name',
'Spalte logos.displayName',
'Spalte logos.fileId',
'Spalte logos.ownerId',
'Spalte logos.mono',
'Spalte logos.likeCount',
'Spalte logos.hidden',
'Spalte logos.reportCount',
'Index logos.owner',
'Index logos.hidden',
'Index logos.likes',
'Tabelle logo_likes',
'Spalte logo_likes.logoId',
'Spalte logo_likes.userId',
'Index logo_likes.logo',
'Index logo_likes.user_logo',
'Tabelle logo_reports',
'Spalte logo_reports.logoId',
'Spalte logo_reports.reason',
'Spalte logo_reports.reporterHash',
'Index logo_reports.logo',
'Bucket card-images', 'Bucket card-images',
'Bucket gallery-images', 'Bucket gallery-images',
'Bucket logo-images',
]; ];
const NEW_OBJECTS = EXPECTED_OBJECTS.filter((o) => /gallery|reports/.test(o)); // Erweiterung um die Galerie (gallery, reports, gallery-images) bzw. um die Logos (logos, logo_likes, logo_reports, logo-images)
const GALLERY_OBJECTS = EXPECTED_OBJECTS.filter((o) => /^(Tabelle|Spalte|Index) (gallery|reports)(\.|$)|^Bucket gallery-images$/.test(o));
const LOGO_OBJECTS = EXPECTED_OBJECTS.filter((o) => /^(Tabelle|Spalte|Index) logo(s|_likes|_reports)(\.|$)|^Bucket logo-images$/.test(o));
const T = '/v1/tablesdb/aircard/tables'; const T = '/v1/tablesdb/aircard/tables';
const str = (table, key, size, required) => [`${T}/${table}/columns/string`, { key, size, required }]; const str = (table, key, size, required) => [`${T}/${table}/columns/string`, { key, size, required }];
@@ -658,6 +780,31 @@ describe('main gegen Fake-Server', () => {
}], }],
str('reports', 'reporterHash', 64, true), str('reports', 'reporterHash', 64, true),
[`${T}/reports/indexes`, { key: 'gallery', type: 'key', columns: ['galleryId'] }], [`${T}/reports/indexes`, { key: 'gallery', type: 'key', columns: ['galleryId'] }],
[T, { tableId: 'logos', name: 'Logos', permissions: [], rowSecurity: true }],
str('logos', 'name', 40, true),
str('logos', 'displayName', 30, false),
str('logos', 'fileId', 36, true),
str('logos', 'ownerId', 36, true),
[`${T}/logos/columns/boolean`, { key: 'mono', required: false, default: false }],
[`${T}/logos/columns/integer`, { key: 'likeCount', required: false, default: 0, min: 0 }],
[`${T}/logos/columns/boolean`, { key: 'hidden', required: false, default: false }],
[`${T}/logos/columns/integer`, { key: 'reportCount', required: false, default: 0, min: 0 }],
[`${T}/logos/indexes`, { key: 'owner', type: 'key', columns: ['ownerId'] }],
[`${T}/logos/indexes`, { key: 'hidden', type: 'key', columns: ['hidden'] }],
[`${T}/logos/indexes`, { key: 'likes', type: 'key', columns: ['likeCount'] }],
[T, { tableId: 'logo_likes', name: 'Logo Likes', permissions: [], rowSecurity: true }],
str('logo_likes', 'logoId', 36, true),
str('logo_likes', 'userId', 36, true),
[`${T}/logo_likes/indexes`, { key: 'logo', type: 'key', columns: ['logoId'] }],
// Mehrspalten-Index: `columns` geht als Liste in dieser Reihenfolge an Appwrite
[`${T}/logo_likes/indexes`, { key: 'user_logo', type: 'key', columns: ['userId', 'logoId'] }],
[T, { tableId: 'logo_reports', name: 'Logo Reports', permissions: ['read("label:admin")'], rowSecurity: false }],
str('logo_reports', 'logoId', 36, true),
[`${T}/logo_reports/columns/enum`, {
key: 'reason', elements: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true,
}],
str('logo_reports', 'reporterHash', 64, true),
[`${T}/logo_reports/indexes`, { key: 'logo', type: 'key', columns: ['logoId'] }],
['/v1/storage/buckets', { ['/v1/storage/buckets', {
bucketId: 'card-images', name: 'Card Images', permissions: ['create("users")'], fileSecurity: true, bucketId: 'card-images', name: 'Card Images', permissions: ['create("users")'], fileSecurity: true,
maximumFileSize: 5242880, allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'], maximumFileSize: 5242880, allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'],
@@ -668,16 +815,22 @@ describe('main gegen Fake-Server', () => {
maximumFileSize: 5242880, allowedFileExtensions: ['png', 'webp'], maximumFileSize: 5242880, allowedFileExtensions: ['png', 'webp'],
compression: 'none', antivirus: false, compression: 'none', antivirus: false,
}], }],
['/v1/storage/buckets', {
bucketId: 'logo-images', name: 'Logo Images', permissions: ['create("users")'], fileSecurity: true,
maximumFileSize: 1048576, allowedFileExtensions: ['png'],
compression: 'none', antivirus: false,
}],
]; ];
const isLogoPost = ([path, body]) => /\/tables\/logo/.test(path) || /^logo/.test(body.tableId ?? body.bucketId ?? '');
test('--check auf leerem Projekt: Exit 1, alle 28 Objekte FEHLT, nichts angelegt', async () => { test('--check auf leerem Projekt: Exit 1, alle 51 Objekte FEHLT, nichts angelegt', async () => {
await withFake({}, async (fake) => { await withFake({}, async (fake) => {
const { code, lines } = await run(fake, { check: true }); const { code, lines } = await run(fake, { check: true });
assert.equal(code, 1); assert.equal(code, 1);
assert.equal(EXPECTED_OBJECTS.length, 28); assert.equal(EXPECTED_OBJECTS.length, 51);
assert.deepEqual(startsWith(lines, 'FEHLT'), EXPECTED_OBJECTS.map((o) => `FEHLT: ${o}`)); assert.deepEqual(startsWith(lines, 'FEHLT'), EXPECTED_OBJECTS.map((o) => `FEHLT: ${o}`));
assert.equal(startsWith(lines, 'OK').length, 0); assert.equal(startsWith(lines, 'OK').length, 0);
assert.ok(lines.includes('UNVOLLSTÄNDIG: 28 fehlend.'), lines.join('\n')); assert.ok(lines.includes('UNVOLLSTÄNDIG: 51 fehlend.'), lines.join('\n'));
assert.equal(fake.posts().length, 0); assert.equal(fake.posts().length, 0);
assert.equal(fake.otherWrites().length, 0); assert.equal(fake.otherWrites().length, 0);
}); });
@@ -706,13 +859,16 @@ describe('main gegen Fake-Server', () => {
assert.ok(!first.lines.join('\n').includes(KEY)); assert.ok(!first.lines.join('\n').includes(KEY));
// Alle Spalten und Indizes 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.columns.size, 31);
assert.equal(fake.state.indexes.size, 4); assert.equal(fake.state.indexes.size, 10);
for (const col of fake.state.columns.values()) assert.ok(col.gets >= 2, `Spalte ${col.key} nicht bis available geprüft`); for (const 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`); 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 // 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.deepEqual(fake.state.indexPosts.map((p) => `${p.table}.${p.key}`), [
'gallery.search_text', 'gallery.owner', 'gallery.hidden', 'reports.gallery',
'logos.owner', 'logos.hidden', 'logos.likes', 'logo_likes.logo', 'logo_likes.user_logo', 'logo_reports.logo',
]);
assert.ok(fake.state.indexPosts.every((p) => p.allColumnsAvailable), JSON.stringify(fake.state.indexPosts)); assert.ok(fake.state.indexPosts.every((p) => p.allColumnsAvailable), JSON.stringify(fake.state.indexPosts));
// Zweite Prüfung // Zweite Prüfung
@@ -732,24 +888,21 @@ describe('main gegen Fake-Server', () => {
}); });
}); });
test('Bestehendes Schema (nur cards + card-images) wird ergänzt, Bestehendes nicht angefasst', async () => { test('Bestehendes Schema ohne gallery, reports, gallery-images wird ergänzt, Bestehendes nicht angefasst', async () => {
await withFake({}, async (fake) => { await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0); assert.equal((await run(fake)).code, 0);
// Zustand vor der Erweiterung nachstellen // Zustand vor der Erweiterung nachstellen
for (const map of [fake.state.tables, fake.state.columns, fake.state.indexes]) { dropFromFake(fake, { tables: ['gallery', 'reports'], buckets: ['gallery-images'] });
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 requestsBefore = fake.state.requests.length;
const check = await run(fake, { check: true }); const check = await run(fake, { check: true });
assert.equal(check.code, 1); assert.equal(check.code, 1);
assert.deepEqual(startsWith(check.lines, 'FEHLT'), NEW_OBJECTS.map((o) => `FEHLT: ${o}`)); assert.deepEqual(startsWith(check.lines, 'FEHLT'), GALLERY_OBJECTS.map((o) => `FEHLT: ${o}`));
assert.equal(fake.posts().length, EXPECTED_POSTS.length); assert.equal(fake.posts().length, EXPECTED_POSTS.length);
const apply = await run(fake); const apply = await run(fake);
assert.equal(apply.code, 0, apply.lines.join('\n')); assert.equal(apply.code, 0, apply.lines.join('\n'));
assert.deepEqual(startsWith(apply.lines, 'ANGELEGT'), NEW_OBJECTS.map((o) => `ANGELEGT: ${o}`)); 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: Tabelle cards'));
assert.ok(apply.lines.includes('OK: Bucket card-images')); assert.ok(apply.lines.includes('OK: Bucket card-images'));
const newPosts = fake.state.requests.slice(requestsBefore).filter((r) => r.method === 'POST'); const newPosts = fake.state.requests.slice(requestsBefore).filter((r) => r.method === 'POST');
@@ -760,6 +913,49 @@ describe('main gegen Fake-Server', () => {
}); });
}); });
test('Bestehendes Schema ohne die Logo-Objekte wird um sie ergänzt, cards/gallery/reports und die Buckets bleiben unberührt', async () => {
await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0);
// Stand vor den Logos nachstellen: cards, gallery, reports, card-images, gallery-images
dropFromFake(fake, { tables: ['logos', 'logo_likes', 'logo_reports'], buckets: ['logo-images'] });
const kept = EXPECTED_OBJECTS.filter((o) => !LOGO_OBJECTS.includes(o));
assert.equal(LOGO_OBJECTS.length, 23);
assert.equal(kept.length, 28);
const requestsBefore = fake.state.requests.length;
const postsBefore = fake.posts().length;
// --check: jedes fehlende neue Objekt als FEHLT, sonst nichts auffällig, nichts angelegt
const check = await run(fake, { check: true });
assert.equal(check.code, 1);
assert.deepEqual(startsWith(check.lines, 'FEHLT'), LOGO_OBJECTS.map((o) => `FEHLT: ${o}`));
assert.equal(startsWith(check.lines, 'ABWEICHUNG').length, 0);
assert.deepEqual(startsWith(check.lines, 'OK: ').filter((l) => l !== 'OK: Schema vollständig'), kept.map((o) => `OK: ${o}`));
assert.ok(check.lines.includes('UNVOLLSTÄNDIG: 23 fehlend.'), check.lines.join('\n'));
assert.equal(fake.posts().length, postsBefore);
// Anlegen: genau die Logo-Objekte, samt Mehrspalten-Index, in der Reihenfolge von DESIRED
const apply = await run(fake);
assert.equal(apply.code, 0, apply.lines.join('\n'));
assert.deepEqual(startsWith(apply.lines, 'ANGELEGT'), LOGO_OBJECTS.map((o) => `ANGELEGT: ${o}`));
assert.deepEqual(startsWith(apply.lines, 'OK: ').filter((l) => l !== 'OK: Schema vollständig'), kept.map((o) => `OK: ${o}`));
const newPosts = fake.state.requests.slice(requestsBefore).filter((r) => r.method === 'POST');
assert.deepEqual(newPosts.map((r) => [r.path, r.body]), EXPECTED_POSTS.filter(isLogoPost));
// Mehrspalten-Index: `columns` geht als Liste in dieser Reihenfolge an Appwrite
const userLogo = newPosts.find((r) => r.body.key === 'user_logo');
assert.equal(userLogo.path, `${T}/logo_likes/indexes`);
assert.deepEqual(userLogo.body.columns, ['userId', 'logoId']);
assert.ok(fake.state.indexPosts.every((p) => p.allColumnsAvailable));
// Alles Bestehende wurde nur gelesen
assert.equal(fake.otherWrites().length, 0);
assert.equal(fake.state.columns.size, 31);
assert.equal(fake.state.indexes.size, 10);
// danach ist das Schema vollständig
const again = await run(fake, { check: true });
assert.equal(again.code, 0, again.lines.join('\n'));
});
});
test('Fehlende Indizes bei vorhandener Tabelle: --check meldet FEHLT, Anlegen legt nur sie an', async () => { test('Fehlende Indizes bei vorhandener Tabelle: --check meldet FEHLT, Anlegen legt nur sie an', async () => {
await withFake({}, async (fake) => { await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0); assert.equal((await run(fake)).code, 0);
@@ -887,6 +1083,41 @@ describe('main gegen Fake-Server', () => {
}); });
}); });
test('Abweichende Logo-Objekte werden gemeldet, aber nicht verändert (Exit 1)', async () => {
await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0);
fake.state.tables.get('aircard/logos').$permissions = ['read("any")'];
fake.state.tables.get('aircard/logo_reports').rowSecurity = true;
fake.state.columns.get('aircard/logos/name').size = 80;
fake.state.columns.get('aircard/logos/mono').default = true;
fake.state.columns.get('aircard/logos/likeCount').default = 1;
fake.state.columns.get('aircard/logo_likes/userId').required = false;
fake.state.indexes.get('aircard/logo_likes/user_logo').columns = ['logoId', 'userId'];
fake.state.buckets.get('logo-images').maximumFileSize = 5242880;
fake.state.buckets.get('logo-images').allowedFileExtensions = ['png', 'webp'];
const requestsBefore = fake.state.requests.length;
for (const check of [true, false]) {
const { code, lines } = await run(fake, { check });
assert.equal(code, 1);
assert.deepEqual(startsWith(lines, 'ABWEICHUNG'), [
'ABWEICHUNG: logos.$permissions ist [read("any")], erwartet []',
'ABWEICHUNG: logos.name.size ist 80, erwartet 40',
'ABWEICHUNG: logos.mono.default ist true, erwartet false',
'ABWEICHUNG: logos.likeCount.default ist 1, erwartet 0',
'ABWEICHUNG: logo_likes.userId.required ist false, erwartet true',
'ABWEICHUNG: logo_likes.user_logo.columns ist [logoId, userId], erwartet [userId, logoId]',
'ABWEICHUNG: logo_reports.rowSecurity ist true, erwartet false',
'ABWEICHUNG: logo-images.maximumFileSize ist 5242880, erwartet 1048576',
'ABWEICHUNG: logo-images.allowedFileExtensions ist [png, webp], erwartet [png]',
]);
assert.equal(startsWith(lines, 'ANGELEGT').length, 0);
assert.ok(!lines.includes('OK: Schema vollständig'));
}
assert.ok(fake.state.requests.slice(requestsBefore).every((r) => r.method === 'GET'));
});
});
test('Integer-Spalte mit riesigen Standard-min/max der Antwort gilt als in Ordnung', async () => { test('Integer-Spalte mit riesigen Standard-min/max der Antwort gilt als in Ordnung', async () => {
await withFake({}, async (fake) => { await withFake({}, async (fake) => {
assert.equal((await run(fake)).code, 0); assert.equal((await run(fake)).code, 0);
@@ -1069,6 +1300,10 @@ describe('CLI', () => {
assert.match(stdout, /FEHLT: Index gallery\.search_text/); assert.match(stdout, /FEHLT: Index gallery\.search_text/);
assert.match(stdout, /FEHLT: Tabelle reports/); assert.match(stdout, /FEHLT: Tabelle reports/);
assert.match(stdout, /FEHLT: Bucket gallery-images/); assert.match(stdout, /FEHLT: Bucket gallery-images/);
assert.match(stdout, /FEHLT: Tabelle logos/);
assert.match(stdout, /FEHLT: Index logo_likes\.user_logo/);
assert.match(stdout, /FEHLT: Tabelle logo_reports/);
assert.match(stdout, /FEHLT: Bucket logo-images/);
assert.ok(!(stdout + stderr).includes(KEY)); assert.ok(!(stdout + stderr).includes(KEY));
assert.equal(fake.posts().length, 0); assert.equal(fake.posts().length, 0);
}); });