// 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') // Kundenquelltext nicht oeffentlich (Entscheidung 2026-10-04) assert.equal(gen.body.private, true) }) 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) // hier ohne PREVIEW_DEPLOY_ROOT: kein Deploy -> 'pending' und als fehlgeschlagen markiert assert.deepEqual([d.status, d.provisioningStatus], ['pending', 'deploy_failed']) // der Status wird vor dem Redeploy geschrieben, danach nicht mehr (der Runner setzt 'deployed') const patches = aw.calls.filter((c) => c.method === 'PATCH' && c.path.endsWith('/p_mit')) assert.equal(patches.at(-1).body.data.status, undefined) }) test('Neues Projekt: alter verknuepfter Eintrag mit demselben Namen blockiert mit klarer Meldung, bevor ein Repo entsteht', async () => { aw.insert('websiteProjects', { repoFullName: 'WEBklar/alt-belegt', projectName: 'Alt', archived: true, status: 'archived', kind: 'website', customerId: 'cAlt' }) const before = gitea.calls.filter((c) => c.path.endsWith('/generate')).length await assert.rejects(svc.createProjectFromTemplate({ displayName: 'alt belegt', repoName: 'alt-belegt' }), (err) => err.status === 409 && /alten Projekt/.test(err.message) && /archiviert/.test(err.message)) assert.equal(gitea.calls.filter((c) => c.path.endsWith('/generate')).length, before) }) test('Neues Projekt: alter Platzhalter ohne Bezug wird entfernt, das neue Projekt startet sauber', async () => { aw.insert('websiteProjects', { $id: 'platzhalter', repoFullName: 'WEBklar/frei-machen', projectName: 'alt', archived: true, status: 'archived' }) const r = await svc.createProjectFromTemplate({ displayName: 'Frei machen', customerId: 'c1' }) assert.equal(aw.get('websiteProjects', 'platzhalter'), undefined) const d = docOf(r.repoFullName) assert.equal(d.archived, false) assert.equal(d.customerId, 'c1') }) 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/) })