- 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>
287 lines
10 KiB
JavaScript
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 }
|
|
}
|