import { test, before, after } from 'node:test' import assert from 'node:assert/strict' import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { randomUUID } from 'node:crypto' import { startFakeGitea } from './helpers/fakeGitea.js' import { startFakeAppwrite } from './helpers/fakeAppwrite.js' let gitea let aw let tmp let originalCwd let previewWorkDir let moveRepoToOrg let isOrgRepo let isInternalRepo let repoLockKey let currentRepoFullName // Schluessel: urspruenglich gesetzter repoFullName -> Appwrite-$id. Wird beim Seeden befuellt // (siehe Controller-Ruling R2) - eine Live-Suche ueber repoFullName wuerde nach einem Umzug // nichts mehr finden, weil sich das Feld dann geaendert hat. const idByRepo = new Map() /** Liefert das gespeicherte Dokument des Repos, ueber den zur Seed-Zeit gebauten Id-Index. */ function doc(name) { return aw.get('websiteProjects', idByRepo.get(name)) } const PORTAL_HOOK_URL = 'https://project.webklar.com/webhook/gitea' before(async () => { gitea = await startFakeGitea({ repos: [ { full_name: 'JUSN/D-ner-Nation' }, { full_name: 'knso/emmas-schatzkiste' }, { full_name: 'ANDJ/Steuerungstechnik' }, { full_name: 'knso/hytail-ark' }, { full_name: 'WEBklar/schon-da' }, { full_name: 'JUSN/schon-da' }, { full_name: 'WEBklar/von-hand' }, { full_name: 'ANDJ/unberuehrt' }, { full_name: 'JUSN/doc-fail-test' }, { full_name: 'JUSN/verboten' }, { full_name: 'JUSN/Case-Test' }, { full_name: 'knso/rausgezogen' }, ], // C1: Hooks kommen (wie im echten Gitea) OHNE Top-Level-`url` an - `url` hier ist nur die // Seed-Abkuerzung, gespeichert/ausgeliefert wird das Ziel ausschliesslich unter `config.url`. hooks: { 'knso/emmas-schatzkiste': [{ id: 110, url: PORTAL_HOOK_URL }], 'knso/hytail-ark': [{ id: 7, url: 'https://webklar.com/webhook/hytail-ark/deploy' }], 'WEBklar/von-hand': [{ id: 210, url: PORTAL_HOOK_URL }], 'WEBklar/schon-da': [{ id: 310, url: PORTAL_HOOK_URL }], 'JUSN/doc-fail-test': [{ id: 410, url: PORTAL_HOOK_URL }], 'knso/rausgezogen': [{ id: 510, url: PORTAL_HOOK_URL }], }, // knso/von-hand wurde von Hand bereits nach WEBklar/von-hand verschoben (kein Transfer-Aufruf noetig). // WEBklar/rausgezogen wurde von Hand AUS der Organisation heraus nach knso/rausgezogen verschoben // (final-fix Punkt 2: Schritt-1-Aufraeumen darf dieser Weiterleitung nicht folgen). redirects: { 'knso/von-hand': 'WEBklar/von-hand', 'WEBklar/rausgezogen': 'knso/rausgezogen', }, }) const repoNames = [ 'JUSN/D-ner-Nation', 'knso/emmas-schatzkiste', 'ANDJ/Steuerungstechnik', 'knso/hytail-ark', 'WEBklar/schon-da', 'JUSN/schon-da', 'knso/von-hand', // Appwrite kennt noch den alten Namen (siehe Brief) 'ANDJ/unberuehrt', 'JUSN/doc-fail-test', 'JUSN/verboten', 'jusn/case-test', // Appwrite kennt eine andere Schreibweise als Gitea (dort: JUSN/Case-Test) 'WEBklar/rausgezogen', // Appwrite kennt noch den WEBklar-Namen, Gitea hat es herausverschoben ] const websiteProjects = repoNames.map((repoFullName) => { const $id = randomUUID() idByRepo.set(repoFullName, $id) return { $id, repoFullName, projectName: repoFullName.split('/').pop() } }) aw = await startFakeAppwrite({ seed: { websiteProjects } }) originalCwd = process.cwd() tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-move-')) process.chdir(tmp) Object.assign(process.env, { GITEA_BASE_URL: gitea.baseUrl, GITEA_API_TOKEN: 'test-token', APPWRITE_ENDPOINT: aw.endpoint, APPWRITE_API_KEY: 'test-key', PREVIEW_DEPLOY_ROOT: '', TRAEFIK_DYNAMIC_DIR: '', }) // config.js liest die Umgebung beim Import ;({ previewWorkDir } = await import('../server/services/previewDeploy.js')) ;({ moveRepoToOrg, isOrgRepo, isInternalRepo, repoLockKey, currentRepoFullName } = await import( '../server/services/repoMove.js' )) }) after(async () => { process.chdir(originalCwd) await gitea.close() await aw.close() await fs.rm(tmp, { recursive: true, force: true }) }) test('verschiebt Repo aus eigenem Konto', async () => { await fs.mkdir(previewWorkDir('JUSN/D-ner-Nation'), { recursive: true }) const r = await moveRepoToOrg(doc('JUSN/D-ner-Nation')) assert.deepEqual(r, { moved: { from: 'JUSN/D-ner-Nation', to: 'WEBklar/D-ner-Nation' }, warnings: [] }) assert.deepEqual(gitea.calls.find((c) => c.path.endsWith('/transfer')).body, { new_owner: 'WEBklar' }) const d = aw.get('websiteProjects', doc('JUSN/D-ner-Nation').$id) assert.equal(d.repoFullName, 'WEBklar/D-ner-Nation') assert.equal(d.giteaRepoUrl, `${gitea.baseUrl}/WEBklar/D-ner-Nation`) assert.equal(d.giteaRepoName, 'D-ner-Nation') await assert.rejects(fs.access(previewWorkDir('JUSN/D-ner-Nation'))) }) test('löscht den alten Portal-Webhook', async () => { const r = await moveRepoToOrg(doc('knso/emmas-schatzkiste')) assert.deepEqual(r.moved, { from: 'knso/emmas-schatzkiste', to: 'WEBklar/emmas-schatzkiste' }) assert.deepEqual(gitea.hooksOf('WEBklar/emmas-schatzkiste'), []) assert.equal( gitea.calls.some( (c) => c.method === 'DELETE' && c.path === '/api/v1/repos/WEBklar/emmas-schatzkiste/hooks/110' ), true ) }) test('docNotUpdated: Repo zieht trotzdem um, Portal-Webhook wird trotzdem geloescht', async () => { aw.failNext({ method: 'PATCH', pathIncludes: doc('JUSN/doc-fail-test').$id, status: 500 }) const r = await moveRepoToOrg(doc('JUSN/doc-fail-test')) assert.deepEqual(r.moved, { from: 'JUSN/doc-fail-test', to: 'WEBklar/doc-fail-test' }) assert.match(r.warnings[0], /^Das Repo liegt jetzt in WEBklar/) assert.deepEqual(gitea.hooksOf('WEBklar/doc-fail-test'), []) }) test('schon in WEBklar: nichts passiert', async () => { const r = await moveRepoToOrg(doc('WEBklar/schon-da')) assert.deepEqual(r, { moved: null, warnings: [] }) // R11: "Pushes nachtragen" raeumt auch bei einem schon in WEBklar liegenden Repo einen liegen // gebliebenen Einzel-Webhook auf. assert.deepEqual(gitea.hooksOf('WEBklar/schon-da'), []) }) test('internes Repo per Liste und per fremdem Webhook', async () => { for (const name of ['ANDJ/Steuerungstechnik', 'knso/hytail-ark']) { assert.deepEqual(await moveRepoToOrg(doc(name)), { moved: null, warnings: ['Internes Repo, bleibt wo es ist.'] }) } assert.equal(gitea.calls.filter((c) => c.path.endsWith('/transfer') && /steuerung|hytail/i.test(c.path)).length, 0) }) test('Name existiert in WEBklar', async () => { const r = await moveRepoToOrg(doc('JUSN/schon-da')) assert.deepEqual(r, { moved: null, warnings: ['In WEBklar gibt es schon ein Repo namens schon-da. Bitte in Gitea umbenennen und dann ‚Pushes nachtragen‘ klicken.'] }) }) test('R10: 403 beim Transfer', async () => { gitea.failNext({ method: 'POST', pathIncludes: '/transfer', status: 403 }) const r = await moveRepoToOrg(doc('JUSN/verboten')) assert.equal(r.moved, null) assert.match(r.warnings[0], /^Umzug nach WEBklar nicht möglich \(.+\)\.$/) }) test('Weiterleitung korrigiert Projektdaten', async () => { const r = await moveRepoToOrg(doc('knso/von-hand')) assert.deepEqual(r.moved, { from: 'knso/von-hand', to: 'WEBklar/von-hand' }) assert.equal(gitea.calls.filter((c) => c.path.endsWith('/transfer') && c.path.includes('von-hand')).length, 0) const d = aw.get('websiteProjects', doc('knso/von-hand').$id) assert.equal(d.repoFullName, 'WEBklar/von-hand') assert.equal(d.giteaRepoUrl, `${gitea.baseUrl}/WEBklar/von-hand`) assert.equal(d.giteaRepoName, 'von-hand') // R11/I3: der beim Hand-Umzug liegen gebliebene Einzel-Webhook wird jetzt mit aufgeraeumt. assert.deepEqual(gitea.hooksOf('WEBklar/von-hand'), []) }) test('nur Groß-/Kleinschreibung unterschiedlich: echter Umzug', async () => { // I1: gespeichert ist "jusn/case-test", Gitea antwortet auf denselben Namen mit "JUSN/Case-Test" // (reine Schreibweise, kein tatsaechlicher Umzug) - das darf den Selbstheilungs-Zweig NICHT // ausloesen, sondern muss ganz normal weiter zum Transfer fuehren. const r = await moveRepoToOrg(doc('jusn/case-test')) assert.deepEqual(r, { moved: { from: 'jusn/case-test', to: 'WEBklar/Case-Test' }, warnings: [] }) assert.equal( gitea.calls.some((c) => c.method === 'POST' && c.path === '/api/v1/repos/jusn/case-test/transfer'), true ) assert.equal(aw.get('websiteProjects', doc('jusn/case-test').$id).repoFullName, 'WEBklar/Case-Test') }) test('Erkennung ohne Groß-/Kleinschreibung', async () => { assert.equal(isOrgRepo('webklar/x'), true) assert.equal(await isInternalRepo('andj/steuerungstechnik'), true) }) test('repoLockKey', () => { assert.equal(repoLockKey('JUSN/D-ner-Nation'), 'repo:jusn/d-ner-nation') }) test('currentRepoFullName', async () => { assert.equal(await currentRepoFullName('WEBklar/D-ner-Nation'), 'WEBklar/D-ner-Nation') // Eintrag vorhanden assert.equal(await currentRepoFullName('JUSN/D-ner-Nation'), 'WEBklar/D-ner-Nation') // kein Eintrag, Weiterleitung }) test('Gitea nicht erreichbar', async () => { gitea.setDown(true) const r = await moveRepoToOrg(doc('ANDJ/unberuehrt')) gitea.setDown(false) assert.equal(r.moved, null) assert.match(r.warnings[0], /^Gitea ist gerade nicht erreichbar \(.+\)\. Bitte später ‚Pushes nachtragen‘ klicken\.$/) }) test('Schritt 1 folgt keiner Weiterleitung: von Hand aus WEBklar herausverschobenes Repo wird nicht angefasst', async () => { // final-fix Punkt 2: WEBklar/rausgezogen (isOrgRepo(from) === true) leitet inzwischen auf // knso/rausgezogen um - das Aufraeumen darf dem NICHT folgen (sonst wuerde der Portal-Webhook // eines fremden Repos geloescht). Stattdessen wird der kanonische Name aufgeloest und, weil er // ausserhalb von WEBklar liegt, nur das Projektdokument nachgezogen. const r = await moveRepoToOrg(doc('WEBklar/rausgezogen')) assert.deepEqual(r, { moved: null, warnings: [] }) assert.equal( gitea.calls.some((c) => c.method === 'DELETE' && /rausgezogen/i.test(c.path)), false ) assert.deepEqual(gitea.hooksOf('knso/rausgezogen'), [ { id: 510, type: 'gitea', active: true, events: ['push'], config: { content_type: 'json', url: PORTAL_HOOK_URL } }, ]) const d = aw.get('websiteProjects', doc('WEBklar/rausgezogen').$id) assert.equal(d.repoFullName, 'knso/rausgezogen') }) test('R10-Ein-Zeiler: Transfer meldet Erfolg, aber unveraenderten full_name -> kein Aufraeumen', async () => { // final-fix Punkt 3: der Transfer selbst antwortet 201, aber full_name ist danach nicht in // WEBklar (simuliert einen Gitea-Bug/Teil-Fehlschlag). Erst NACH dieser Pruefung darf // aufgeraeumt werden - hier eben nicht. gitea.setTransferNoop() const r = await moveRepoToOrg(doc('ANDJ/unberuehrt')) assert.equal(r.moved, null) assert.deepEqual(r.warnings, ['Umzug nach WEBklar nicht möglich (Übertragung nicht abgeschlossen).']) assert.equal( gitea.calls.some((c) => c.method === 'DELETE' && /unberuehrt/i.test(c.path)), false ) })