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>
This commit is contained in:
1 parent
3a0b0089dd
commit
e43cdb667b
13 files changed
+498
-83
No files matched your search
@@ -28,6 +28,10 @@ import { randomUUID } from 'node:crypto'
|
||||
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()
|
||||
|
||||
@@ -114,11 +118,24 @@ export async function startFakeAppwrite(options = {}) {
|
||||
send(res, 200, { total, documents: result })
|
||||
}
|
||||
|
||||
function handleCreateDocument(match, searchParams, body, headers, res) {
|
||||
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)) {
|
||||
return send(res, 409, { message: 'Document with the requested ID already exists.' })
|
||||
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 || {}) }
|
||||
@@ -135,6 +152,9 @@ export async function startFakeAppwrite(options = {}) {
|
||||
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)
|
||||
@@ -212,11 +232,9 @@ export async function startFakeAppwrite(options = {}) {
|
||||
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' })
|
||||
}
|
||||
Promise.resolve()
|
||||
.then(() => route.handler(match, url.searchParams, parsedBody, req.headers, res))
|
||||
.catch((err) => send(res, 500, { message: err?.message || 'fakeAppwrite: interner Fehler' }))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -245,10 +263,24 @@ export async function startFakeAppwrite(options = {}) {
|
||||
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, close }
|
||||
return { endpoint, all, get, calls, failNext, setBeforeCreate, insert, close }
|
||||
}
|
||||
@@ -63,6 +63,7 @@ export async function startFakeGitea(options = {}) {
|
||||
let transferDelayMs = 0
|
||||
let responseDelayMs = 0
|
||||
let transferNoopNext = false
|
||||
let onGenerate = null
|
||||
const pendingFailures = []
|
||||
|
||||
let nextRepoId = 1
|
||||
@@ -229,6 +230,30 @@ export async function startFakeGitea(options = {}) {
|
||||
send(res, 200, serializeRepo(found.record))
|
||||
}
|
||||
|
||||
/**
|
||||
* Repo aus Vorlage erzeugen wie Gitea (`POST /repos/{tplOwner}/{tpl}/generate`): 409, wenn es das
|
||||
* Ziel schon gibt. `setOnGenerate(fn)` ruft fn(repo) nach dem Anlegen auf, BEVOR geantwortet
|
||||
* wird - so laesst sich der sofort feuernde Webhook `repository/created` nachstellen.
|
||||
*/
|
||||
async function handleGenerate(match, searchParams, body, headers, res) {
|
||||
const owner = body?.owner || ''
|
||||
const name = body?.name || ''
|
||||
if (!owner || !name) return send(res, 422, { message: 'owner und name fehlen' })
|
||||
if (repos.has(keyFrom(owner, name))) {
|
||||
return send(res, 409, { message: 'The repository with the same name already exists.' })
|
||||
}
|
||||
const record = addRepoInternal({ full_name: `${owner}/${name}`, description: body.description || '', private: body.private })
|
||||
if (onGenerate) await onGenerate(serializeRepo(record))
|
||||
send(res, 201, serializeRepo(record))
|
||||
}
|
||||
|
||||
function handleGetBranches(match, searchParams, body, headers, res) {
|
||||
const found = findRepoOrRedirect(match[1], match[2])
|
||||
if (!found) return send(res, 404, { message: 'Not Found' })
|
||||
if (found.redirectTo) return sendRepoRedirect(res, found.redirectTo, '/branches')
|
||||
send(res, 200, [{ name: found.record.default_branch || 'main' }])
|
||||
}
|
||||
|
||||
function handlePatchRepo(match, searchParams, body, headers, res) {
|
||||
const found = findRepoOrRedirect(match[1], match[2])
|
||||
if (!found) return send(res, 404, { message: 'Not Found' })
|
||||
@@ -419,6 +444,8 @@ export async function startFakeGitea(options = {}) {
|
||||
{ method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/hooks$/, handler: handleGetRepoHooks },
|
||||
{ method: 'DELETE', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/hooks\/([^/]+)$/, handler: handleDeleteRepoHook },
|
||||
{ method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/activities\/feeds$/, handler: handleFeeds },
|
||||
{ method: 'POST', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/generate$/, handler: handleGenerate },
|
||||
{ method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/branches$/, handler: handleGetBranches },
|
||||
{ method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/contents\/(.+)$/, handler: handleGetContents },
|
||||
{ method: 'POST', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/contents\/(.+)$/, handler: handleWriteContents },
|
||||
{ method: 'PUT', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/contents\/(.+)$/, handler: handleWriteContents },
|
||||
@@ -542,6 +569,10 @@ export async function startFakeGitea(options = {}) {
|
||||
pendingFailures.push({ method, pathIncludes, status })
|
||||
}
|
||||
|
||||
function setOnGenerate(fn) {
|
||||
onGenerate = fn || null
|
||||
}
|
||||
|
||||
function close() {
|
||||
for (const socket of sockets) socket.destroy()
|
||||
return new Promise((resolve) => server.close(() => resolve()))
|
||||
@@ -549,6 +580,7 @@ export async function startFakeGitea(options = {}) {
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
setOnGenerate,
|
||||
calls,
|
||||
repo,
|
||||
addRepo,
|
||||
|
||||
Reference in new issue
Block a user