From e43cdb667b02b61e612861e567866ed63aabcfc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 13:22:53 +0000 Subject: [PATCH] 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 --- server/middleware/appwriteStaff.js | 2 +- server/middleware/cors.js | 2 +- server/routes/admin/websiteProjects.js | 4 +- server/services/appwriteAdmin.js | 39 +++-- server/services/customerAdmin.js | 2 +- server/services/giteaAdmin.js | 205 ++++++++++++++++------ server/services/giteaClient.js | 2 +- server/services/repoAssignJob.js | 2 +- server/services/repoMove.js | 2 + test/giteaAdmin.test.js | 6 +- test/helpers/fakeAppwrite.js | 50 +++++- test/helpers/fakeGitea.js | 32 ++++ test/projektAblauf.test.js | 233 +++++++++++++++++++++++++ 13 files changed, 498 insertions(+), 83 deletions(-) create mode 100644 test/projektAblauf.test.js diff --git a/server/middleware/appwriteStaff.js b/server/middleware/appwriteStaff.js index ba7df49..d9ca95e 100644 --- a/server/middleware/appwriteStaff.js +++ b/server/middleware/appwriteStaff.js @@ -29,7 +29,7 @@ export async function requireAppwriteStaff(req, res, next) { } if (!response.ok) { - return res.status(401).json({ error: data?.message || 'Ung�ltiges JWT' }) + return res.status(401).json({ error: data?.message || 'Ungültiges JWT' }) } if (!Array.isArray(data?.labels) || !data.labels.includes('admin')) { diff --git a/server/middleware/cors.js b/server/middleware/cors.js index b5562c2..deab6d5 100644 --- a/server/middleware/cors.js +++ b/server/middleware/cors.js @@ -7,7 +7,7 @@ export function corsMiddleware(allowedOrigins) { if (origin && allowedOrigins.includes(origin)) { res.setHeader('Access-Control-Allow-Origin', origin) res.setHeader('Access-Control-Allow-Headers', ALLOW_HEADERS) - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS') + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS') if (req.method === 'OPTIONS') { return res.sendStatus(204) } diff --git a/server/routes/admin/websiteProjects.js b/server/routes/admin/websiteProjects.js index a22bb46..f55aa04 100644 --- a/server/routes/admin/websiteProjects.js +++ b/server/routes/admin/websiteProjects.js @@ -13,7 +13,7 @@ const router = Router() router.use(requireAppwriteStaff) router.post('/create-from-template', async (req, res) => { - const { repoName, displayName, subdomain, customerId = '', ticketId = '', owner } = req.body || {} + const { repoName, displayName, subdomain, customerId = '', ticketId = '', owner, kind = '' } = req.body || {} if (!repoName && !displayName) { return res.status(400).json({ error: 'repoName oder displayName ist erforderlich' }) @@ -27,6 +27,8 @@ router.post('/create-from-template', async (req, res) => { customerId, ticketId, owner, + kind, + staffName: req.appwriteUser?.name || req.appwriteUser?.email || '', }) return res.status(201).json({ ok: true, ...result }) } catch (err) { diff --git a/server/services/appwriteAdmin.js b/server/services/appwriteAdmin.js index e7c14fb..9b585f3 100644 --- a/server/services/appwriteAdmin.js +++ b/server/services/appwriteAdmin.js @@ -300,19 +300,32 @@ export async function upsertWebsiteProjectByRepo(repoFullName, data) { } const now = new Date().toISOString() - return adminFetch(collectionPath(config.collections.websiteProjects), { - method: 'POST', - body: { - ...payload, - customerId: payload.customerId ?? '', - ticketId: payload.ticketId ?? '', - status: payload.status ?? 'pending', - provisioningStatus: payload.provisioningStatus ?? 'pending', - projectName: payload.projectName || repoFullName.split('/').pop() || repoFullName, - createdAt: now, - documentId: ID.unique(), - }, - }) + try { + return await adminFetch(collectionPath(config.collections.websiteProjects), { + method: 'POST', + body: { + ...payload, + customerId: payload.customerId ?? '', + ticketId: payload.ticketId ?? '', + status: payload.status ?? 'pending', + provisioningStatus: payload.provisioningStatus ?? 'pending', + projectName: payload.projectName || repoFullName.split('/').pop() || repoFullName, + createdAt: now, + documentId: ID.unique(), + }, + }) + } catch (err) { + // Ein anderer Schreiber ohne gemeinsame Sperre hat das Dokument zwischen Suche und Anlage + // angelegt: der Unique-Index auf repoFullName meldet 409. Dann das vorhandene Dokument + // aktualisieren, statt den Fehler bis in die Oberflaeche zu reichen. + if (err.status !== 409) throw err + const raced = await listDocuments(config.collections.websiteProjects, [ + Query.equal('repoFullName', repoFullName), + Query.limit(1), + ]) + if (!raced[0]) throw err + return updateDocument(config.collections.websiteProjects, raced[0].$id, buildProjectPayload(raced[0], data)) + } } export async function verifyDatabaseAccess() { diff --git a/server/services/customerAdmin.js b/server/services/customerAdmin.js index e91a689..dd67ec8 100644 --- a/server/services/customerAdmin.js +++ b/server/services/customerAdmin.js @@ -198,7 +198,7 @@ export async function updateCustomerWithPortalAccess(customerId, payload) { portalPassword = normalizedPassword } } else if (normalizedPassword) { - const error = new Error('Portal-Login fehlt � bitte E-Mail setzen und Passwort erneut speichern') + const error = new Error('Portal-Login fehlt – bitte E-Mail setzen und Passwort erneut speichern') error.status = 400 throw error } diff --git a/server/services/giteaAdmin.js b/server/services/giteaAdmin.js index 878333b..073ce05 100644 --- a/server/services/giteaAdmin.js +++ b/server/services/giteaAdmin.js @@ -18,6 +18,7 @@ import { giteaFetch } from './giteaClient.js' import { slugify } from './slug.js' import { withLocks } from './keyedLock.js' import { repoLockKey } from './repoMove.js' +import { isKind } from './projectKinds.js' // Sicherheitsnetz wie bei listRecentPushes (siehe pushHistory.js, Controller-Ruling R8): sollte // Gitea "page" ignorieren und immer dieselbe volle Seite liefern, blaettert die Schleife nicht @@ -49,12 +50,16 @@ export async function listAllAccessibleRepos() { /** * Findet Repos, die in Gitea liegen, aber noch kein Appwrite-Projektdokument haben, und legt fuer - * jedes nicht archivierte ein minimales Dokument an (status "synced", ohne Hosting/Subdomain) - - * ohne dass dafuer manuell "Abgleich" gedrueckt werden muesste. Wird vom Projekt-Dialog bei jedem + * jedes ein minimales Dokument an (status "synced", ohne Hosting/Subdomain; in Gitea archivierte + * als archiviertes Projekt) - ohne dass dafuer manuell "Abgleich" gedrueckt werden muesste. Bei + * vorhandenen Dokumenten wird nur der Archiv-Stand aus Gitea gespiegelt (Repo dort direkt + * archiviert oder reaktiviert). Wird vom Projekt-Dialog bei jedem * Oeffnen aufgerufen, laeuft also oft: die aeussere `withLocks(['import'])`-Sperre serialisiert * parallele Laeufe komplett (ein zweiter Aufruf sieht danach garantiert den Stand des ersten), die * innere `withLocks([repoLockKey(...)])` je neuem Repo schuetzt zusaetzlich gegen andere * gleichzeitige Schreiber desselben Projektdokuments (z. B. Umzug/Zuweisung). + * Ein Fehler bei einem Repo bricht den Lauf nicht ab (sonst fehlten alle Repos danach). + * `createdRepos` enthaelt nur neue, nicht archivierte Repos (nur sie bekommen Zuordnungs-Mails). */ export async function importNewRepos() { return withLocks(['import'], async () => { @@ -64,39 +69,71 @@ export async function importNewRepos() { ]) // Ohne Ruecksicht auf Gross-/Kleinschreibung vergleichen (Controller-Ruling). - const existingNames = new Set( - docs.map((d) => String(d.repoFullName || '').toLowerCase()).filter(Boolean) - ) - - let created = 0 - const createdRepos = [] - for (const repo of repos) { - const fullName = repo.full_name - if (!fullName || repo.archived) continue - if (existingNames.has(fullName.toLowerCase())) continue - - await withLocks([repoLockKey(fullName)], async () => { - const recheck = await listDocuments(config.collections.websiteProjects, [ - Query.equal('repoFullName', fullName), - Query.limit(1), - ]) - if (recheck[0]) return - - const doc = await upsertWebsiteProjectByRepo(fullName, { - projectName: repo.description || repo.name || fullName.split('/').pop(), - giteaRepoUrl: repo.html_url, - giteaRepoName: repo.name, - repoFullName: fullName, - archived: false, - status: 'synced', - giteaSyncedAt: new Date().toISOString(), - }) - created += 1 - createdRepos.push({ projectId: doc.$id, repo }) - }) + const docsByName = new Map() + for (const d of docs) { + const key = String(d.repoFullName || '').toLowerCase() + if (key && !docsByName.has(key)) docsByName.set(key, d) } - return { created, createdRepos } + let created = 0 + let archivedImported = 0 + let archiveSynced = 0 + const createdRepos = [] + const failed = [] + for (const repo of repos) { + const fullName = repo.full_name + if (!fullName) continue + const repoArchived = Boolean(repo.archived) + + try { + const existing = docsByName.get(fullName.toLowerCase()) + if (existing) { + if (Boolean(existing.archived) === repoArchived) continue + await withLocks([repoLockKey(fullName)], async () => { + const fresh = await getDocument(config.collections.websiteProjects, existing.$id) + if (!fresh || Boolean(fresh.archived) === repoArchived) return + await updateDocument(config.collections.websiteProjects, fresh.$id, { + archived: repoArchived, + ...(repoArchived + ? { status: 'archived' } + : fresh.status === 'archived' ? { status: 'synced' } : {}), + updatedAt: new Date().toISOString(), + }) + archiveSynced += 1 + }) + continue + } + + await withLocks([repoLockKey(fullName)], async () => { + const recheck = await listDocuments(config.collections.websiteProjects, [ + Query.equal('repoFullName', fullName), + Query.limit(1), + ]) + if (recheck[0]) return + + const doc = await upsertWebsiteProjectByRepo(fullName, { + projectName: repo.description || repo.name || fullName.split('/').pop(), + giteaRepoUrl: repo.html_url, + giteaRepoName: repo.name, + repoFullName: fullName, + archived: repoArchived, + status: repoArchived ? 'archived' : 'synced', + giteaSyncedAt: new Date().toISOString(), + }) + if (repoArchived) { + archivedImported += 1 + return + } + created += 1 + createdRepos.push({ projectId: doc.$id, repo }) + }) + } catch (err) { + failed.push(fullName) + console.error(`[importNewRepos] ${fullName} übersprungen:`, err.message) + } + } + + return { created, createdRepos, archivedImported, archiveSynced, failed } }) } @@ -104,6 +141,7 @@ export async function syncReposToAppwrite() { const repos = await listAllAccessibleRepos() let created = 0 let updated = 0 + const failed = [] for (const repo of repos) { const repoFullName = repo.full_name @@ -115,25 +153,38 @@ export async function syncReposToAppwrite() { ]) const payload = { - projectName: repo.description || repo.name || repoFullName.split('/').pop(), giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`, giteaRepoName: repo.name || repoFullName.split('/').pop(), repoFullName, // In Gitea archivierte Repos werden im Ticketsystem ausgeblendet. archived: Boolean(repo.archived), } + const repoTitle = repo.description || repo.name || repoFullName.split('/').pop() if (!existing[0]) { - payload.status = 'synced' + payload.projectName = repoTitle + payload.status = repo.archived ? 'archived' : 'synced' created += 1 } else { + // Der Name im Ticketsystem bleibt: frueher ueberschrieb der Abgleich ihn mit der + // Gitea-Beschreibung (aus "Döner Nation" wurde so "D-ner-Nation"). + if (!existing[0].projectName) payload.projectName = repoTitle + // Archiv-Stand aus Gitea uebernehmen, ohne dass status auf 'archived' haengen bleibt + if (repo.archived && existing[0].status !== 'archived') payload.status = 'archived' + if (!repo.archived && existing[0].status === 'archived') payload.status = 'synced' updated += 1 } - await upsertWebsiteProjectByRepo(repoFullName, payload) + try { + await upsertWebsiteProjectByRepo(repoFullName, payload) + } catch (err) { + // Ein fehlerhaftes Repo darf den Abgleich aller folgenden nicht abbrechen + failed.push(repoFullName) + console.error(`[syncReposToAppwrite] ${repoFullName} übersprungen:`, err.message) + } } - return { synced: repos.length, created, updated } + return { synced: repos.length, created, updated, failed } } export async function ensureSubdomainAvailable(subdomain) { @@ -148,7 +199,7 @@ export async function ensureSubdomainAvailable(subdomain) { } } -export async function generateRepoFromTemplate({ owner, repoName }) { +export async function generateRepoFromTemplate({ owner, repoName, description }) { const templateOwner = config.gitea.templateOwner const templateRepo = config.gitea.templateRepo const targetOwner = owner || config.gitea.defaultOwner @@ -163,7 +214,8 @@ export async function generateRepoFromTemplate({ owner, repoName }) { owner: targetOwner, name: repoName, private: false, - description: `Website-Projekt ${repoName}`, + // Die Beschreibung ist der Projektname in Gitea (Abgleich und Webhook lesen ihn von dort) + description: description || `Website-Projekt ${repoName}`, git_content: true, }), }) @@ -278,6 +330,8 @@ export async function createProjectFromTemplate({ customerId = '', ticketId = '', owner, + kind = '', + staffName = '', }) { const targetOwner = owner || config.gitea.defaultOwner const safeRepoName = slugify(repoName) || slugify(displayName) || slugify(subdomain) @@ -289,6 +343,11 @@ export async function createProjectFromTemplate({ if (!safeRepoName) { throw new Error('repoName oder displayName ist erforderlich') } + if (kind && !isKind(kind)) { + const error = new Error('Unbekannte Projektart.') + error.status = 400 + throw error + } if (hasWebsite) { await ensureSubdomainAvailable(safeSubdomain) @@ -297,6 +356,7 @@ export async function createProjectFromTemplate({ const repo = await generateRepoFromTemplate({ owner: targetOwner, repoName: safeRepoName, + description: displayName || safeRepoName, }) const repoFullName = repo.full_name || `${targetOwner}/${safeRepoName}` @@ -324,7 +384,11 @@ export async function createProjectFromTemplate({ } const previewUrl = hasWebsite ? buildPreviewUrl(safeSubdomain) : '' - const project = await upsertWebsiteProjectByRepo(repoFullName, { + // Gitea meldet das neue Repo sofort per Webhook (repository/created, dann der Push der Config). + // Der Webhook legt das Dokument unter dieser Repo-Sperre an; ohne dieselbe Sperre legen beide + // gleichzeitig an und der Unique-Index auf repoFullName lehnt dieses hier ab ("Document with + // the requested ID already exists") - das Repo gab es dann schon, Kunde und Ticket fehlten. + const project = await withLocks([repoLockKey(repoFullName)], () => upsertWebsiteProjectByRepo(repoFullName, { customerId, ticketId, projectName: previewConfig.displayName, @@ -332,9 +396,23 @@ export async function createProjectFromTemplate({ giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`, giteaRepoName: repo.name || safeRepoName, repoFullName, - subdomain: safeSubdomain, + // Ohne Website null statt '': auf subdomain liegt ein Unique-Index, und ein zweites + // Projekt mit '' scheiterte mit "Document with the requested ID already exists". + subdomain: safeSubdomain || null, previewUrl, - projectType: hasWebsite ? 'website' : 'repo', + // Login-geschuetzte Preview (isPublic bleibt aus) - wie deriveProjectType und das Ticketsystem + projectType: hasWebsite ? 'preview' : 'repo', + // Art direkt mitgeben (Ticket: "Neues Projekt" = Website) - sonst stand das Projekt auf + // "Art fehlt" und der Zuordnungs-Lauf mahnte es an. Felder wie completeAssignment. + ...(kind + ? { + kind, + forCustomer: Boolean(customerId), + kindSetBy: staffName || '', + kindSetAt: new Date().toISOString(), + previewMode: kind === 'website' || kind === 'webapp' ? 'auto' : 'off', + } + : {}), ...(hasWebsite && deployKeepsStatus(deployResult) ? {} // Preview-Runner (C1): Status setzt der Verfolger : { @@ -343,14 +421,14 @@ export async function createProjectFromTemplate({ ? (deployResult.deployed ? 'ready' : 'deploy_failed') : 'ready', }), - }) + }), { waitMs: 10000 }) return { projectId: project.$id, repoFullName, previewUrl, subdomain: safeSubdomain, - projectType: hasWebsite ? 'website' : 'repo', + projectType: hasWebsite ? 'preview' : 'repo', deploy: deployResult, } } @@ -494,7 +572,7 @@ export async function updateWebsiteProject(projectId, { projectName, subdomain, const previewUrl = hasWebsite && nextSubdomain ? buildPreviewUrl(nextSubdomain) : '' const updated = await updateDocument(config.collections.websiteProjects, projectId, { projectName: nextName, - subdomain: nextSubdomain, + subdomain: nextSubdomain || null, // null statt '' (Unique-Index, siehe createProjectFromTemplate) previewUrl, projectType: nextType, isPublic, @@ -573,7 +651,26 @@ export async function archiveRepo(projectId, archived = true) { } } - if (!archived && !repoMissing && doc.subdomain) { + if (archived) { + const updated = await updateDocument(config.collections.websiteProjects, projectId, { + archived: true, + status: 'archived', + updatedAt: new Date().toISOString(), + }) + return { project: updated, archived: true } + } + + // Reaktivieren: status 'archived' zuruecksetzen, und zwar VOR dem Redeploy - der Preview-Runner + // setzt nach dem Bau selbst 'deployed', das darf hier danach nicht ueberschrieben werden. + // Blieb 'archived' stehen, bot das Ticket das Projekt unter "Projekt wählen" nicht mehr an. + const redeploy = !repoMissing && Boolean(doc.subdomain) + let updated = await updateDocument(config.collections.websiteProjects, projectId, { + archived: false, + status: redeploy ? 'pending' : 'synced', + updatedAt: new Date().toISOString(), + }) + + if (redeploy) { try { const branch = (await getRepoInfo(repoFullName))?.defaultBranch || 'main' const cfg = (await fetchPreviewConfig(repoFullName, branch)) || { @@ -583,19 +680,19 @@ export async function archiveRepo(projectId, archived = true) { subdomain: doc.subdomain, displayName: doc.projectName, } - await runDeploy(repoFullName, branch, cfg, { project: doc }) + const deployResult = await runDeploy(repoFullName, branch, cfg, { project: doc }) + if (deployResult?.deployed) { + updated = await updateDocument(config.collections.websiteProjects, projectId, { + status: 'deployed', + provisioningStatus: 'ready', + }) + } } catch (err) { console.error('[archiveRepo] Redeploy bei Reaktivierung fehlgeschlagen:', err.message) } } - const updated = await updateDocument(config.collections.websiteProjects, projectId, { - archived: Boolean(archived), - ...(archived ? { status: 'archived' } : {}), - updatedAt: new Date().toISOString(), - }) - - return { project: updated, archived: Boolean(archived) } + return { project: updated, archived: false } } /** Repo-Basisdaten (Groesse in KB, Default-Branch). */ diff --git a/server/services/giteaClient.js b/server/services/giteaClient.js index 003ac5f..32ce603 100644 --- a/server/services/giteaClient.js +++ b/server/services/giteaClient.js @@ -52,7 +52,7 @@ export async function giteaFetch(path, options = {}) { if (!response.ok) { let message = data?.message || `Gitea ${response.status}` if (response.status === 409 && /same name already exists/i.test(message)) { - message = 'Ein Gitea-Repository mit diesem Namen existiert bereits. Bitte einen anderen Subdomain-/Repo-Namen w�hlen.' + message = 'Ein Gitea-Repository mit diesem Namen existiert bereits. Bitte einen anderen Subdomain-/Repo-Namen wählen.' } const error = new Error(message) error.status = response.status diff --git a/server/services/repoAssignJob.js b/server/services/repoAssignJob.js index d1fadc0..95e5c6b 100644 --- a/server/services/repoAssignJob.js +++ b/server/services/repoAssignJob.js @@ -149,7 +149,7 @@ export async function runRepoAssignJob({ const docs = await listDocuments(collection(), [Query.limit(5000)]) for (const p of docs) { - if (!p.createdByGitea || p.kind || handled.has(p.$id)) continue + if (!p.createdByGitea || p.kind || p.archived || handled.has(p.$id)) continue // Wiederholung/Nachholen: erste Mail noch nicht raus (0-2 Versuche), Repo noch juenger als 24 h. const attempts = Number(p.assignMailAttempts || 0) if (!p.assignMailSentAt && attempts < 3) { diff --git a/server/services/repoMove.js b/server/services/repoMove.js index 17d1ce0..ad963de 100644 --- a/server/services/repoMove.js +++ b/server/services/repoMove.js @@ -59,6 +59,8 @@ function sameRepoFullName(a, b) { export async function isInternalRepo(repoFullName) { const lower = repoFullName.toLowerCase() if (config.gitea.keepInPlace.some((entry) => entry.toLowerCase() === lower)) return true + // Die Vorlage fuer "Neues Projekt" darf nie umziehen, sonst findet generate sie nicht mehr + if (`${config.gitea.templateOwner}/${config.gitea.templateRepo}`.toLowerCase() === lower) return true const hooks = await giteaFetch(`/repos/${repoFullName}/hooks`) const portalUrl = portalWebhookUrl() diff --git a/test/giteaAdmin.test.js b/test/giteaAdmin.test.js index f1c3eca..01e86d9 100644 --- a/test/giteaAdmin.test.js +++ b/test/giteaAdmin.test.js @@ -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 () => { diff --git a/test/helpers/fakeAppwrite.js b/test/helpers/fakeAppwrite.js index 6051145..61a8ecd 100644 --- a/test/helpers/fakeAppwrite.js +++ b/test/helpers/fakeAppwrite.js @@ -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>} 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 } } diff --git a/test/helpers/fakeGitea.js b/test/helpers/fakeGitea.js index 25b9ea7..40b8604 100644 --- a/test/helpers/fakeGitea.js +++ b/test/helpers/fakeGitea.js @@ -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, diff --git a/test/projektAblauf.test.js b/test/projektAblauf.test.js new file mode 100644 index 0000000..45b8c6d --- /dev/null +++ b/test/projektAblauf.test.js @@ -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/) +})