'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('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('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({ rows: [], $id: 'x' }), { status: 200 }); }, }); await appwrite.getRow('gallery', 'x'); await appwrite.listRows('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, 9); 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)); } });