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:
ClaudeandClaude Opus 5.5 committed 2026-10-04 13:22:53 +00:00
1 parent 3a0b0089dd
commit e43cdb667b
13 files changed
+498 -83

No files matched your search

+5 -1
View File
@@ -65,7 +65,11 @@ test('importNewRepos legt nur fehlende an', async () => {
const neu = aw.all('websiteProjects').find((d) => d.repoFullName === 'JUSN/neu')
assert.deepEqual([neu.projectName, neu.status, neu.subdomain || '', neu.previewUrl || ''], ['Döner Nation', 'synced', '', ''])
assert.ok(aw.all('websiteProjects').some((d) => d.repoFullName === 'knso/privat'))
assert.ok(!aw.all('websiteProjects').some((d) => d.repoFullName === 'JUSN/archiv'))
// In Gitea archivierte Repos kommen als archiviertes Projekt an (sichtbar unter "Archiviert"),
// aber nicht in createdRepos (keine Zuordnungs-Mail)
const archiv = aw.all('websiteProjects').find((d) => d.repoFullName === 'JUSN/archiv')
assert.deepEqual([archiv.archived, archiv.status], [true, 'archived'])
assert.equal(r.archivedImported, 1)
assert.equal(aw.calls.filter((c) => c.method === 'PATCH').length, 0)
})
test('importNewRepos läuft nie doppelt', async () => {
+41 -9
View File
@@ -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 }
}
+32
View File
@@ -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,
+233
View File
@@ -0,0 +1,233 @@
// Ablauf Projekt anlegen / archivieren / Import im Ticketsystem (Fehler aus dem Live-Durchlauf 2026-10-04):
// - "Neues Projekt" ohne Subdomain schrieb subdomain '' -> Unique-Index -> "Document with the requested ID already exists"
// - Wettlauf mit dem Webhook repository/created beim Anlegen
// - Reaktivieren liess status 'archived' stehen
// - archivierte Repos kamen nie ins Ticketsystem, Archiv-Stand aus Gitea nie an
// - "Gitea-Repos synchronisieren" ueberschrieb Projektnamen
import { test, before, after } from 'node:test'
import assert from 'node:assert/strict'
import { startFakeGitea } from './helpers/fakeGitea.js'
import { startFakeAppwrite } from './helpers/fakeAppwrite.js'
let gitea
let aw
let config
let svc
let handleRepositoryCreated
let upsertWebsiteProjectByRepo
const docs = () => aw.all('websiteProjects')
const docOf = (name) => docs().find((d) => String(d.repoFullName).toLowerCase() === name.toLowerCase())
before(async () => {
gitea = await startFakeGitea({
repos: [
{ full_name: 'knso/webklar-preview-template' },
{ full_name: 'knso/gradient-component' },
{ full_name: 'WEBklar/ohne-site' },
{ full_name: 'WEBklar/mit-site' },
{ full_name: 'WEBklar/D-ner-Nation', description: 'D-ner-Nation' },
],
})
aw = await startFakeAppwrite({
unique: { websiteProjects: ['repoFullName', 'subdomain'] },
seed: {
websiteProjects: [
// wie live: ein altes Projekt traegt subdomain '' (danach scheiterte jedes weitere ohne Website)
{ repoFullName: 'knso/gradient-component', projectName: 'gradient component', subdomain: '' },
{ $id: 'p_ohne', repoFullName: 'WEBklar/ohne-site', projectName: 'Ohne Site', status: 'created' },
{ $id: 'p_mit', repoFullName: 'WEBklar/mit-site', projectName: 'Mit Site', subdomain: 'mit-site', status: 'deployed' },
{ $id: 'p_doener', repoFullName: 'WEBklar/D-ner-Nation', projectName: 'Döner Nation', subdomain: 'doener-nation' },
],
customers: [{ $id: 'c1', name: 'Kunde' }],
},
})
Object.assign(process.env, {
GITEA_BASE_URL: gitea.baseUrl,
GITEA_API_TOKEN: 'test-token',
GITEA_DEFAULT_OWNER: 'WEBklar',
GITEA_TEMPLATE_OWNER: 'knso',
GITEA_TEMPLATE_REPO: 'webklar-preview-template',
APPWRITE_ENDPOINT: aw.endpoint,
APPWRITE_API_KEY: 'test-key',
PREVIEW_DEPLOY_ROOT: '',
TRAEFIK_DYNAMIC_DIR: '',
})
;({ config } = await import('../server/config.js'))
svc = await import('../server/services/giteaAdmin.js')
;({ handleRepositoryCreated } = await import('../server/services/repoAssignJob.js'))
;({ upsertWebsiteProjectByRepo } = await import('../server/services/appwriteAdmin.js'))
})
after(async () => {
await gitea.close()
await aw.close()
})
test('Neues Projekt ohne Subdomain: kein subdomain-Konflikt mehr, Kunde bleibt dran', async () => {
const r = await svc.createProjectFromTemplate({ displayName: 'Zweites ohne Website', customerId: 'c1' })
assert.equal(r.repoFullName, 'WEBklar/zweites-ohne-website')
assert.equal(r.projectType, 'repo')
const d = docOf('WEBklar/zweites-ohne-website')
assert.equal(d.subdomain, null)
assert.equal(d.customerId, 'c1')
assert.equal(d.projectName, 'Zweites ohne Website')
// Gitea-Beschreibung = Anzeigename (Abgleich/Webhook lesen den Namen von dort)
const gen = gitea.calls.find((c) => c.method === 'POST' && c.path.endsWith('/generate'))
assert.equal(gen.body.description, 'Zweites ohne Website')
})
test('Neues Projekt: Webhook repository/created legt das Dokument zuerst an -> eines, mit Kunde und Ticket', async () => {
gitea.setOnGenerate(async (repo) => {
await handleRepositoryCreated({ action: 'created', repository: repo, sender: { login: 'webklar-system' } }, {
sendMail: async () => ({ sent: false }),
})
})
try {
const r = await svc.createProjectFromTemplate({ displayName: 'Webhook zuerst', customerId: 'c1', ticketId: 't1' })
const all = docs().filter((d) => d.repoFullName === r.repoFullName)
assert.equal(all.length, 1)
assert.equal(all[0].customerId, 'c1')
assert.equal(all[0].ticketId, 't1')
assert.equal(all[0].projectType, 'repo')
assert.equal(all[0].createdByGitea, 'webklar-system')
} finally {
gitea.setOnGenerate(null)
}
})
test('Wettlauf zwischen Suche und Anlage: 409 wird zum Update statt zur Fehlermeldung', async () => {
let raced = false
aw.setBeforeCreate((col, data) => {
if (col !== 'websiteProjects' || raced || data.repoFullName !== 'WEBklar/wettlauf') return
raced = true
aw.insert('websiteProjects', { repoFullName: 'WEBklar/wettlauf', projectName: 'wettlauf', status: 'synced' })
})
try {
const d = await upsertWebsiteProjectByRepo('WEBklar/wettlauf', { repoFullName: 'WEBklar/wettlauf', customerId: 'c1', projectName: 'Wettlauf' })
assert.ok(raced)
assert.equal(d.customerId, 'c1')
assert.equal(docs().filter((x) => x.repoFullName === 'WEBklar/wettlauf').length, 1)
} finally {
aw.setBeforeCreate(null)
}
})
test('Neues Projekt mit Art (Ticket): kind gesetzt; unbekannte Art -> 400 ohne Repo', async () => {
await svc.createProjectFromTemplate({ displayName: 'Mit Art', customerId: 'c1', ticketId: 't2', kind: 'website', staffName: 'Kenso' })
const d = docOf('WEBklar/mit-art')
assert.deepEqual([d.kind, d.forCustomer, d.kindSetBy, d.previewMode], ['website', true, 'Kenso', 'auto'])
assert.ok(d.kindSetAt)
const before = gitea.calls.filter((c) => c.path.endsWith('/generate')).length
await assert.rejects(svc.createProjectFromTemplate({ displayName: 'Falsch', kind: 'quatsch' }), (err) => err.status === 400)
assert.equal(gitea.calls.filter((c) => c.path.endsWith('/generate')).length, before)
})
test('Typ "Internes Projekt" schreibt null statt leerer Subdomain', async () => {
const r = await svc.updateWebsiteProject('p_mit', { projectType: 'project' })
assert.equal(r.subdomain, '')
assert.equal(aw.get('websiteProjects', 'p_mit').subdomain, null)
// fuer die folgenden Tests wieder als Website
await svc.updateWebsiteProject('p_mit', { projectType: 'preview', subdomain: 'mit-site' })
assert.equal(aw.get('websiteProjects', 'p_mit').subdomain, 'mit-site')
})
test('Archivieren und Reaktivieren ohne Website: status geht zurueck auf synced', async () => {
await svc.archiveRepo('p_ohne', true)
assert.deepEqual([aw.get('websiteProjects', 'p_ohne').archived, aw.get('websiteProjects', 'p_ohne').status], [true, 'archived'])
assert.equal(gitea.repo('WEBklar/ohne-site').archived, true)
await svc.archiveRepo('p_ohne', false)
assert.deepEqual([aw.get('websiteProjects', 'p_ohne').archived, aw.get('websiteProjects', 'p_ohne').status], [false, 'synced'])
assert.equal(gitea.repo('WEBklar/ohne-site').archived, false)
})
test('Reaktivieren mit Website: status nicht mehr archived (pending bis der Deploy meldet)', async () => {
await svc.archiveRepo('p_mit', true)
assert.equal(aw.get('websiteProjects', 'p_mit').status, 'archived')
await svc.archiveRepo('p_mit', false)
const d = aw.get('websiteProjects', 'p_mit')
assert.equal(d.archived, false)
assert.equal(d.status, 'pending') // hier ohne PREVIEW_DEPLOY_ROOT: kein Deploy, also nicht 'deployed'
// Status vor dem Redeploy geschrieben, nicht danach (der Runner setzt spaeter selbst 'deployed')
const patches = aw.calls.filter((c) => c.method === 'PATCH' && c.path.endsWith('/p_mit'))
assert.equal(patches.at(-1).body.data.status, 'pending')
})
test('Abgleich ueberschreibt den Projektnamen nicht mehr', async () => {
await svc.syncReposToAppwrite()
assert.equal(aw.get('websiteProjects', 'p_doener').projectName, 'Döner Nation')
})
test('Import: archivierte Repos kommen als archiviert an (ohne Mail-Liste), Gitea-Archivstand wird gespiegelt, ein Fehler bricht nicht ab', async () => {
const g = await startFakeGitea({
repos: [
{ full_name: 'knso/neu-archiviert', archived: true, description: 'Alt' },
{ full_name: 'knso/kaputt' },
{ full_name: 'knso/neu-aktiv' },
{ full_name: 'knso/in-gitea-archiviert', archived: true },
{ full_name: 'knso/in-gitea-reaktiviert' },
],
})
const a = await startFakeAppwrite({
seed: {
websiteProjects: [
{ $id: 'x1', repoFullName: 'knso/in-gitea-archiviert', projectName: 'A', status: 'synced', archived: false },
{ $id: 'x2', repoFullName: 'knso/in-gitea-reaktiviert', projectName: 'B', status: 'archived', archived: true },
],
},
})
const original = { gitea: config.gitea.baseUrl, appwrite: config.appwrite.endpoint }
config.gitea.baseUrl = g.baseUrl
config.appwrite.endpoint = a.endpoint
const originalError = console.error
console.error = () => {}
try {
// erste Anlage (knso/kaputt kommt nach dem archivierten) schlaegt fehl
a.failNext({ method: 'POST', pathIncludes: '/collections/websiteProjects/documents', status: 400 })
const r = await svc.importNewRepos()
const byName = (n) => a.all('websiteProjects').find((d) => d.repoFullName === n)
// der erzwungene Fehler trifft die erste Anlage: das archivierte neue Repo
assert.deepEqual(r.failed, ['knso/neu-archiviert'])
assert.ok(byName('knso/kaputt'))
assert.ok(byName('knso/neu-aktiv'))
assert.deepEqual(r.createdRepos.map((x) => x.repo.full_name).sort(), ['knso/kaputt', 'knso/neu-aktiv'])
assert.equal(r.archiveSynced, 2)
assert.deepEqual([a.get('websiteProjects', 'x1').archived, a.get('websiteProjects', 'x1').status], [true, 'archived'])
assert.deepEqual([a.get('websiteProjects', 'x2').archived, a.get('websiteProjects', 'x2').status], [false, 'synced'])
// naechster Lauf holt das archivierte Repo nach - als archiviert, ohne Mail-Liste
const r2 = await svc.importNewRepos()
assert.equal(r2.archivedImported, 1)
assert.deepEqual(r2.createdRepos, [])
const arch = byName('knso/neu-archiviert')
assert.deepEqual([arch.archived, arch.status, arch.projectName], [true, 'archived', 'Alt'])
// dritter Lauf: nichts mehr zu tun
const r3 = await svc.importNewRepos()
assert.deepEqual([r3.created, r3.archivedImported, r3.archiveSynced, r3.failed.length], [0, 0, 0, 0])
} finally {
config.gitea.baseUrl = original.gitea
config.appwrite.endpoint = original.appwrite
console.error = originalError
await g.close()
await a.close()
}
})
test('Vorlage fuer "Neues Projekt" zieht beim Zuweisen nie um', async () => {
const { isInternalRepo } = await import('../server/services/repoMove.js')
assert.equal(await isInternalRepo('knso/webklar-preview-template'), true)
})
test('CORS erlaubt PUT (Preview-Variablen speichern)', async () => {
const { corsMiddleware } = await import('../server/middleware/cors.js')
const headers = {}
let status = 0
const req = { method: 'OPTIONS', get: (h) => (h === 'Origin' ? 'https://ticket.webklar.com' : undefined) }
const res = { setHeader: (k, v) => { headers[k] = v }, sendStatus: (s) => { status = s } }
corsMiddleware(['https://ticket.webklar.com'])(req, res, () => {})
assert.equal(status, 204)
assert.match(headers['Access-Control-Allow-Methods'], /\bPUT\b/)
})