import { test, after, beforeEach, mock } from 'node:test' import assert from 'node:assert/strict' import fs from 'node:fs' import path from 'node:path' import { starteUmgebung, KUNDE_ID, PROJEKT_ID, TICKET_ID, ZWEITES_TICKET, FREMD, VON, MELDUNG, festpreisEingabe, kundeDoc, projektDoc, baueDeps, sha256, abgelehnt, dateienIn, ergebnis, tor, warte, wartebis, } from './helpers/auftraegeAb.js' const umg = await starteUmgebung() const { aw, config, uploadDir } = umg after(() => umg.schliesse()) beforeEach(umg.zuruecksetzen) const { getAuftrag, updateAuftrag, findAuftragByTokenHash } = await import('../server/services/auftraege/auftragStore.js') const { createProjektRechnung, getProjektRechnung } = await import('../server/services/auftraege/projektRechnungenStore.js') const { lesePdf } = await import('../server/services/auftraege/ablage.js') const { sperreAuftrag } = await import('../server/services/auftraege/konstanten.js') const { withLocks } = await import('../server/services/keyedLock.js') const { legeEntwurfAn, aendereEntwurf, loescheEntwurf, sendeAuftrag, erinnereAuftrag, neueFassung, storniereAuftrag, ordneProjektZu } = await import('../server/services/auftraege/auftraege.js') const JETZT = new Date('2026-10-06T10:00:00Z') /** Eine versendete AB (Entwurf anlegen und senden); liefert den Auftrag. */ async function gesendet(deps, patch) { const entwurf = await legeEntwurfAn(festpreisEingabe(patch), { von: VON }) return (await sendeAuftrag(entwurf.id, { von: VON, deps, jetzt: JETZT })).auftrag } /** * Hält die Sperre der AB wie L5 beim Bestätigen (sperreAuftrag(id), V2), startet die Aktion, wartet, bis sie an der Sperre * steht, führt waehrend() aus (dort schreibt „L5“) und liefert das Ergebnis der Aktion ({ wert } oder { fehler }) nach dem Freigeben. */ async function unterFremderSperre(id, { aktion, bereit = () => true, waehrend }) { const sperre = tor() const haelt = withLocks(sperreAuftrag(id), () => sperre.promise) const lauf = ergebnis(aktion()) await wartebis(bereit) await warte(30) // die Aktion steht jetzt an der Sperre und darf noch nichts geschrieben haben await waehrend() sperre.freigeben() await haelt return lauf } test('erinnereAuftrag: neuer Token (der alte gilt nicht mehr), gleiche PDF-Datei, Mail mit erinnerung: true, Versanddatum bleibt', async () => { const { deps, calls } = baueDeps() const erste = await gesendet(deps) const spaeter = new Date('2026-10-20T08:00:00Z') const { auftrag, mailFehler } = await erinnereAuftrag(erste.id, { deps, jetzt: spaeter }) assert.equal(mailFehler, false) assert.equal(calls.token.length, 2) assert.notEqual(auftrag.tokenHash, erste.tokenHash) assert.equal(auftrag.tokenHash, sha256(calls.token[1])) assert.equal(new Date(auftrag.tokenGueltigBis).getTime(), spaeter.getTime() + 60 * 86_400_000) assert.equal(await findAuftragByTokenHash(erste.tokenHash), null) assert.equal((await findAuftragByTokenHash(auftrag.tokenHash)).id, erste.id) assert.deepEqual( [auftrag.status, auftrag.nummer, auftrag.gesendetAm, auftrag.pdfDatei, auftrag.pdfSha256, auftrag.mailStatus], ['versendet', erste.nummer, erste.gesendetAm, erste.pdfDatei, erste.pdfSha256, 'gesendet'], ) assert.equal(calls.pdf.length, 1) // kein neues PDF assert.equal(calls.mail.length, 2) assert.deepEqual(calls.mail[1].pdf, await lesePdf(erste.pdfDatei)) assert.deepEqual([calls.mail[1].token, calls.mail[1].erinnerung], [calls.token[1], true]) assert.deepEqual(await getAuftrag(erste.id), auftrag) }) test('erinnereAuftrag nach einem Mail-Fehler: gewöhnliche Mail ohne Erinnerungs-Hinweis an die aktuelle Adresse des Kunden', async () => { mock.method(console, 'error', () => {}) const erste = await gesendet(baueDeps({ mailFehler: new Error('Verbindung abgebrochen') }).deps) assert.deepEqual([erste.mailStatus, erste.gesendetAn], ['fehler', 'anna@example.invalid']) aw.seed(config.collections.customers, kundeDoc({ email: 'neu@example.invalid' })) // der Admin hat die Adresse korrigiert const { deps, calls } = baueDeps() const { auftrag, mailFehler } = await erinnereAuftrag(erste.id, { deps }) assert.equal(mailFehler, false) assert.deepEqual([auftrag.mailStatus, auftrag.mailFehler, auftrag.gesendetAn], ['gesendet', '', 'neu@example.invalid']) assert.deepEqual([calls.mail[0].erinnerung, calls.mail[0].auftrag.gesendetAn], [false, 'neu@example.invalid']) }) test('erinnereAuftrag: scheitert die Mail, gilt trotzdem der neue Token; das Ergebnis meldet mailFehler', async () => { mock.method(console, 'error', () => {}) const erste = await gesendet(baueDeps().deps) const kaputt = baueDeps({ mailFehler: Object.assign(new Error('x'), { code: 'ECONNREFUSED' }) }) const { auftrag, mailFehler } = await erinnereAuftrag(erste.id, { deps: kaputt.deps }) assert.equal(mailFehler, true) assert.deepEqual([auftrag.status, auftrag.mailStatus, auftrag.mailFehler], ['versendet', 'fehler', 'E-Mail konnte nicht gesendet werden (ECONNREFUSED).']) assert.equal(auftrag.tokenHash, sha256(kaputt.calls.token[0])) assert.equal(await findAuftragByTokenHash(erste.tokenHash), null) }) test('erinnereAuftrag: nur versendete (409), unbekannt 404, ohne E-Mail 400, beschädigte PDF-Datei 500 – jeweils ohne neuen Token', async () => { mock.method(console, 'error', () => {}) const { deps } = baueDeps() const a = await gesendet(deps) for (const status of ['entwurf', 'bestaetigt', 'abgerechnet', 'storniert', 'ersetzt', 'widerrufen']) { await updateAuftrag(a.id, { status }) await abgelehnt(erinnereAuftrag(a.id, { deps }), 409, MELDUNG.keineErinnerung, status) } await abgelehnt(erinnereAuftrag('a_gibtsnicht', { deps }), 404, MELDUNG.auftragFehlt) await updateAuftrag(a.id, { status: 'versendet' }) aw.seed(config.collections.customers, kundeDoc({ email: ' ' })) await abgelehnt(erinnereAuftrag(a.id, { deps }), 400, MELDUNG.keineMail) aw.seed(config.collections.customers, kundeDoc()) fs.writeFileSync(path.join(uploadDir, a.pdfDatei), 'manipuliert') await abgelehnt(erinnereAuftrag(a.id, { deps }), 500, MELDUNG.pruefsumme) assert.equal((await getAuftrag(a.id)).tokenHash, a.tokenHash) }) test('erinnereAuftrag wartet auf die Sperre der AB und prüft den Stand danach (L5 bestätigt gerade: keine Erinnerung mehr)', async () => { const { deps, calls } = baueDeps() const a = await gesendet(deps) const lauf = await unterFremderSperre(a.id, { aktion: () => erinnereAuftrag(a.id, { deps }), waehrend: async () => { assert.equal((await getAuftrag(a.id)).tokenHash, a.tokenHash) // noch kein neuer Token await updateAuftrag(a.id, { status: 'bestaetigt', bestaetigtAm: new Date().toISOString(), bestaetigtUeber: 'link' }) }, }) assert.deepEqual([lauf.fehler?.status, lauf.fehler?.message], [409, MELDUNG.keineErinnerung]) assert.equal(calls.mail.length, 1) // nur die Mail des ersten Sendens assert.equal((await getAuftrag(a.id)).tokenHash, a.tokenHash) }) test('neueFassung: Kopie als Entwurf mit ersetztId, ohne Nummer, Schnappschuss, PDF und Token; die alte AB bleibt unberührt', async () => { const alt = await gesendet(baueDeps().deps) const neu = await neueFassung(alt.id, { von: 'justin@webklar.com' }) assert.deepEqual([neu.status, neu.ersetztId, neu.nummer, neu.kunde, neu.erstelltVon], ['entwurf', alt.id, '', null, 'justin@webklar.com']) assert.ok(!neu.pdfDatei && !neu.tokenHash && !neu.gesendetAm) for (const feld of ['kundenId', 'projektId', 'titel', 'zeitquelle', 'art', 'positionen', 'stundensatzCents', 'schaetzungMinuten', 'summeCents', 'zahlungsplan', 'zahlungszielTage', 'auftragVom', 'auftragWeg', 'startAm', 'fertigBis']) { assert.deepEqual(neu[feld], alt[feld], feld) } assert.deepEqual(await getAuftrag(alt.id), alt) }) test('neueFassung: nur für versendete, bestätigte und abgerechnete (409); je AB nur ein offener Entwurf', async () => { const alt = await gesendet(baueDeps().deps) for (const status of ['entwurf', 'storniert', 'ersetzt', 'widerrufen']) { await updateAuftrag(alt.id, { status }) await abgelehnt(neueFassung(alt.id, { von: VON }), 409, MELDUNG.keineNeueFassung, status) } for (const status of ['bestaetigt', 'abgerechnet', 'versendet']) { await updateAuftrag(alt.id, { status }) const neu = await neueFassung(alt.id, { von: VON }) await abgelehnt(neueFassung(alt.id, { von: VON }), 409, MELDUNG.schonEntwurf, status) await loescheEntwurf(neu.id) } await abgelehnt(neueFassung('a_gibtsnicht', { von: VON }), 404, MELDUNG.auftragFehlt) }) test('Senden der neuen Fassung: die alte AB wird ersetzt, ihre Rechnungen wandern mit, der alte Link bleibt auffindbar', async () => { const { deps } = baueDeps() const alt = await gesendet(deps) await createProjektRechnung('in_Anzahlung1', { kundenId: KUNDE_ID, projektId: PROJEKT_ID, auftragId: alt.id, art: 'anzahlung', betragCents: 40000, arbeitsblattIds: [], rechnungsnummer: 'WEBKLAR-0007', aktiv: true, erstelltVon: VON, erstelltAm: new Date().toISOString(), }) const entwurf = await neueFassung(alt.id, { von: VON }) await aendereEntwurf(entwurf.id, { positionen: [{ beschreibung: 'Webdesign Startseite', menge: 1, einzelpreisCents: 70000 }] }) const { auftrag: neu } = await sendeAuftrag(entwurf.id, { von: VON, deps, jetzt: new Date('2026-10-07T10:00:00Z') }) assert.deepEqual([neu.nummer, neu.status, neu.ersetztId, neu.summeCents], ['AB-2026-0002', 'versendet', alt.id, 70000]) const altNachher = await getAuftrag(alt.id) assert.deepEqual([altNachher.status, altNachher.ersetztDurchId, altNachher.tokenHash], ['ersetzt', neu.id, alt.tokenHash]) assert.equal((await findAuftragByTokenHash(alt.tokenHash)).id, alt.id) assert.equal((await getProjektRechnung('in_Anzahlung1')).auftragId, neu.id) }) test('Senden der neuen Fassung: ist die alte AB inzwischen storniert, gibt es 409 vor der Nummer', async () => { const { deps, calls } = baueDeps() const alt = await gesendet(deps) const neu = await neueFassung(alt.id, { von: VON }) await updateAuftrag(alt.id, { status: 'storniert' }) await abgelehnt(sendeAuftrag(neu.id, { von: VON, deps, jetzt: JETZT }), 409, MELDUNG.ersetzteUngueltig) assert.equal(calls.pdf.length, 1) // nur die PDF der ersten AB const danach = await getAuftrag(neu.id) assert.deepEqual([danach.status, danach.nummer], ['entwurf', '']) assert.equal((await getAuftrag(alt.id)).status, 'storniert') }) test('Senden der neuen Fassung: scheitert das Speichern erst nach dem Ersetzen der alten AB, führt die Wiederholung alles zu Ende', async () => { mock.method(console, 'error', () => {}) const { deps } = baueDeps() const alt = await gesendet(deps) await createProjektRechnung('in_Anzahlung2', { kundenId: KUNDE_ID, projektId: PROJEKT_ID, auftragId: alt.id, art: 'anzahlung', betragCents: 40000, arbeitsblattIds: [], rechnungsnummer: 'WEBKLAR-0008', aktiv: true, erstelltVon: VON, erstelltAm: new Date().toISOString(), }) const neu = await neueFassung(alt.id, { von: VON }) // erster PATCH auf auftraege: alte AB ersetzt (geht durch); zweiter PATCH: Speichern der neuen AB (scheitert) aw.failNext({ collection: config.collections.auftraege, method: 'PATCH', status: 500, after: 1 }) await assert.rejects(sendeAuftrag(neu.id, { von: VON, deps, jetzt: JETZT }), (err) => err.status === 500) assert.equal((await getAuftrag(neu.id)).status, 'entwurf') assert.deepEqual(dateienIn(uploadDir), [alt.pdfDatei]) // die Datei der neuen AB wurde wieder entfernt const { auftrag } = await sendeAuftrag(neu.id, { von: VON, deps, jetzt: JETZT }) assert.equal(auftrag.nummer, 'AB-2026-0002') assert.deepEqual([(await getAuftrag(alt.id)).status, (await getAuftrag(alt.id)).ersetztDurchId], ['ersetzt', neu.id]) assert.equal((await getProjektRechnung('in_Anzahlung2')).auftragId, neu.id) }) test('Senden der neuen Fassung wartet auf die Sperre der alten AB und prüft sie danach noch einmal (L5 widerruft gerade)', async () => { const { deps, calls } = baueDeps() const alt = await gesendet(deps) const neu = await neueFassung(alt.id, { von: VON }) const lauf = await unterFremderSperre(alt.id, { aktion: () => sendeAuftrag(neu.id, { von: VON, deps, jetzt: JETZT }), bereit: () => calls.pdf.length === 2, // das PDF der neuen AB ist fertig, als Nächstes kommt das Ersetzen waehrend: async () => { assert.equal((await getAuftrag(alt.id)).status, 'versendet') // noch nicht ersetzt await updateAuftrag(alt.id, { status: 'widerrufen', widerrufenAm: new Date().toISOString() }) }, }) assert.deepEqual([lauf.fehler?.status, lauf.fehler?.message], [409, MELDUNG.ersetzteUngueltig]) const danach = await getAuftrag(neu.id) assert.deepEqual([danach.status, danach.nummer], ['entwurf', '']) assert.deepEqual(dateienIn(uploadDir), [alt.pdfDatei]) // die Datei der neuen AB wurde wieder entfernt assert.equal((await getAuftrag(alt.id)).status, 'widerrufen') }) test('Senden: zeigt die ersetztId eines Entwurfs auf ihn selbst (beschädigte Daten), gibt es 409 statt eines Hängers an der eigenen Sperre', { timeout: 5000 }, async () => { const { deps, calls } = baueDeps() const entwurf = await legeEntwurfAn(festpreisEingabe(), { von: VON }) await updateAuftrag(entwurf.id, { ersetztId: entwurf.id }) await abgelehnt(sendeAuftrag(entwurf.id, { von: VON, deps, jetzt: JETZT }), 409, MELDUNG.ersetzteUngueltig) assert.deepEqual([calls.pdf.length, calls.mail.length], [0, 0]) const danach = await getAuftrag(entwurf.id) assert.deepEqual([danach.status, danach.nummer], ['entwurf', '']) assert.deepEqual(dateienIn(uploadDir), []) }) test('storniereAuftrag: versendet und bestätigt werden storniert (ohne Mail); Token, Nummer und PDF bleiben, damit der Link „storniert“ erklären kann', async () => { const { deps, calls } = baueDeps() const a = await gesendet(deps) const b = await storniereAuftrag(a.id) assert.equal(b.status, 'storniert') assert.ok(Math.abs(new Date(b.storniertAm).getTime() - Date.now()) < 60_000) assert.deepEqual([b.nummer, b.tokenHash, b.pdfDatei], [a.nummer, a.tokenHash, a.pdfDatei]) assert.equal((await findAuftragByTokenHash(a.tokenHash)).id, a.id) assert.equal(calls.mail.length, 1) // nur die Mail des Sendens const c = await gesendet(deps) await updateAuftrag(c.id, { status: 'bestaetigt', bestaetigtAm: new Date().toISOString(), bestaetigtUeber: 'link' }) assert.equal((await storniereAuftrag(c.id)).status, 'storniert') }) test('storniereAuftrag: sonst 409 (auch ein zweites Mal), unbekannt 404', async () => { const a = await gesendet(baueDeps().deps) for (const status of ['entwurf', 'abgerechnet', 'ersetzt', 'storniert', 'widerrufen']) { await updateAuftrag(a.id, { status }) await abgelehnt(storniereAuftrag(a.id), 409, MELDUNG.keinStorno, status) } await abgelehnt(storniereAuftrag('a_gibtsnicht'), 404, MELDUNG.auftragFehlt) }) test('storniereAuftrag wartet auf die Sperre der AB (L5 hält sie beim Widerruf) und sieht danach den neuen Stand', async () => { const a = await gesendet(baueDeps().deps) const lauf = await unterFremderSperre(a.id, { aktion: () => storniereAuftrag(a.id), waehrend: async () => { assert.equal((await getAuftrag(a.id)).status, 'versendet') // noch nicht storniert await updateAuftrag(a.id, { status: 'widerrufen', widerrufenAm: new Date().toISOString() }) }, }) assert.deepEqual([lauf.fehler?.status, lauf.fehler?.message], [409, MELDUNG.keinStorno]) assert.equal((await getAuftrag(a.id)).status, 'widerrufen') })