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, VON, MELDUNG, festpreisEingabe, kundeDoc, baueDeps, sha256, abgelehnt, ergebnis, tor, warte, wartebis, dateienIn, } from './helpers/auftraegeAb.js' const umg = await starteUmgebung() const { aw, config, uploadDir } = umg after(() => umg.schliesse()) beforeEach(umg.zuruecksetzen) const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const { getAuftrag, listAuftraege, updateAuftrag } = await import('../server/services/auftraege/auftragStore.js') const { lesePdf } = await import('../server/services/auftraege/ablage.js') const { kundeSnapshot } = await import('../server/services/auftraege/auftragSicht.js') const { TEXTE_FASSUNG } = await import('../server/services/auftraege/texte.js') const { AGB_FASSUNG, TOKEN_GUELTIG_TAGE } = await import('../server/services/auftraege/konstanten.js') const { AuftragError } = await import('../server/services/auftraege/fehler.js') const { legeEntwurfAn, aendereEntwurf, loescheEntwurf, sendeAuftrag } = await import('../server/services/auftraege/auftraege.js') const JETZT = new Date('2026-10-06T10:00:00Z') // Berlin: 12:00 const neuerEntwurf = (patch) => legeEntwurfAn(festpreisEingabe(patch), { von: VON }) const sende = (id, deps, extra = {}) => sendeAuftrag(id, { von: VON, deps, jetzt: JETZT, ...extra }) const VOR_DER_NUMMER = [ ['Interessent', { customerStatus: 'lead' }, 400, MELDUNG.keinFesterKunde], ['verlorener Kunde (Schreibweise egal)', { customerStatus: ' Lost ' }, 400, MELDUNG.keinFesterKunde], ['Kundenart fehlt', { kundenart: '' }, 400, MELDUNG.keineKundenart], ['Kundenart unbekannt', { kundenart: 'firma' }, 400, MELDUNG.keineKundenart], ['E-Mail fehlt', { email: '' }, 400, MELDUNG.keineMail], ['E-Mail nur Leerzeichen', { email: ' ' }, 400, MELDUNG.keineMail], ['Straße fehlt', { street: '' }, 409, MELDUNG.adresse], ['PLZ nur Leerzeichen', { postalCode: ' ' }, 409, MELDUNG.adresse], ['Ort fehlt', { city: '' }, 409, MELDUNG.adresse], ['Interessent hat Vorrang vor allem anderen', { customerStatus: 'lead', kundenart: '', email: '', street: '' }, 400, MELDUNG.keinFesterKunde], ['Kundenart kommt vor E-Mail und Anschrift', { kundenart: '', email: '', street: '' }, 400, MELDUNG.keineKundenart], ['E-Mail kommt vor der Anschrift', { email: '', street: '' }, 400, MELDUNG.keineMail], ] test('Senden: unvollständige Kundendaten werden vor der Nummer abgelehnt – nichts wird erzeugt, gespeichert oder verbraucht', async () => { const entwurf = await neuerEntwurf() const { deps, calls } = baueDeps() for (const [name, patch, status, meldung] of VOR_DER_NUMMER) { aw.seed(config.collections.customers, kundeDoc(patch)) await abgelehnt(sende(entwurf.id, deps), status, meldung, name) } assert.deepEqual([calls.pdf.length, calls.token.length, calls.mail.length], [0, 0, 0]) assert.deepEqual(dateienIn(uploadDir), []) const unveraendert = await getAuftrag(entwurf.id) assert.deepEqual([unveraendert.status, unveraendert.nummer, unveraendert.jahr], ['entwurf', '', null]) aw.seed(config.collections.customers, kundeDoc()) // der Kunde ist wieder vollständig: die erste echte Nummer ist 0001 assert.equal((await sende(entwurf.id, deps)).auftrag.nummer, 'AB-2026-0001') }) test('Senden: der Entwurf wird vor der Nummer noch einmal geprüft (beschädigte Daten ergeben die Feldmeldung)', async () => { const entwurf = await neuerEntwurf() await updateAuftrag(entwurf.id, { positionen: [] }) await abgelehnt(sende(entwurf.id, baueDeps().deps), 400, MELDUNG.positionen) }) test('Senden: Nummer, eingefrorener Kunde, PDF mit Prüfsumme, Token und Mail', async () => { aw.seed(config.collections.financeSettings, { $id: 'main', steuernummer: '12/345/67890', bankDetailsText: 'IBAN DE00 0000 0000 0000 0000 00' }) const entwurf = await neuerEntwurf() const { deps, calls } = baueDeps() const { auftrag, mailFehler } = await sende(entwurf.id, deps) assert.equal(mailFehler, false) assert.deepEqual([auftrag.id, auftrag.status, auftrag.nummer, auftrag.jahr, auftrag.laufnummer], [entwurf.id, 'versendet', 'AB-2026-0001', 2026, 1]) assert.equal(new Date(auftrag.gesendetAm).getTime(), JETZT.getTime()) assert.equal(auftrag.gesendetAn, 'anna@example.invalid') assert.deepEqual(auftrag.kunde, kundeSnapshot(kundeDoc())) assert.equal(auftrag.kunde.kundenart, 'unternehmen') assert.deepEqual([auftrag.texteFassung, auftrag.agbFassung], [TEXTE_FASSUNG, AGB_FASSUNG]) assert.deepEqual([auftrag.mailStatus, auftrag.mailFehler], ['gesendet', '']) // PDF: einmal mit der Nummer und dem Kunden-Schnappschuss erzeugt, abgelegt, Prüfsumme gespeichert const pdf = Buffer.from('%PDF-1.4 AB-2026-0001') assert.equal(calls.pdf.length, 1) assert.equal(calls.pdf[0].auftrag.nummer, 'AB-2026-0001') assert.deepEqual(calls.pdf[0].auftrag.kunde, kundeSnapshot(kundeDoc())) assert.deepEqual(calls.pdf[0].optionen, { settings: { steuernummer: '12/345/67890', bankDetailsText: 'IBAN DE00 0000 0000 0000 0000 00' } }) assert.equal(auftrag.pdfDatei, `${KUNDE_ID}/${entwurf.id}.pdf`) assert.equal(auftrag.pdfSha256, sha256(pdf)) assert.deepEqual(await lesePdf(auftrag.pdfDatei), pdf) // Token: im Dokument nur der Hash, 60 Tage gültig ab dem Versand assert.equal(calls.token.length, 1) assert.equal(auftrag.tokenHash, sha256(calls.token[0])) assert.equal(new Date(auftrag.tokenGueltigBis).getTime(), JETZT.getTime() + TOKEN_GUELTIG_TAGE * 86_400_000) // Mail: einmal, mit dem gespeicherten Dokument, dem PDF und dem Klartext-Token assert.equal(calls.mail.length, 1) assert.equal(calls.mail[0].auftrag.nummer, 'AB-2026-0001') assert.deepEqual(calls.mail[0].pdf, pdf) assert.deepEqual([calls.mail[0].token, calls.mail[0].erinnerung], [calls.token[0], false]) assert.deepEqual(await getAuftrag(entwurf.id), auftrag) }) test('Senden: Nummern zählen fortlaufend, im neuen Berliner Jahr beginnt wieder 0001', async () => { const { deps } = baueDeps() const nummern = [] for (let i = 0; i < 2; i += 1) nummern.push((await sende((await neuerEntwurf()).id, deps)).auftrag.nummer) const neujahr = await sende((await neuerEntwurf()).id, deps, { jetzt: new Date('2026-12-31T23:30:00Z') }) assert.deepEqual(nummern, ['AB-2026-0001', 'AB-2026-0002']) assert.deepEqual([neujahr.auftrag.nummer, neujahr.auftrag.jahr, neujahr.auftrag.laufnummer], ['AB-2027-0001', 2027, 1]) assert.equal(new Date(neujahr.auftrag.gesendetAm).toISOString(), '2026-12-31T23:30:00.000Z') }) test('Senden: nur Entwürfe (409), unbekannte Aufträge (404) und Kunden (404)', async () => { const { deps } = baueDeps() const entwurf = await neuerEntwurf() await sende(entwurf.id, deps) await abgelehnt(sende(entwurf.id, deps), 409, MELDUNG.nurEntwurfSenden) await abgelehnt(sende('a_gibtsnicht', deps), 404, MELDUNG.auftragFehlt) const zweiter = await neuerEntwurf() await deleteDocument(config.collections.customers, KUNDE_ID) await abgelehnt(sende(zweiter.id, deps), 404, MELDUNG.kundeFehlt) }) test('Senden: scheitert die PDF-Erzeugung (Fehler, leer oder unvollständiger Entwurf), bleibt alles wie vorher und die Nummer ist nicht verbraucht', async () => { mock.method(console, 'error', () => {}) const entwurf = await neuerEntwurf() const faelle = [ [baueDeps({ pdfFehler: new Error('pdfkit kaputt') }), 500, MELDUNG.pdf], [baueDeps({ pdf: Buffer.alloc(0) }), 500, MELDUNG.pdf], [baueDeps({ pdfFehler: new AuftragError(409, 'Entwurf unvollständig: Anschrift fehlt.') }), 409, 'Entwurf unvollständig: Anschrift fehlt.'], ] for (const [fehlerhaft, status, meldung] of faelle) { await abgelehnt(sende(entwurf.id, fehlerhaft.deps), status, meldung) assert.deepEqual([fehlerhaft.calls.token.length, fehlerhaft.calls.mail.length], [0, 0]) } assert.deepEqual(dateienIn(uploadDir), []) const danach = await getAuftrag(entwurf.id) assert.deepEqual([danach.status, danach.nummer, danach.jahr], ['entwurf', '', null]) assert.ok(!danach.pdfDatei && !danach.tokenHash) assert.equal((await sende(entwurf.id, baueDeps().deps)).auftrag.nummer, 'AB-2026-0001') }) test('Senden: eine schon vorhandene Zieldatei wird nie überschrieben – Fehler, Entwurf und Datei bleiben', async () => { mock.method(console, 'error', () => {}) const entwurf = await neuerEntwurf() const ziel = path.join(uploadDir, KUNDE_ID, `${entwurf.id}.pdf`) fs.mkdirSync(path.dirname(ziel), { recursive: true }) fs.writeFileSync(ziel, 'Rest eines Absturzes') const { deps, calls } = baueDeps() await abgelehnt(sende(entwurf.id, deps), 500, MELDUNG.pdf) assert.equal(fs.readFileSync(ziel, 'utf8'), 'Rest eines Absturzes') assert.equal(calls.mail.length, 0) assert.equal((await getAuftrag(entwurf.id)).status, 'entwurf') }) test('Senden: scheitert das Speichern der Auftragsbestätigung, wird die eben abgelegte Datei entfernt und das Senden lässt sich wiederholen', async () => { mock.method(console, 'error', () => {}) const entwurf = await neuerEntwurf() const { deps, calls } = baueDeps() aw.failNext({ collection: config.collections.auftraege, method: 'PATCH', status: 500 }) await assert.rejects(sende(entwurf.id, deps), (err) => err.status === 500) assert.equal(calls.mail.length, 0) assert.deepEqual(dateienIn(uploadDir), []) assert.equal((await getAuftrag(entwurf.id)).status, 'entwurf') const { auftrag } = await sende(entwurf.id, deps) assert.equal(auftrag.nummer, 'AB-2026-0001') assert.deepEqual(dateienIn(uploadDir), [`${KUNDE_ID}/${entwurf.id}.pdf`]) }) test('Senden: scheitert nur die Mail, bleibt die AB versendet (mailStatus fehler) und der Fehlertext verrät nichts Internes', async () => { mock.method(console, 'error', () => {}) const [e1, e2] = [await neuerEntwurf(), await neuerEntwurf()] const mitCode = baueDeps({ mailFehler: Object.assign(new Error('535 Login smtp.example.invalid'), { code: 'EAUTH' }) }) const r1 = await sende(e1.id, mitCode.deps) assert.equal(r1.mailFehler, true) assert.deepEqual( [r1.auftrag.status, r1.auftrag.nummer, r1.auftrag.mailStatus, r1.auftrag.mailFehler], ['versendet', 'AB-2026-0001', 'fehler', 'E-Mail konnte nicht gesendet werden (EAUTH).'], ) assert.ok(r1.auftrag.pdfDatei && r1.auftrag.tokenHash) assert.deepEqual(await getAuftrag(e1.id), r1.auftrag) const ohneCode = baueDeps({ mailFehler: new Error('Verbindung zu mail.example.invalid abgebrochen') }) const r2 = await sende(e2.id, ohneCode.deps) assert.deepEqual([r2.auftrag.nummer, r2.auftrag.mailStatus, r2.auftrag.mailFehler], ['AB-2026-0002', 'fehler', 'E-Mail konnte nicht gesendet werden.']) }) test('Senden: zwei Entwürfe gleichzeitig bekommen verschiedene Nummern', async () => { const [a, b] = [await neuerEntwurf(), await neuerEntwurf()] const { deps } = baueDeps({ pdfWarten: warte(30) }) const ergebnisse = await Promise.all([sende(a.id, deps), sende(b.id, deps)]) assert.deepEqual(ergebnisse.map((e) => e.auftrag.nummer).sort(), ['AB-2026-0001', 'AB-2026-0002']) assert.equal(new Set(ergebnisse.map((e) => e.auftrag.pdfDatei)).size, 2) }) test('Senden: dieselbe AB zweimal gleichzeitig (Doppelklick) – einmal gesendet, das zweite Mal 409, genau eine Nummer und eine Mail', async () => { const entwurf = await neuerEntwurf() const { deps, calls } = baueDeps({ pdfWarten: warte(30) }) const ergebnisse = await Promise.allSettled([sende(entwurf.id, deps), sende(entwurf.id, deps)]) assert.deepEqual(ergebnisse.map((r) => r.status).sort(), ['fulfilled', 'rejected']) const abgewiesen = ergebnisse.find((r) => r.status === 'rejected').reason assert.deepEqual([abgewiesen.status, abgewiesen.message], [409, MELDUNG.nurEntwurfSenden]) assert.deepEqual([calls.pdf.length, calls.mail.length], [1, 1]) assert.deepEqual((await listAuftraege({ jahr: 2026 })).map((a) => a.nummer), ['AB-2026-0001']) }) test('Senden hält die Sperre: Ändern und Löschen währenddessen warten und scheitern danach mit 409', async () => { const entwurf = await neuerEntwurf() const pdfTor = tor() const { deps, calls } = baueDeps({ pdfWarten: pdfTor.promise }) const senden = ergebnis(sende(entwurf.id, deps)) await wartebis(() => calls.pdf.length === 1) // das Senden hält die Sperre und wartet auf das PDF const aendern = ergebnis(aendereEntwurf(entwurf.id, { titel: 'Heimlich geändert' })) const loeschen = ergebnis(loescheEntwurf(entwurf.id)) await warte(30) // beide sind unterwegs, dürfen aber noch nichts geschrieben haben assert.equal((await getAuftrag(entwurf.id)).titel, 'Website Beispiel') pdfTor.freigeben() const [s, a, l] = await Promise.all([senden, aendern, loeschen]) assert.equal(s.wert.auftrag.status, 'versendet') assert.deepEqual([a.fehler?.status, a.fehler?.message], [409, MELDUNG.nurEntwurfAendern]) assert.deepEqual([l.fehler?.status, l.fehler?.message], [409, MELDUNG.nurEntwurfLoeschen]) const danach = await getAuftrag(entwurf.id) assert.deepEqual([danach.titel, danach.status], ['Website Beispiel', 'versendet']) }) test('Senden: während die Mail unterwegs ist, ist die Sperre schon frei (die nächste AB bekommt ihre Nummer)', async () => { const [a, b] = [await neuerEntwurf(), await neuerEntwurf()] const mailTor = tor() const langsam = baueDeps({ mailWarten: mailTor.promise }) const ersteMail = ergebnis(sende(a.id, langsam.deps)) await wartebis(() => langsam.calls.mail.length === 1) const zweite = await sende(b.id, baueDeps().deps) // läuft durch, obwohl die Mail der ersten noch hängt assert.equal(zweite.auftrag.nummer, 'AB-2026-0002') mailTor.freigeben() assert.equal((await ersteMail).wert.auftrag.nummer, 'AB-2026-0001') })