Files
ClaudeandClaude Opus 5.5 dc5de99611 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>
2026-10-04 19:20:25 +00:00

419 lines
21 KiB
JavaScript

'use strict';
// Tests für deploy/api/appwrite.js gegen einen Fake-Appwrite im Speicher (fake-appwrite.js).
// Aufruf: node --test "deploy/api/*.test.js"
const { test, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const http = require('node:http');
const { createAppwrite, AppwriteError, queryEqual, queryLimit, queryCursorAfter } = require('./appwrite.js');
const { startFakeAppwrite } = require('./fake-appwrite.js');
const fakes = [];
afterEach(async () => {
for (const fake of fakes.splice(0)) await fake.close();
});
async function setup(options = {}) {
const fake = await startFakeAppwrite();
fakes.push(fake);
const appwrite = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: fake.key, ...options });
return { fake, appwrite };
}
const lastCall = (fake) => fake.calls[fake.calls.length - 1];
test('Zeilen: anlegen, lesen, ändern, löschen mit Projekt- und Key-Header', async () => {
const { fake, appwrite } = await setup();
const created = await appwrite.createRow('gallery', 'row1', { title: 'T', imageFileId: 'i', previewFileId: 'p', ownerId: 'o' }, ['read("any")']);
assert.equal(created.$id, 'row1');
assert.equal(lastCall(fake).method, 'POST');
assert.equal(lastCall(fake).path, '/v1/tablesdb/aircard/tables/gallery/rows');
assert.deepEqual(lastCall(fake).body, {
rowId: 'row1',
data: { title: 'T', imageFileId: 'i', previewFileId: 'p', ownerId: 'o' },
permissions: ['read("any")'],
});
assert.equal(lastCall(fake).headers['x-appwrite-project'], fake.project);
assert.equal(lastCall(fake).headers['x-appwrite-key'], fake.key);
assert.match(lastCall(fake).headers['content-type'], /^application\/json/);
assert.equal((await appwrite.getRow('gallery', 'row1')).title, 'T');
assert.equal(lastCall(fake).path, '/v1/tablesdb/aircard/tables/gallery/rows/row1');
await appwrite.updateRow('gallery', 'row1', { hidden: true });
assert.equal(lastCall(fake).method, 'PATCH');
assert.deepEqual(lastCall(fake).body, { data: { hidden: true } }, 'ohne permissions-Feld, wenn keine übergeben werden');
assert.deepEqual(fake.row('gallery', 'row1').$permissions, ['read("any")']);
await appwrite.updateRow('gallery', 'row1', { reportCount: 3 }, ['read("user:o")']);
assert.deepEqual(lastCall(fake).body, { data: { reportCount: 3 }, permissions: ['read("user:o")'] });
assert.deepEqual(fake.row('gallery', 'row1').$permissions, ['read("user:o")']);
assert.equal(await appwrite.deleteRow('gallery', 'row1'), true);
assert.equal(fake.row('gallery', 'row1'), null);
});
test('Fehlende Zeile/Datei: getRow und getFile liefern null, deleteRow/deleteFile/updateFilePermissions sind tolerant', async () => {
const { appwrite } = await setup();
assert.equal(await appwrite.getRow('gallery', 'nope'), null);
assert.equal(await appwrite.getFile('gallery-images', 'nope'), null);
assert.equal(await appwrite.deleteRow('gallery', 'nope'), false);
assert.equal(await appwrite.deleteFile('gallery-images', 'nope'), false);
assert.equal(await appwrite.updateFilePermissions('gallery-images', 'nope', ['read("any")']), null);
});
test('Doppelte Zeilen-ID wirft AppwriteError mit Status 409 und Typ row_already_exists', async () => {
const { appwrite } = await setup();
const data = { galleryId: 'g', reason: 'spam', reporterHash: 'h' };
await appwrite.createRow('reports', 'r1', data, []);
await assert.rejects(
() => appwrite.createRow('reports', 'r1', data, []),
(err) => err instanceof AppwriteError && err.status === 409 && err.type === 'row_already_exists',
);
});
test('Andere Fehler (400, 401, 500) werden als AppwriteError geworfen', async () => {
const { fake, appwrite } = await setup();
await assert.rejects(
() => appwrite.createRow('gallery', 'x', { title: 'x'.repeat(61), imageFileId: 'i', previewFileId: 'p', ownerId: 'o' }, []),
(err) => err instanceof AppwriteError && err.status === 400 && err.type === 'document_invalid_structure',
);
fake.failNext((call) => call.method === 'GET', { status: 500, type: 'general_server_error' });
await assert.rejects(
() => appwrite.getRow('gallery', 'x'),
(err) => err instanceof AppwriteError && err.status === 500,
);
const wrongKey = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: 'falscher-key' });
await assert.rejects(
() => wrongKey.getRow('gallery', 'x'),
(err) => err instanceof AppwriteError && err.status === 401,
);
});
test('listRows schickt die Queries als queries[]-Parameter und liefert die Zeilen', async () => {
const { fake, appwrite } = await setup();
for (const id of ['a', 'b', 'c']) {
await appwrite.createRow('reports', id, { galleryId: id === 'c' ? 'other' : 'g1', reason: 'spam', reporterHash: id }, []);
}
const rows = await appwrite.listRows('reports', [queryEqual('galleryId', ['g1']), queryLimit(100)]);
assert.deepEqual(rows.map((row) => row.$id).sort(), ['a', 'b']);
const { search } = lastCall(fake);
const params = search.split('&').map((part) => decodeURIComponent(part));
assert.deepEqual(params, [
'queries[]={"method":"equal","attribute":"galleryId","values":["g1"]}',
'queries[]={"method":"limit","values":[100]}',
]);
assert.equal(lastCall(fake).headers['x-appwrite-key'], fake.key);
});
test('queryEqual, queryLimit und queryCursorAfter liefern Appwrite-JSON-Strings', () => {
assert.equal(queryEqual('galleryId', ['x']), '{"method":"equal","attribute":"galleryId","values":["x"]}');
assert.equal(queryLimit(100), '{"method":"limit","values":[100]}');
assert.equal(queryCursorAfter('abc'), '{"method":"cursorAfter","values":["abc"]}');
});
test('listRows blättert mit cursorAfter', async () => {
const { appwrite } = await setup();
for (const id of ['a', 'b', 'c', 'd', 'e']) {
await appwrite.createRow('reports', id, { galleryId: 'g1', reason: 'spam', reporterHash: id }, []);
}
const first = await appwrite.listRows('reports', [queryEqual('galleryId', ['g1']), queryLimit(2)]);
assert.deepEqual(first.map((row) => row.$id), ['a', 'b']);
const second = await appwrite.listRows('reports', [queryEqual('galleryId', ['g1']), queryLimit(2), queryCursorAfter('b')]);
assert.deepEqual(second.map((row) => row.$id), ['c', 'd']);
const last = await appwrite.listRows('reports', [queryEqual('galleryId', ['g1']), queryLimit(2), queryCursorAfter('d')]);
assert.deepEqual(last.map((row) => row.$id), ['e']);
});
test('countRows: liefert das total der Abfrage (nicht die Länge der Seite) und schickt limit(1) mit', async () => {
const { fake, appwrite } = await setup();
for (const id of ['a', 'b', 'c', 'd']) {
await appwrite.createRow('reports', id, { galleryId: id === 'd' ? 'other' : 'g1', reason: 'spam', reporterHash: id }, []);
}
assert.equal(await appwrite.countRows('reports', [queryEqual('galleryId', ['g1'])]), 3);
const call = lastCall(fake);
assert.equal(call.method, 'GET');
assert.equal(call.path, '/v1/tablesdb/aircard/tables/reports/rows');
assert.deepEqual(call.search.split('&').map((part) => decodeURIComponent(part)), [
'queries[]={"method":"equal","attribute":"galleryId","values":["g1"]}',
'queries[]={"method":"limit","values":[1]}',
]);
assert.equal(call.headers['x-appwrite-key'], fake.key);
assert.equal(await appwrite.countRows('reports', [queryEqual('galleryId', ['nix'])]), 0);
assert.equal(await appwrite.countRows('reports'), 4, 'ohne Abfragen zählt es die ganze Tabelle');
});
test('countRows: fehlendes oder ungültiges total ist ein AppwriteError (502, invalid_response), Appwrite-Fehler werden weitergereicht', async () => {
for (const payload of [{ rows: [] }, { total: '3', rows: [] }, { total: null, rows: [] }, { total: -1, rows: [] }, { total: 1.5, rows: [] }, [], 'text', null]) {
const appwrite = createAppwrite({
endpoint: 'http://appwrite.invalid/v1',
project: 'p',
key: 'k',
fetchImpl: async () => new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
await assert.rejects(
() => appwrite.countRows('reports', []),
(err) => err instanceof AppwriteError && err.type === 'invalid_response' && err.status === 502,
JSON.stringify(payload),
);
}
const zero = createAppwrite({ endpoint: 'http://appwrite.invalid/v1', project: 'p', key: 'k', fetchImpl: async () => new Response(JSON.stringify({ total: 0, rows: [] }), { status: 200 }) });
assert.equal(await zero.countRows('reports', []), 0, 'total 0 ist eine gültige Zahl');
const { fake, appwrite } = await setup();
fake.failNext((call) => call.method === 'GET', { status: 500, type: 'general_server_error' });
await assert.rejects(() => appwrite.countRows('reports', []), (err) => err instanceof AppwriteError && err.status === 500);
await assert.rejects(() => appwrite.countRows('keine-tabelle', []), (err) => err instanceof AppwriteError && err.status === 404 && err.type === 'table_not_found');
});
test('Dateien: lesen, Rechte setzen, löschen', async () => {
const { fake, appwrite } = await setup();
fake.addFile({ id: 'f1', owner: 'u1', mimeType: 'image/webp', size: 1234 });
const file = await appwrite.getFile('gallery-images', 'f1');
assert.equal(file.mimeType, 'image/webp');
assert.equal(file.sizeOriginal, 1234);
assert.equal(file.bucketId, 'gallery-images');
assert.deepEqual(file.$permissions, ['read("any")', 'delete("user:u1")']);
assert.equal(lastCall(fake).path, '/v1/storage/buckets/gallery-images/files/f1');
await appwrite.updateFilePermissions('gallery-images', 'f1', ['read("user:u1")']);
assert.equal(lastCall(fake).method, 'PUT');
assert.deepEqual(lastCall(fake).body, { permissions: ['read("user:u1")'] });
assert.deepEqual(fake.file('f1').$permissions, ['read("user:u1")']);
assert.equal(await appwrite.deleteFile('gallery-images', 'f1'), true);
assert.equal(fake.file('f1'), null);
});
test('readFileHead: liefert nur die ersten Bytes, mit Server-Key, auch bei einer großen Datei', async () => {
const { fake, appwrite } = await setup();
const content = Buffer.concat([Buffer.from('HEADBYTES'), Buffer.alloc(2 * 1024 * 1024, 7)]);
fake.addFile({ id: 'big', owner: 'u1', content, size: content.length });
const head = await appwrite.readFileHead('gallery-images', 'big', 9);
assert.ok(Buffer.isBuffer(head));
assert.equal(head.toString('latin1'), 'HEADBYTES');
assert.equal(lastCall(fake).method, 'GET');
assert.equal(lastCall(fake).path, '/v1/storage/buckets/gallery-images/files/big/view');
assert.equal(lastCall(fake).headers['x-appwrite-key'], fake.key);
const sixtyFour = await appwrite.readFileHead('gallery-images', 'big');
assert.equal(sixtyFour.length, 64, 'Standard: 64 Bytes');
fake.addFile({ id: 'small', owner: 'u1', content: Buffer.from('abc'), size: 3 });
assert.equal((await appwrite.readFileHead('gallery-images', 'small', 64)).toString(), 'abc', 'kürzere Datei: alles');
});
test('readFileHead: fehlende Datei ist null, andere Fehler werfen AppwriteError', async () => {
const { fake, appwrite } = await setup();
assert.equal(await appwrite.readFileHead('gallery-images', 'nope'), null);
fake.addFile({ id: 'f1', owner: 'u1' });
fake.failNext((c) => c.path.endsWith('/files/f1/view'), { status: 500 });
await assert.rejects(() => appwrite.readFileHead('gallery-images', 'f1'), (err) => err instanceof AppwriteError && err.status === 500);
const wrongKey = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: 'falscher-key' });
await assert.rejects(() => wrongKey.readFileHead('gallery-images', 'f1'), (err) => err instanceof AppwriteError && err.status === 401);
await assert.rejects(() => appwrite.readFileHead('gallery-images', '..'), TypeError);
});
test('accountFromJwt: JWT-Header ohne Key; 401 gibt null, andere Fehler werfen', async () => {
const { fake, appwrite } = await setup();
fake.addUser('aaa.bbb.ccc', { id: 'u1', labels: ['admin'] });
const user = await appwrite.accountFromJwt('aaa.bbb.ccc');
assert.equal(user.$id, 'u1');
assert.deepEqual(user.labels, ['admin']);
const call = lastCall(fake);
assert.equal(call.path, '/v1/account');
assert.equal(call.headers['x-appwrite-jwt'], 'aaa.bbb.ccc');
assert.equal(call.headers['x-appwrite-project'], fake.project);
assert.equal(call.headers['x-appwrite-key'], undefined, 'das Konto wird nie mit dem Server-Key abgefragt');
assert.equal(await appwrite.accountFromJwt('xxx.yyy.zzz'), null);
fake.failNext((c) => c.path === '/v1/account', { status: 500 });
await assert.rejects(() => appwrite.accountFromJwt('aaa.bbb.ccc'), (err) => err instanceof AppwriteError && err.status === 500);
});
test('Pfadsegmente: jede ID wird kodiert, "." und ".." werden abgelehnt, bevor eine Anfrage rausgeht', async () => {
const { fake, appwrite } = await setup();
await appwrite.getRow('gallery', '../x');
assert.equal(lastCall(fake).path, '/v1/tablesdb/aircard/tables/gallery/rows/..%2Fx');
await appwrite.getFile('gallery-images', 'a b/c');
assert.equal(lastCall(fake).path, '/v1/storage/buckets/gallery-images/files/a%20b%2Fc');
const before = fake.calls.length;
for (const bad of ['..', '.', '', 42, null, undefined]) {
await assert.rejects(() => appwrite.getRow('gallery', bad), /Pfadsegment/);
await assert.rejects(() => appwrite.deleteFile('gallery-images', bad), /Pfadsegment/);
}
await assert.rejects(() => appwrite.getRow('..', 'x'), /Pfadsegment/);
assert.equal(fake.calls.length, before, 'es ging keine Anfrage raus');
});
test('Zeitlimit: Appwrite-Aufrufe tragen standardmäßig AbortSignal.timeout(10000)', async () => {
let seenSignal = null;
const appwrite = createAppwrite({
endpoint: 'http://appwrite.invalid/v1',
project: 'p',
key: 'k',
fetchImpl: async (url, init) => {
seenSignal = init.signal;
return new Response(JSON.stringify({ $id: 'x' }), { status: 200, headers: { 'Content-Type': 'application/json' } });
},
});
await appwrite.getRow('gallery', 'x');
assert.ok(seenSignal instanceof AbortSignal);
assert.equal(seenSignal.aborted, false);
});
test('Zeitlimit: hängender Appwrite-Aufruf endet mit AppwriteError (timeout)', async () => {
const { fake, appwrite } = await setup({ timeoutMs: 80 });
fake.hangNext((call) => call.path.endsWith('/rows/slow'));
await assert.rejects(
() => appwrite.getRow('gallery', 'slow'),
(err) => err instanceof AppwriteError && err.type === 'timeout' && err.status === 0,
);
});
test('Netzfehler: AppwriteError (unreachable) ohne Key im Text', async () => {
const appwrite = createAppwrite({
endpoint: 'http://appwrite.invalid/v1',
project: 'p',
key: 'GEHEIMER-KEY-123',
fetchImpl: async () => {
throw new TypeError('fetch failed GEHEIMER-KEY-123');
},
});
await assert.rejects(
() => appwrite.getRow('gallery', 'x'),
(err) => err instanceof AppwriteError && err.type === 'unreachable' && !String(err.message).includes('GEHEIMER-KEY-123'),
);
});
test('404 gilt nur mit dem erwarteten Typ als „fehlt“, jedes andere 404 ist ein Fehler', async () => {
const { fake, appwrite } = await setup();
const rejectsWith404 = (promise, type) =>
assert.rejects(promise, (err) => err instanceof AppwriteError && err.status === 404 && err.type === type);
// erwartete Typen: fehlt
assert.equal(await appwrite.getRow('gallery', 'x'), null);
assert.equal(await appwrite.deleteRow('gallery', 'x'), false);
assert.equal(await appwrite.getFile('gallery-images', 'x'), null);
assert.equal(await appwrite.deleteFile('gallery-images', 'x'), false);
assert.equal(await appwrite.updateFilePermissions('gallery-images', 'x', []), null);
// unbekannte Tabelle / Datenbank / Bucket: Fehler
await rejectsWith404(appwrite.getRow('keine-tabelle', 'x'), 'table_not_found');
await rejectsWith404(appwrite.deleteRow('keine-tabelle', 'x'), 'table_not_found');
await rejectsWith404(appwrite.listRows('keine-tabelle', []), 'table_not_found');
await rejectsWith404(appwrite.getFile('kein-bucket', 'x'), 'storage_bucket_not_found');
await rejectsWith404(appwrite.updateFilePermissions('kein-bucket', 'x', []), 'storage_bucket_not_found');
await rejectsWith404(appwrite.deleteFile('kein-bucket', 'x'), 'storage_bucket_not_found');
const otherDatabase = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: fake.key, database: 'andere-db' });
await rejectsWith404(otherDatabase.getRow('gallery', 'x'), 'database_not_found');
// Typ von der falschen Art (Zeile statt Datei und umgekehrt) und unbekannte Route
fake.failNext((c) => c.method === 'GET', { status: 404, type: 'storage_file_not_found' });
await rejectsWith404(appwrite.getRow('gallery', 'x'), 'storage_file_not_found');
fake.failNext((c) => c.method === 'GET', { status: 404, type: 'row_not_found' });
await rejectsWith404(appwrite.getFile('gallery-images', 'x'), 'row_not_found');
fake.failNext((c) => c.method === 'GET', { status: 404, type: 'general_route_not_found' });
await rejectsWith404(appwrite.getRow('gallery', 'x'), 'general_route_not_found');
});
test('404 ohne JSON-Antwort (z. B. von einem Proxy) ist ein Fehler, kein „fehlt“', async () => {
const appwrite = createAppwrite({
endpoint: 'http://appwrite.invalid/v1',
project: 'p',
key: 'k',
fetchImpl: async () => new Response('404 page not found', { status: 404 }),
});
for (const call of [
() => appwrite.getRow('gallery', 'x'),
() => appwrite.getFile('gallery-images', 'x'),
() => appwrite.deleteRow('gallery', 'x'),
() => appwrite.deleteFile('gallery-images', 'x'),
() => appwrite.updateFilePermissions('gallery-images', 'x', []),
]) {
await assert.rejects(call, (err) => err instanceof AppwriteError && err.status === 404 && err.type === 'unknown');
}
});
test('listRows: eine Antwort ohne rows-Array wirft einen AppwriteError statt still [] zu liefern', async () => {
for (const payload of [{ total: 0 }, { rows: 'x' }, { rows: null }, [], 'text', null]) {
const appwrite = createAppwrite({
endpoint: 'http://appwrite.invalid/v1',
project: 'p',
key: 'k',
fetchImpl: async () => new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
await assert.rejects(
() => appwrite.listRows('reports', []),
(err) => err instanceof AppwriteError && err.type === 'invalid_response' && err.status === 502,
JSON.stringify(payload),
);
}
const empty = createAppwrite({
endpoint: 'http://appwrite.invalid/v1',
project: 'p',
key: 'k',
fetchImpl: async () => new Response(JSON.stringify({ total: 0, rows: [] }), { status: 200 }),
});
assert.deepEqual(await empty.listRows('reports', []), [], 'eine echte leere Liste bleibt eine leere Liste');
});
test('Alle Appwrite-Aufrufe verwenden redirect: "error" (der Key folgt keiner Weiterleitung)', async () => {
const seen = [];
const appwrite = createAppwrite({
endpoint: 'http://appwrite.invalid/v1',
project: 'p',
key: 'k',
fetchImpl: async (url, init) => {
seen.push(init.redirect);
return new Response(JSON.stringify({ total: 0, rows: [], $id: 'x' }), { status: 200 });
},
});
await appwrite.getRow('gallery', 'x');
await appwrite.listRows('gallery', []);
await appwrite.countRows('gallery', []);
await appwrite.createRow('gallery', 'x', {}, []);
await appwrite.updateRow('gallery', 'x', {});
await appwrite.deleteRow('gallery', 'x');
await appwrite.getFile('gallery-images', 'x');
await appwrite.updateFilePermissions('gallery-images', 'x', []);
await appwrite.deleteFile('gallery-images', 'x');
await appwrite.accountFromJwt('a.b.c');
assert.equal(seen.length, 10);
assert.ok(seen.every((value) => value === 'error'));
});
test('Eine Weiterleitung von Appwrite wird nicht verfolgt (echter Server antwortet 302)', async () => {
const target = http.createServer((req, res) => {
target.hits = (target.hits || 0) + 1;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end('{}');
});
await new Promise((resolve) => target.listen(0, '127.0.0.1', resolve));
const redirector = http.createServer((req, res) => {
res.writeHead(302, { Location: `http://127.0.0.1:${target.address().port}/gesammelt` });
res.end();
});
await new Promise((resolve) => redirector.listen(0, '127.0.0.1', resolve));
try {
const appwrite = createAppwrite({ endpoint: `http://127.0.0.1:${redirector.address().port}/v1`, project: 'p', key: 'GEHEIMER-KEY' });
await assert.rejects(() => appwrite.getRow('gallery', 'x'), (err) => err instanceof AppwriteError && err.type === 'unreachable');
assert.equal(target.hits || 0, 0, 'das Ziel der Weiterleitung wurde nie angefragt');
} finally {
redirector.closeAllConnections();
target.closeAllConnections();
await new Promise((resolve) => redirector.close(resolve));
await new Promise((resolve) => target.close(resolve));
}
});