import http from 'node:http' import { randomUUID } from 'node:crypto' /** * Nachgebautes Appwrite fuer Tests: nur der REST-Ausschnitt, den `appwriteAdmin.js` und * `appwriteStaff.js` nutzen (Dokumente einer Sammlung, `/account` per JWT). Laeuft auf * 127.0.0.1 mit einem vom Betriebssystem vergebenen Port; `endpoint` endet auf `/v1`, * genau wie `config.appwrite.endpoint`. * * @param {object} [options] * @param {Record} [options.seed] * Anfangsbestand je Sammlung (Sammlungsname -> Dokumente). Jedes Dokument darf `$id` * mitbringen (sonst wird eins erzeugt); `$createdAt`/`$updatedAt` werden sonst mit dem * Startzeitpunkt gefuellt. * @returns {Promise<{ * endpoint: string, * all: (collection: string) => object[], * get: (collection: string, id: string) => (object|undefined), * calls: Array<{method: string, path: string, query: Record, headers: Record, body: any}>, * failNext: (spec: {method: string, pathIncludes: string, status: number}) => void, * close: () => Promise, * }>} `all`/`get` lesen den LIVE-Stand (nach create/patch/delete ueber HTTP). Die * Datenbank-ID im Pfad wird nicht geprueft - jede wird akzeptiert. * `failNext({method, pathIncludes, status})` laesst GENAU die naechste Anfrage, deren Methode * passt und deren Pfad `pathIncludes` enthaelt, mit diesem Status und einer generischen * JSON-Fehlermeldung scheitern (danach wieder normales Verhalten). */ export async function startFakeAppwrite(options = {}) { const calls = [] const pendingFailures = [] // Optionale Unique-Indizes wie in Appwrite/MariaDB: { websiteProjects: ['repoFullName', 'subdomain'] }. // Strings vergleichen ohne Gross-/Kleinschreibung, null/undefined kollidieren nie, '' schon. const uniqueAttrs = options.unique || {} let beforeCreate = null /** @type {Map>} Sammlungsname -> (Dokument-ID -> Dokument). */ const store = new Map() function collectionOf(name) { let map = store.get(name) if (!map) { map = new Map() store.set(name, map) } return map } function all(col) { return [...collectionOf(col).values()] } function get(col, id) { return collectionOf(col).get(id) } function send(res, status, body) { if (body === undefined) { res.writeHead(status) res.end() return } const text = JSON.stringify(body) res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(text), }) res.end(text) } function parseQueries(searchParams) { return searchParams .getAll('queries[]') .map((s) => { try { return JSON.parse(s) } catch { return null } }) .filter(Boolean) } /* ------------------------------------------------------------- Routen --- */ function handleAccount(match, searchParams, body, headers, res) { if (headers['x-appwrite-jwt'] === 'jwt-admin') { return send(res, 200, { $id: 'staff1', labels: ['admin'] }) } send(res, 401, { message: 'Unauthorized' }) } function handleListDocuments(match, searchParams, body, headers, res) { const docs = [...collectionOf(match[2]).values()] const queries = parseQueries(searchParams) let result = docs.filter((doc) => queries.every((q) => { if (q.method === 'equal') return q.values.includes(doc[q.attribute]) if (q.method === 'greaterThan') return doc[q.attribute] > q.values[0] return true }) ) const orderAsc = queries.find((q) => q.method === 'orderAsc') const orderDesc = queries.find((q) => q.method === 'orderDesc') const sortAttr = orderAsc?.attribute || orderDesc?.attribute if (sortAttr) { const dir = orderAsc ? 1 : -1 result = [...result].sort((a, b) => { if (a[sortAttr] === b[sortAttr]) return 0 return a[sortAttr] > b[sortAttr] ? dir : -dir }) } const total = result.length const limit = queries.find((q) => q.method === 'limit') if (limit) result = result.slice(0, Number(limit.values[0])) send(res, 200, { total, documents: result }) } function uniqueConflict(col, data, ownId) { for (const attr of uniqueAttrs[col] || []) { const value = data?.[attr] if (value === null || value === undefined) continue const norm = (v) => (typeof v === 'string' ? v.toLowerCase() : v) for (const doc of collectionOf(col).values()) { if (doc.$id !== ownId && doc[attr] !== null && doc[attr] !== undefined && norm(doc[attr]) === norm(value)) return true } } return false } async function handleCreateDocument(match, searchParams, body, headers, res) { if (beforeCreate) await beforeCreate(match[2], body?.data || {}) const map = collectionOf(match[2]) const documentId = body?.documentId || randomUUID() if (map.has(documentId) || uniqueConflict(match[2], body?.data, documentId)) { return send(res, 409, { message: 'Document with the requested ID already exists.', type: 'document_already_exists' }) } const now = new Date().toISOString() const doc = { $id: documentId, $createdAt: now, $updatedAt: now, ...(body?.data || {}) } map.set(documentId, doc) send(res, 201, doc) } function handleGetDocument(match, searchParams, body, headers, res) { const doc = collectionOf(match[2]).get(match[3]) if (!doc) return send(res, 404, { message: 'Document not found' }) send(res, 200, doc) } function handlePatchDocument(match, searchParams, body, headers, res) { const doc = collectionOf(match[2]).get(match[3]) if (!doc) return send(res, 404, { message: 'Document not found' }) if (uniqueConflict(match[2], body?.data, doc.$id)) { return send(res, 409, { message: 'Document with the requested ID already exists.', type: 'document_already_exists' }) } Object.assign(doc, body?.data || {}) doc.$updatedAt = new Date().toISOString() send(res, 200, doc) } function handleDeleteDocument(match, searchParams, body, headers, res) { const map = collectionOf(match[2]) if (!map.has(match[3])) return send(res, 404, { message: 'Document not found' }) map.delete(match[3]) send(res, 204) } const ROUTES = [ { method: 'GET', re: /^\/v1\/account$/, handler: handleAccount }, { method: 'GET', re: /^\/v1\/databases\/([^/]+)\/collections\/([^/]+)\/documents$/, handler: handleListDocuments, }, { method: 'POST', re: /^\/v1\/databases\/([^/]+)\/collections\/([^/]+)\/documents$/, handler: handleCreateDocument, }, { method: 'GET', re: /^\/v1\/databases\/([^/]+)\/collections\/([^/]+)\/documents\/([^/]+)$/, handler: handleGetDocument, }, { method: 'PATCH', re: /^\/v1\/databases\/([^/]+)\/collections\/([^/]+)\/documents\/([^/]+)$/, handler: handlePatchDocument, }, { method: 'DELETE', re: /^\/v1\/databases\/([^/]+)\/collections\/([^/]+)\/documents\/([^/]+)$/, handler: handleDeleteDocument, }, ] /* --------------------------------------------------------- Server --- */ const sockets = new Set() const server = http.createServer((req, res) => { const chunks = [] req.on('data', (c) => chunks.push(c)) req.on('end', () => { const raw = Buffer.concat(chunks).toString('utf8') const url = new URL(req.url, 'http://fake-appwrite.local') let parsedBody = null if (raw) { try { parsedBody = JSON.parse(raw) } catch { parsedBody = null } } calls.push({ method: req.method, path: url.pathname, query: Object.fromEntries(url.searchParams), headers: { ...req.headers }, body: parsedBody, }) const failIdx = pendingFailures.findIndex( (f) => f.method === req.method && url.pathname.includes(f.pathIncludes) ) if (failIdx !== -1) { const [f] = pendingFailures.splice(failIdx, 1) return send(res, f.status, { message: `fakeAppwrite: erzwungener Fehler (${f.status})` }) } const route = ROUTES.find((r) => r.method === req.method && r.re.test(url.pathname)) if (!route) return send(res, 404, { message: 'Not Found' }) const match = route.re.exec(url.pathname) Promise.resolve() .then(() => route.handler(match, url.searchParams, parsedBody, req.headers, res)) .catch((err) => send(res, 500, { message: err?.message || 'fakeAppwrite: interner Fehler' })) }) }) server.on('connection', (socket) => { sockets.add(socket) socket.on('close', () => sockets.delete(socket)) }) await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) const { port } = server.address() const endpoint = `http://127.0.0.1:${port}/v1` /* --------------------------------------------------------- Seeding --- */ const seededAt = new Date().toISOString() for (const [col, docs] of Object.entries(options.seed || {})) { const map = collectionOf(col) for (const doc of docs || []) { const { $id, $createdAt, $updatedAt, ...fields } = doc const id = $id || randomUUID() map.set(id, { $id: id, $createdAt: $createdAt || seededAt, $updatedAt: $updatedAt || seededAt, ...fields }) } } function failNext({ method, pathIncludes, status }) { pendingFailures.push({ method, pathIncludes, status }) } /** fn(collection, data) laeuft vor jeder Anlage (z. B. um einen gleichzeitigen Schreiber nachzustellen). */ function setBeforeCreate(fn) { beforeCreate = fn || null } /** Dokument direkt in den Speicher legen (ohne HTTP), z. B. aus einem setBeforeCreate-Haken. */ function insert(col, doc) { const now = new Date().toISOString() const id = doc.$id || randomUUID() const full = { $createdAt: now, $updatedAt: now, ...doc, $id: id } collectionOf(col).set(id, full) return full } function close() { for (const socket of sockets) socket.destroy() return new Promise((resolve) => server.close(() => resolve())) } return { endpoint, all, get, calls, failNext, setBeforeCreate, insert, close } }