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 { startFakeGitea } from './helpers/fakeGitea.js' import { startFakeAppwrite } from './helpers/fakeAppwrite.js' let gitea let aw let tmp let originalCwd let assignProject let reconcileProject const now = new Date('2026-09-27T16:00:00Z') let nextFeedId = 1 /** * Baut einen Gitea-1.25-Feed-Eintrag vom Typ `commit_repo` nach (ein Commit), siehe * `test/pushHistory.test.js` fuer denselben Aufbau: `content` traegt das JSON * `{Commits:[...], HeadCommit:{...}}` als String, wie im echten Aktivitaeten-Feed. */ function pushEntry({ sha, at, user, msg }) { const login = user || 'JUSN' const commit = { Sha1: sha, Message: msg || `Commit ${sha}`, AuthorEmail: `${login.toLowerCase()}@example.com`, AuthorName: login, CommitterEmail: `${login.toLowerCase()}@example.com`, CommitterName: login, Timestamp: at, } return { id: nextFeedId++, op_type: 'commit_repo', act_user: { login, full_name: '' }, ref_name: 'refs/heads/main', created: at, content: JSON.stringify({ Commits: [commit], HeadCommit: { Sha1: sha }, CompareURL: '', Len: 1 }), } } /** * Wartet, bis `aw.calls` mindestens `count` Eintraege hat - deterministisch, kein fester Timeout. * `calls.push` passiert server-seitig, SOBALD eine Anfrage beim Fake-Appwrite ankommt - also * strikt BEVOR die Antwort beim Aufrufer eintrifft und dieser (falls ueberhaupt) die naechste * Anfrage lostritt. Damit laesst sich `failNext` gezielt auf einen bestimmten von mehreren gleich * aussehenden Aufrufen ansetzen (z. B. das zweite GET desselben Dokuments), ohne zu raten. */ function waitForAppwriteCalls(count) { return new Promise((resolve) => { ;(function check() { if (aw.calls.length >= count) return resolve() setImmediate(check) })() }) } before(async () => { gitea = await startFakeGitea({ repos: [ { full_name: 'JUSN/D-ner-Nation' }, { full_name: 'JUSN/leer' }, { full_name: 'JUSN/nur-kunde' }, { full_name: 'JUSN/doppelt' }, { full_name: 'WEBklar/ticket-fail' }, { full_name: 'WEBklar/worksheet-fail' }, { full_name: 'JUSN/reread-fail' }, { full_name: 'WEBklar/inlock-fail' }, ], feeds: { // Newest first, wie der echte Gitea-Feed. 'WEBklar/ticket-fail': [pushEntry({ sha: 'tf1', user: 'JUSN', at: '2026-09-27T10:00:00Z', msg: 'Ticket-Fail-Push' })], 'WEBklar/worksheet-fail': [pushEntry({ sha: 'wf1', user: 'JUSN', at: '2026-09-27T10:00:00Z', msg: 'Worksheet-Fail-Push' })], 'JUSN/D-ner-Nation': [ pushEntry({ sha: 's1', user: 'webklar-system', at: '2026-09-27T14:50:00Z', msg: 'System-Push' }), pushEntry({ sha: 'k1', user: 'knso', at: '2026-09-27T14:48:00Z', msg: 'Update preview config from ticket system' }), pushEntry({ sha: '26bc22ecf17e2e5a5a8794dcb4cf3892f802c51d', user: 'JUSN', at: '2026-09-27T14:46:34Z', msg: 'Bilder aktualisiert' }), { ...pushEntry({ sha: 'x1', at: '2026-09-27T14:46:33Z' }), content: '' }, { id: nextFeedId++, op_type: 'create_repo', created: '2026-09-27T14:02:46Z', act_user: { login: 'JUSN' } }, ], 'JUSN/nur-kunde': [pushEntry({ sha: 'n1', user: 'JUSN', at: '2026-09-27T12:00:00Z', msg: 'Kundenseite live' })], 'JUSN/doppelt': [pushEntry({ sha: 'd1', user: 'JUSN', at: '2026-09-27T11:00:00Z', msg: 'Doppelt-Push' })], 'JUSN/leer': [], }, }) aw = await startFakeAppwrite({ seed: { websiteProjects: [ { $id: 'p1', repoFullName: 'JUSN/D-ner-Nation', projectName: 'D-ner-Nation', customerId: '', ticketId: '' }, { $id: 'p2', repoFullName: 'JUSN/leer', projectName: 'leer', customerId: '', ticketId: '' }, { $id: 'p3', repoFullName: 'JUSN/nur-kunde', projectName: 'nur-kunde', customerId: '', ticketId: '' }, { $id: 'p4', repoFullName: 'JUSN/doppelt', projectName: 'doppelt', customerId: '', ticketId: '' }, { $id: 'p5', repoFullName: 'WEBklar/ticket-fail', projectName: 'ticket-fail', customerId: '', ticketId: '' }, { $id: 'p6', repoFullName: 'WEBklar/worksheet-fail', projectName: 'worksheet-fail', customerId: '', ticketId: '' }, { $id: 'p7', repoFullName: 'JUSN/reread-fail', projectName: 'reread-fail', customerId: '', ticketId: '' }, { $id: 'p8', repoFullName: 'WEBklar/inlock-fail', projectName: 'inlock-fail', customerId: '', ticketId: '' }, ], workorders: [{ $id: 't1', woid: '100115' }], customers: [{ $id: 'c1', name: 'Döner Nation' }], }, }) originalCwd = process.cwd() tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'project-assignment-')) 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 ;({ assignProject, reconcileProject } = await import('../server/services/projectAssignment.js')) }) after(async () => { process.chdir(originalCwd) await gitea.close() await aw.close() await fs.rm(tmp, { recursive: true, force: true }) }) test('assign speichert, verschiebt und trägt nach', async () => { const r = await assignProject('p1', { customerId: 'c1', ticketId: 't1' }, { now }) assert.equal(r.ok, true) assert.deepEqual(r.moved, { from: 'JUSN/D-ner-Nation', to: 'WEBklar/D-ner-Nation' }) assert.equal(r.backfilled, 1); assert.deepEqual(r.warnings, []) const d = aw.get('websiteProjects', 'p1') assert.deepEqual([d.customerId, d.ticketId], ['c1', 't1']) const ws = aw.all('worksheets').filter((w) => w.workorderId === 't1') assert.equal(ws.length, 1) assert.deepEqual([ws[0].employeeName, ws[0].endDate, ws[0].endTime], ['JUSN', '27.09.2026', '1646']) }) test('zweites Nachtragen erzeugt nichts Neues', async () => { const r = await reconcileProject('p1', { now }) assert.deepEqual([r.moved, r.backfilled], [null, 0]) }) test('nur Kunde ohne Pushes: kein Ticket', async () => { const before = aw.all('workorders').length await assignProject('p2', { customerId: 'c1' }, { now }) assert.equal(aw.all('workorders').length, before) }) test('nur Kunde mit Pushes: Webpage-Ticket entsteht', async () => { // ticketAdmin loggt die automatische Ticket-Anlage (console.log) - hier abfangen, damit die // Testausgabe sauber bleibt (Ruling, Fix-Runde 1). const originalLog = console.log console.log = () => {} let r try { r = await assignProject('p3', { customerId: 'c1' }, { now }) } finally { console.log = originalLog } const stored = aw.get('websiteProjects', 'p3') const t = aw.all('workorders').find((w) => w.$id === stored.ticketId) assert.equal(t.type, 'Webpage'); assert.equal(r.backfilled, 1) // I2: die Antwort muss die gerade erst automatisch angelegte ticketId zeigen, nicht den Stand // von vor dem Anlegen. assert.equal(r.project.ticketId, stored.ticketId) }) test('Kunde erneut zuweisen ohne Ticket: kein zweites Ticket, kein doppeltes Arbeitsblatt', async () => { // p3 hat aus dem vorigen Test bereits ein automatisch angelegtes Ticket - ein verzoegerter // zweiter Klick (gleicher Kunde, keine ticketId) darf es nicht durch ein leeres ueberschreiben // (Review I3). "Verzoegert" reicht hier als einfaches Abwarten des ersten Aufrufs. const before = { workorders: aw.all('workorders').length, worksheets: aw.all('worksheets').length } const ticketIdBefore = aw.get('websiteProjects', 'p3').ticketId const r = await assignProject('p3', { customerId: 'c1' }, { now }) assert.equal(aw.all('workorders').length, before.workorders) assert.equal(aw.all('worksheets').length, before.worksheets) assert.equal(aw.get('websiteProjects', 'p3').ticketId, ticketIdBefore) assert.equal(r.project.ticketId, ticketIdBefore) }) test('Doppelklick: ein Umzug, keine doppelten Arbeitsblätter', async () => { await Promise.all([assignProject('p4', { customerId: 'c1', ticketId: 't1' }, { now }), assignProject('p4', { customerId: 'c1', ticketId: 't1' }, { now })]) assert.equal(gitea.calls.filter((c) => c.path === '/api/v1/repos/JUSN/doppelt/transfer').length, 1) assert.equal(aw.all('worksheets').filter((w) => w.details.includes('WEBklar/doppelt') || w.details.includes('JUSN/doppelt')).length, 1) }) test('Gitea-Ausfall: Zuweisung bleibt', async () => { // Diese Zuweisung loest bewusst einen Gitea-Ausfall aus (`deletePortalHooks` in `repoMove.js` // loggt einen fehlgeschlagenen Hook-Abruf mit console.error) - Konsole hier abfangen, damit // die Testausgabe sauber bleibt. const originalError = console.error const originalWarn = console.warn console.error = () => {} console.warn = () => {} gitea.setDown(true) let r try { r = await assignProject('p2', { customerId: 'c1', ticketId: 't1' }, { now }) } finally { gitea.setDown(false) console.error = originalError console.warn = originalWarn } assert.equal(r.ok, true); assert.equal(aw.get('websiteProjects', 'p2').ticketId, 't1') assert.match(r.warnings[0], /^Gitea ist gerade nicht erreichbar/) }) test('unbekanntes Projekt', async () => { await assert.rejects(assignProject('nope', { ticketId: 't1' }, { now }), (e) => e.status === 404) }) test('Appwrite-Ausfall beim Ticket-Anlegen: Zuweisung bleibt, Warnung statt Abbruch', async () => { aw.failNext({ method: 'POST', pathIncludes: 'workorders', status: 500 }) const r = await assignProject('p5', { customerId: 'c1' }, { now }) assert.equal(r.ok, true) assert.equal(aw.get('websiteProjects', 'p5').customerId, 'c1') assert.match(r.warnings[0], /^Pushes konnten nicht nachgetragen werden \(/) }) test('Appwrite-Ausfall beim Arbeitsblatt-Anlegen: Zuweisung bleibt, Warnung statt Abbruch', async () => { aw.failNext({ method: 'POST', pathIncludes: 'worksheets', status: 500 }) const r = await assignProject('p6', { customerId: 'c1', ticketId: 't1' }, { now }) assert.equal(r.ok, true) assert.equal(aw.get('websiteProjects', 'p6').ticketId, 't1') assert.match(r.warnings[0], /^Pushes konnten nicht nachgetragen werden \(/) }) test('Nachlesen nach Umzug schlaegt fehl: Antwort zeigt trotzdem den neuen Namen', async () => { // p7 zieht hier tatsaechlich um (JUSN -> WEBklar); das GET danach (Controller-Ruling R13/R14) // soll scheitern. Gezielt getimt: warten, bis das GET VOR dem Umzug und das PATCH AUS dem // Umzug (`updateProjectDocument` in repoMove.js) durch sind, dann erst failNext scharf machen - // so trifft es sicher das GET NACH dem Umzug und keines der beiden vorherigen. const target = aw.calls.length + 2 const p = reconcileProject('p7', { now }) await waitForAppwriteCalls(target) aw.failNext({ method: 'GET', pathIncludes: 'p7', status: 500 }) const r = await p assert.deepEqual(r.moved, { from: 'JUSN/reread-fail', to: 'WEBklar/reread-fail' }) assert.deepEqual(r.warnings, []) assert.equal(r.project.repoFullName, 'WEBklar/reread-fail') assert.equal(r.project.giteaRepoUrl, `${gitea.baseUrl}/WEBklar/reread-fail`) assert.equal(r.project.giteaRepoName, 'reread-fail') }) test('Appwrite-Fehler beim Nachlesen unter der Sperre in assignProject: Zuweisung bleibt, Warnung statt Abbruch', async () => { // Gezielt getimt wie oben: warten, bis die Existenzpruefung und das Speichern der Zuweisung // (beide in `assignProject`) durch sind, dann erst failNext scharf machen - so trifft es sicher // das GET, das der Reconcile-Kern (`reconcileProjectLocked`) direkt danach selbst macht. const target = aw.calls.length + 2 const p = assignProject('p8', { customerId: 'c1' }, { now }) await waitForAppwriteCalls(target) aw.failNext({ method: 'GET', pathIncludes: 'p8', status: 500 }) const r = await p assert.equal(r.ok, true) assert.equal(r.moved, null) assert.equal(r.backfilled, 0) assert.equal(aw.get('websiteProjects', 'p8').customerId, 'c1') assert.match(r.warnings[0], /^Zuweisung gespeichert, aber Umzug und Nachtragen sind fehlgeschlagen \(/) })