Files
Webklar-Kundenbereich/test/helpers/fakeAppwrite.js
T
ClaudeandClaude Opus 5.5 e43cdb667b Projekt-Ablauf im Ticketsystem: Anlegen, Archivieren, Import repariert
- Neues Projekt ohne Subdomain: subdomain null statt '' (Unique-Index
  subdomain_unique, jedes weitere scheiterte mit "Document with the
  requested ID already exists"); ebenso Typ "Internes Projekt"
- Anlegen unter derselben Repo-Sperre wie der Webhook repository/created;
  upsertWebsiteProjectByRepo macht aus einem 409 ein Update
- Gitea-Beschreibung = Anzeigename; Art (kind) beim Anlegen aus dem Ticket
- Reaktivieren setzt status zurueck (vorher blieb 'archived' stehen)
- Import: archivierte Repos als archiviert, Archiv-Stand aus Gitea wird
  gespiegelt, ein fehlerhaftes Repo bricht den Lauf nicht mehr ab
- Abgleich ueberschreibt Projektnamen nicht mehr
- CORS erlaubt PUT (Preview-Variablen speichern)
- Vorlage zieht beim Zuweisen nie in die Org um
- Erinnerungs-Lauf ueberspringt archivierte Projekte
- kaputte Umlaute (U+FFFD) in drei Fehlermeldungen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:22:53 +00:00

287 lines
10 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 = []
// 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<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 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 }
}