diff --git a/web/scripts/appwrite-setup.mjs b/web/scripts/appwrite-setup.mjs index 2b6a100..a6a23f7 100644 --- a/web/scripts/appwrite-setup.mjs +++ b/web/scripts/appwrite-setup.mjs @@ -3,8 +3,9 @@ * AirCard – Appwrite-Schema-Setup (idempotent) * * Legt im selbst gehosteten Appwrite (Projekt `aircard`) die Datenbank `aircard`, - * die Tabellen `cards`, `gallery` und `reports` (TablesDB, mit Spalten und - * Indizes) und die Buckets `card-images` und `gallery-images` an. + * die Tabellen `cards`, `gallery`, `reports`, `logos`, `logo_likes` und + * `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+. * * Aufruf (vom Repo-Wurzelverzeichnis): @@ -40,7 +41,7 @@ const str = (key, size, required) => ({ key, type: 'string', size, required }); export const DESIRED = { 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: [ { id: 'cards', @@ -88,6 +89,54 @@ export const DESIRED = { ], 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("|"), 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: [ { @@ -110,6 +159,17 @@ export const DESIRED = { compression: 'none', 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, + }, ], }; diff --git a/web/scripts/appwrite-setup.test.mjs b/web/scripts/appwrite-setup.test.mjs index a2e14fd..b051490 100644 --- a/web/scripts/appwrite-setup.test.mjs +++ b/web/scripts/appwrite-setup.test.mjs @@ -236,6 +236,16 @@ async function run(fake, opts = {}) { 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], { @@ -260,10 +270,10 @@ describe('DESIRED', () => { 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', () => { + test('Reihenfolge: cards zuerst, dann gallery, reports, logos, logo_likes, logo_reports; Buckets card-images, gallery-images, logo-images', () => { assert.deepEqual(DESIRED.database, { id: 'aircard', name: 'AirCard' }); - assert.deepEqual(DESIRED.tables.map((t) => t.id), ['cards', 'gallery', 'reports']); - assert.deepEqual(DESIRED.buckets.map((b) => b.id), ['card-images', 'gallery-images']); + assert.deepEqual(DESIRED.tables.map((t) => t.id), ['cards', 'gallery', 'reports', 'logos', 'logo_likes', 'logo_reports']); + assert.deepEqual(DESIRED.buckets.map((b) => b.id), ['card-images', 'gallery-images', 'logo-images']); }); test('cards entspricht dem bisherigen Schema (unverändert)', () => { @@ -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', () => { for (const t of DESIRED.tables) { const keys = t.columns.map((c) => c.key); @@ -538,6 +608,18 @@ describe('compareIndex', () => { const { columns, ...none } = ok(); assert.deepEqual(compareIndex(none, desired), ['columns ist [], erwartet [searchText]']); }); + + test('Mehrspalten-Index (logo_likes.user_logo): die Reihenfolge der Spalten zählt', () => { + const userLogo = DESIRED.tables.find((t) => t.id === 'logo_likes').indexes.find((i) => i.key === 'user_logo'); + const okMulti = { key: 'user_logo', type: 'key', columns: ['userId', 'logoId'], lengths: [], orders: [], status: 'available' }; + assert.deepEqual(compareIndex(okMulti, userLogo), []); + assert.deepEqual(compareIndex({ ...okMulti, columns: ['logoId', 'userId'] }, userLogo), [ + 'columns ist [logoId, userId], erwartet [userId, logoId]', + ]); + assert.deepEqual(compareIndex({ ...okMulti, columns: ['userId'] }, userLogo), [ + 'columns ist [userId], erwartet [userId, logoId]', + ]); + }); }); describe('compareBucket', () => { @@ -588,6 +670,21 @@ describe('compareBucket', () => { '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.reporterHash', 'Index reports.gallery', + 'Tabelle logos', + 'Spalte logos.name', + 'Spalte logos.displayName', + 'Spalte logos.fileId', + 'Spalte logos.ownerId', + 'Spalte logos.mono', + 'Spalte logos.likeCount', + 'Spalte logos.hidden', + 'Spalte logos.reportCount', + 'Index logos.owner', + 'Index logos.hidden', + 'Index logos.likes', + 'Tabelle logo_likes', + 'Spalte logo_likes.logoId', + 'Spalte logo_likes.userId', + 'Index logo_likes.logo', + 'Index logo_likes.user_logo', + 'Tabelle logo_reports', + 'Spalte logo_reports.logoId', + 'Spalte logo_reports.reason', + 'Spalte logo_reports.reporterHash', + 'Index logo_reports.logo', 'Bucket card-images', 'Bucket gallery-images', + 'Bucket logo-images', ]; - 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 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), [`${T}/reports/indexes`, { key: 'gallery', type: 'key', columns: ['galleryId'] }], + [T, { tableId: 'logos', name: 'Logos', permissions: [], rowSecurity: true }], + str('logos', 'name', 40, true), + str('logos', 'displayName', 30, false), + str('logos', 'fileId', 36, true), + str('logos', 'ownerId', 36, true), + [`${T}/logos/columns/boolean`, { key: 'mono', required: false, default: false }], + [`${T}/logos/columns/integer`, { key: 'likeCount', required: false, default: 0, min: 0 }], + [`${T}/logos/columns/boolean`, { key: 'hidden', required: false, default: false }], + [`${T}/logos/columns/integer`, { key: 'reportCount', required: false, default: 0, min: 0 }], + [`${T}/logos/indexes`, { key: 'owner', type: 'key', columns: ['ownerId'] }], + [`${T}/logos/indexes`, { key: 'hidden', type: 'key', columns: ['hidden'] }], + [`${T}/logos/indexes`, { key: 'likes', type: 'key', columns: ['likeCount'] }], + [T, { tableId: 'logo_likes', name: 'Logo Likes', permissions: [], rowSecurity: true }], + str('logo_likes', 'logoId', 36, true), + str('logo_likes', 'userId', 36, true), + [`${T}/logo_likes/indexes`, { key: 'logo', type: 'key', columns: ['logoId'] }], + // Mehrspalten-Index: `columns` geht als Liste in dieser Reihenfolge an Appwrite + [`${T}/logo_likes/indexes`, { key: 'user_logo', type: 'key', columns: ['userId', 'logoId'] }], + [T, { tableId: 'logo_reports', name: 'Logo Reports', permissions: ['read("label:admin")'], rowSecurity: false }], + str('logo_reports', 'logoId', 36, true), + [`${T}/logo_reports/columns/enum`, { + key: 'reason', elements: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true, + }], + str('logo_reports', 'reporterHash', 64, true), + [`${T}/logo_reports/indexes`, { key: 'logo', type: 'key', columns: ['logoId'] }], ['/v1/storage/buckets', { bucketId: 'card-images', name: 'Card Images', permissions: ['create("users")'], fileSecurity: true, maximumFileSize: 5242880, allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'], @@ -668,16 +815,22 @@ describe('main gegen Fake-Server', () => { maximumFileSize: 5242880, allowedFileExtensions: ['png', 'webp'], compression: 'none', antivirus: false, }], + ['/v1/storage/buckets', { + bucketId: 'logo-images', name: 'Logo Images', permissions: ['create("users")'], fileSecurity: true, + maximumFileSize: 1048576, allowedFileExtensions: ['png'], + compression: 'none', antivirus: false, + }], ]; + const isLogoPost = ([path, body]) => /\/tables\/logo/.test(path) || /^logo/.test(body.tableId ?? body.bucketId ?? ''); - test('--check auf leerem Projekt: Exit 1, alle 28 Objekte FEHLT, nichts angelegt', async () => { + test('--check auf leerem Projekt: Exit 1, alle 51 Objekte FEHLT, nichts angelegt', async () => { await withFake({}, async (fake) => { const { code, lines } = await run(fake, { check: true }); assert.equal(code, 1); - assert.equal(EXPECTED_OBJECTS.length, 28); + assert.equal(EXPECTED_OBJECTS.length, 51); assert.deepEqual(startsWith(lines, 'FEHLT'), EXPECTED_OBJECTS.map((o) => `FEHLT: ${o}`)); assert.equal(startsWith(lines, 'OK').length, 0); - assert.ok(lines.includes('UNVOLLSTÄNDIG: 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.otherWrites().length, 0); }); @@ -706,13 +859,16 @@ describe('main gegen Fake-Server', () => { assert.ok(!first.lines.join('\n').includes(KEY)); // Alle Spalten und Indizes wurden vor dem Ende ausgewartet (Status available) - assert.equal(fake.state.columns.size, 18); - assert.equal(fake.state.indexes.size, 4); + assert.equal(fake.state.columns.size, 31); + assert.equal(fake.state.indexes.size, 10); for (const col of fake.state.columns.values()) assert.ok(col.gets >= 2, `Spalte ${col.key} nicht bis available geprüft`); for (const idx of fake.state.indexes.values()) assert.ok(idx.gets >= 2, `Index ${idx.key} nicht bis available geprüft`); // Indizes wurden erst angelegt, nachdem ALLE Spalten der Tabelle available waren - assert.deepEqual(fake.state.indexPosts.map((p) => `${p.table}.${p.key}`), ['gallery.search_text', 'gallery.owner', 'gallery.hidden', 'reports.gallery']); + assert.deepEqual(fake.state.indexPosts.map((p) => `${p.table}.${p.key}`), [ + 'gallery.search_text', 'gallery.owner', 'gallery.hidden', 'reports.gallery', + 'logos.owner', 'logos.hidden', 'logos.likes', 'logo_likes.logo', 'logo_likes.user_logo', 'logo_reports.logo', + ]); assert.ok(fake.state.indexPosts.every((p) => p.allColumnsAvailable), JSON.stringify(fake.state.indexPosts)); // Zweite Prüfung @@ -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) => { 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'); + 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'), 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); 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.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'); @@ -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 () => { await withFake({}, async (fake) => { 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 () => { await withFake({}, async (fake) => { 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: Tabelle reports/); assert.match(stdout, /FEHLT: Bucket gallery-images/); + assert.match(stdout, /FEHLT: Tabelle logos/); + assert.match(stdout, /FEHLT: Index logo_likes\.user_logo/); + assert.match(stdout, /FEHLT: Tabelle logo_reports/); + assert.match(stdout, /FEHLT: Bucket logo-images/); assert.ok(!(stdout + stderr).includes(KEY)); assert.equal(fake.posts().length, 0); });