Files
Webklar-Kundenbereich/test/helpers/fakeAppwrite.js
T
ClaudeandClaude Opus 5.5 405b2a0485 Umzug: Webhook-Adresse aus config.url, Aufräumen auch nach Selbstheilung
Fix round 1 (Review C1/I1/I2/I3, Controller-Ruling R11):
- C1 (kritisch): Hooks haben in echtem Gitea kein Top-Level-url, nur config.url -
  isInternalRepo/deletePortalHooks lasen bisher ein Feld, das es in Produktion nicht gibt.
- I1: Schritt-2-Namensvergleich jetzt ohne Ruecksicht auf Gross-/Kleinschreibung.
- I2: fs.rm in Schritt 9 gegen EACCES/EBUSY/ENOTEMPTY & Co. abgesichert.
- I3: Aufraeumen (Cache, alter Einzel-Webhook, Preview-Verzeichnis) laeuft jetzt ueber den
  neuen idempotenten finishMove()-Baustein in jedem Fall, in dem das Repo tatsaechlich in
  WEBklar liegt - auch nach einem Appwrite-Fehler, bei Selbstheilung und bei einem schon
  dort liegenden Repo (WARN.hookNotRemoved bei fehlgeschlagenem Loeschen).
- Fake Gitea: Hook-Form ohne Top-Level-url, 301 mit relativer Location (auch fuer
  Unterpfade) wie im echten Gitea 1.25.2, failNext() fuer gezielte Fehler in Tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:21:34 +00:00

255 lines
8.4 KiB
JavaScript

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<string, object[]>} [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<string, string>, headers: Record<string, string>, body: any}>,
* failNext: (spec: {method: string, pathIncludes: string, status: number}) => void,
* close: () => Promise<void>,
* }>} `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 = []
/** @type {Map<string, Map<string, object>>} 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 handleCreateDocument(match, searchParams, body, headers, res) {
const map = collectionOf(match[2])
const documentId = body?.documentId || randomUUID()
if (map.has(documentId)) {
return send(res, 409, { message: 'Document with the requested ID 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' })
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)
try {
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 })
}
function close() {
for (const socket of sockets) socket.destroy()
return new Promise((resolve) => server.close(() => resolve()))
}
return { endpoint, all, get, calls, failNext, close }
}