feat(api): Logo-Endpunkte, Fehlercodes und gemeinsame Helfer

- common.js (aus app.js herausgelöst, Verhalten gleich): MSG, HttpError, Prüfungen, Rechte-Strings, Melder-Hash,
  Bildmaße und die Dateiprüfungen checkFileExists/checkFileOwnership. Neu: die Logo-Texte in MSG, codeFor,
  likeRowPermissions. app.js exportiert weiter alles wie bisher.
- Fehlerantworten sind jetzt {error, code}: code = MSG-Schlüssel des Textes, dynamische Prüftexte badRequest,
  Appwrite-Fehler unavailable (502) bzw. internal (500).
- logos.js: POST /api/logos/hochladen, /herz, /melden, /loeschen und /api/admin/logo-sichtbarkeit nach dem Vorbild der
  Karten-Endpunkte (Datei prüfen, Zeile anlegen, Datei auf read("any") festlegen, sonst Rücknahme; likeCount und
  reportCount immer aus countRows; ein gemeinsames Rate-Limit für Karten- und Logo-Meldungen).
- appwrite.js: countRows. fake-appwrite.js: Tabellen logos, logo_likes, logo_reports mit Spaltenlängen, Bucket
  logo-images und total in den Listen-Antworten.
- Tests: logos.test.js (59), code-Fälle und MSG-Eindeutigkeit in app.test.js (+7), countRows in appwrite.test.js (+2);
  alle 111 bisherigen Tests unverändert grün (179 gesamt).
- Doku: api/README.md (Endpunkte, Fehlercodes, Logo-Regeln), deploy/README.md (Betrieb kopiert fünf Dateien).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 19:20:25 +00:00
1 parent 88abc2061d
commit dc5de99611
10 files changed
+2181 -286

No files matched your search

+171
View File
@@ -9,6 +9,7 @@ const crypto = require('node:crypto');
const http = require('node:http');
const { createAppwrite, AppwriteError } = require('./appwrite.js');
const { MSG, HttpError, codeFor } = require('./common.js');
const { startFakeAppwrite, pngHeader, webpHeader } = require('./fake-appwrite.js');
const {
createApp,
@@ -1569,3 +1570,173 @@ test('createApp verlangt Appwrite-Objekt und Salz', () => {
assert.throws(() => createApp({ appwrite: {}, salt: '' }));
assert.throws(() => createApp({ appwrite: {} }));
});
// ---- Fehlercodes: jede Fehlerantwort ist {error, code} -------------------------------------------------
/** Erwartet Status und die Antwort {error: <Text des MSG-Schlüssels>, code: <Schlüssel>} (sonst nichts). */
function expectError(res, status, code, label = code) {
assert.equal(res.status, status, `${label}: Status`);
assert.deepEqual(res.json, { error: MSG[code], code }, label);
}
test('MSG: alle Texte sind paarweise verschieden (der Text bestimmt den Code), die Logo-Texte stehen wörtlich fest', () => {
const texts = Object.values(MSG);
assert.ok(texts.length > 0);
assert.equal(new Set(texts).size, texts.length, 'jeder Text gehört zu genau einem Schlüssel');
assert.ok(texts.every((text) => typeof text === 'string' && text !== ''));
const logoKeys = ['logoGone', 'badLogoId', 'logoNameMissing', 'logoFileType', 'logoFileSize', 'logoFormat', 'logoTaken', 'tooManyLogos', 'badHeart', 'badMono'];
assert.deepEqual(Object.fromEntries(logoKeys.map((key) => [key, MSG[key]])), {
logoGone: 'Dieses Logo ist nicht (mehr) verfügbar.',
badLogoId: 'Ungültige Logo-ID.',
logoNameMissing: 'Bitte gib einen Namen an.',
logoFileType: 'Das Logo muss eine PNG-Datei sein.',
logoFileSize: 'Das Logo ist größer als 1 MB.',
logoFormat: 'Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).',
logoTaken: 'Diese Datei wird schon für ein Logo verwendet.',
tooManyLogos: 'Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.',
badHeart: 'Ungültige Angabe zum Herz.',
badMono: 'Ungültige Angabe zu „einfarbig“.',
});
// die bisherigen Texte bleiben unverändert (Auszug)
assert.equal(MSG.cardGone, 'Diese Karte ist nicht (mehr) verfügbar.');
assert.equal(MSG.forbidden, 'Dazu hast du keine Berechtigung.');
assert.equal(MSG.tooManyReports, 'Zu viele Meldungen – bitte später nochmal.');
assert.equal(MSG.tooManyPublications, TOO_MANY_CARDS);
assert.equal(MSG.unavailable, 'Der Dienst ist gerade nicht erreichbar.');
assert.equal(MSG.internal, 'Interner Fehler.');
});
test('codeFor und HttpError: Code = Schlüssel des Textes, sonst badRequest (400) bzw. error', () => {
assert.equal(codeFor(404, MSG.cardGone), 'cardGone');
assert.equal(codeFor(404, MSG.logoGone), 'logoGone');
assert.equal(codeFor(429, MSG.tooManyLogos), 'tooManyLogos');
assert.equal(codeFor(500, MSG.internal), 'internal');
assert.equal(codeFor(400, 'Ungültige Bild-ID.'), 'badRequest', 'dynamischer Prüftext, Status 400');
assert.equal(codeFor(400, MSG.badRequest), 'badRequest');
assert.equal(codeFor(403, 'irgendein anderer Text'), 'error', 'dynamischer Text, anderer Status');
assert.equal(codeFor(409, undefined), 'error');
const withHeaders = new HttpError(429, MSG.tooManyReports, { 'Retry-After': '5' });
assert.ok(withHeaders instanceof Error);
assert.deepEqual([withHeaders.status, withHeaders.message, withHeaders.code, withHeaders.headers], [429, MSG.tooManyReports, 'tooManyReports', { 'Retry-After': '5' }]);
assert.deepEqual(new HttpError(404, MSG.notFound).headers, {});
assert.equal(new HttpError(400, 'Der Titel ist ungültig.').code, 'badRequest');
assert.equal(new HttpError(403, 'Der Titel ist ungültig.').code, 'error');
assert.equal(new HttpError(409, 'x', {}, 'eigener').code, 'eigener', 'ein ausdrücklicher Code gewinnt');
});
test('Fehlerantworten: Anfrage-Fehler (404, 413, 415, 400, 401) haben {error, code}', async () => {
const { call, post, report, fake } = await setup();
seedCard(fake, { id: 'card-9' });
const big = `{"id":"x","reason":"spam"}${' '.repeat(9000)}`;
expectError(await call('GET', '/api/gibts-nicht'), 404, 'notFound', 'unbekannte Route');
expectError(await call('GET', '/api/melden'), 404, 'notFound', 'falsche Methode');
expectError(await call('POST', '/api/melden', { raw: '{}', contentType: 'text/plain' }), 415, 'jsonOnly');
expectError(await call('POST', '/api/melden', { raw: big }), 413, 'tooLarge');
expectError(await call('POST', '/api/melden', { raw: '{kaputt' }), 400, 'badRequest', 'kaputtes JSON');
expectError(await call('POST', '/api/melden', { raw: '[]' }), 400, 'badRequest', 'Array statt Objekt');
expectError(await report('../x', '203.0.1.9'), 400, 'badCardId');
expectError(await report('card-9', '203.0.1.9', 'gibt-es-nicht'), 400, 'badReason');
expectError(await post('/api/veroeffentlichen', publishBody()), 401, 'unauthorized', 'veröffentlichen ohne JWT');
expectError(await post('/api/zurueckziehen', { id: 'card-9' }, 'xxx.yyy.zzz'), 401, 'unauthorized', 'zurückziehen mit falschem JWT');
expectError(await post('/api/admin/sichtbarkeit', { id: 'card-9', sichtbar: true }), 401, 'unauthorized', 'Admin ohne JWT');
});
test('Fehlerantworten: /api/melden mit unbekannter ID ist genau {error, code} aus der Spec', async () => {
const { report } = await setup();
const res = await report('gibts-nicht', '203.0.1.9');
assert.equal(res.status, 404);
assert.deepEqual(res.json, { error: 'Diese Karte ist nicht (mehr) verfügbar.', code: 'cardGone' });
});
test('Fehlerantworten: Logik-Fehler der Karten-Endpunkte tragen den MSG-Schlüssel, dynamische Prüftexte badRequest', async () => {
const { post, fake } = await setup();
seedCard(fake, { id: 'card-9', owner: OTHER });
seedUploads(fake);
expectError(await post('/api/zurueckziehen', { id: 'nope' }, JWT_ME), 404, 'cardGone');
expectError(await post('/api/zurueckziehen', { id: 'card-9' }, JWT_ME), 403, 'forbidden');
expectError(await post('/api/admin/sichtbarkeit', { id: 'card-9', sichtbar: true }, JWT_ME), 403, 'adminOnly');
expectError(await post('/api/admin/sichtbarkeit', { id: 'card-9', sichtbar: 'ja' }, JWT_ADMIN), 400, 'badVisibility');
expectError(await post('/api/admin/sichtbarkeit', { id: '../x', sichtbar: true }, JWT_ADMIN), 400, 'badCardId');
expectError(await post('/api/veroeffentlichen', publishBody({ sourceCardId: '../x' }), JWT_ME), 400, 'badCardId');
// Prüftexte mit Zahlen oder Feldnamen haben keinen festen Schlüssel: code badRequest, Text bleibt der genaue
for (const [overrides, text] of [
[{ title: '' }, 'Bitte gib einen Titel an.'],
[{ title: 'x'.repeat(61) }, 'Der Titel ist zu lang (höchstens 60 Zeichen).'],
[{ imageFileId: '../x' }, 'Ungültige Bild-ID.'],
[{ tags: ['a', 'b', 'c', 'd', 'e', 'f'] }, 'Bitte gib höchstens 5 Tags an.'],
[{ previewFileId: 'img1' }, 'Bild und Vorschau müssen verschiedene Dateien sein.'],
]) {
const res = await post('/api/veroeffentlichen', publishBody(overrides), JWT_ME);
assert.equal(res.status, 400, text);
assert.deepEqual(res.json, { error: text, code: 'badRequest' }, text);
}
});
test('Fehlerantworten: Datei-, Besitz- und Limit-Fehler beim Veröffentlichen tragen ihren Schlüssel', async () => {
const { post, report, fake } = await setup();
const attempt = (n, { image = {}, preview = {}, card = `card${n}` } = {}, overrides = {}) => {
fake.addFile({ id: `img${n}`, owner: ME, ...image });
fake.addFile({ id: `prev${n}`, owner: ME, mimeType: 'image/webp', ...preview });
if (card) fake.addCard(card, ME);
return post('/api/veroeffentlichen', publishBody({ imageFileId: `img${n}`, previewFileId: `prev${n}`, sourceCardId: `card${n}`, ...overrides }), JWT_ME);
};
expectError(await attempt(1, { image: { owner: OTHER } }), 403, 'foreignFile');
expectError(await attempt(2, { image: { bucket: 'card-images' } }), 403, 'foreignFile', 'falscher Bucket');
expectError(await attempt(3, {}, { previewFileId: 'ghost' }), 400, 'fileMissing');
expectError(await attempt(4, { image: { mimeType: 'image/jpeg' } }), 400, 'fileType');
expectError(await attempt(5, { image: { size: MIB5 + 1 } }), 400, 'fileSize');
expectError(await attempt(6, { image: { content: pngHeader(100, 100) } }), 400, 'imageFormat');
expectError(await attempt(7, { image: { permissions: ['delete("user:user-me")'] } }), 403, 'filesTaken');
expectError(await attempt(8, { card: null }), 403, 'notYourCard');
assert.equal((await attempt(9)).status, 201);
expectError(await attempt(10, {}, { sourceCardId: 'card9' }), 409, 'alreadyPublished');
seedPublications(fake, ME, 19); // mit der Karte oben sind es 20
expectError(await attempt(11), 429, 'tooManyPublications');
// Rate-Limit der Meldungen (die Karte `card9` ist sichtbar veröffentlicht)
const id = galleryRowId(ME, 'card9');
for (let i = 0; i < 10; i += 1) assert.equal((await report(id, '203.0.50.9')).status, 200);
const limited = await report(id, '203.0.50.9');
expectError(limited, 429, 'tooManyReports');
assert.equal(limited.headers['retry-after'], '3600');
});
test('Fehlerantworten: Appwrite-Fehler haben code unavailable (502) bzw. internal (500), unerwartete Fehler internal', async () => {
const { report, fake } = await setup();
seedCard(fake);
const isRowRead = (c) => c.method === 'GET' && c.path.includes('/tables/gallery/rows/');
fake.failNext(isRowRead, { status: 500 });
expectError(await report('card-1', '203.0.1.9'), 502, 'unavailable', '5xx');
fake.failNext(isRowRead, { status: 429, type: 'general_rate_limit_exceeded' });
expectError(await report('card-1', '203.0.1.9'), 502, 'unavailable', '429');
fake.failNext(isRowRead, { status: 404, type: 'table_not_found' });
expectError(await report('card-1', '203.0.1.9'), 500, 'internal', 'fehlende Tabelle');
fake.failNext(isRowRead, { status: 401, type: 'general_unauthorized_scope' });
expectError(await report('card-1', '203.0.1.9'), 500, 'internal', 'falscher Key');
const broken = {
accountFromJwt: async () => {
throw new Error('kaputt GEHEIM');
},
};
const app = createApp({ appwrite: broken, salt: SALT, log: () => {} });
const server = http.createServer(app);
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
cleanups.push(() => {
server.closeAllConnections();
return new Promise((resolve) => server.close(resolve));
});
const res = await request(server.address().port, 'POST', '/api/zurueckziehen', { body: { id: 'x' }, headers: { 'X-Aircard-Jwt': JWT_ME } });
expectError(res, 500, 'internal', 'unerwarteter Fehler');
assert.ok(!res.text.includes('GEHEIM'));
});