import { test, after, beforeEach, afterEach, mock } from 'node:test' import assert from 'node:assert/strict' import { startAppwriteMock } from './support/appwriteMock.js' import { useTestEnv } from './support/testEnv.js' import { mailSink } from './support/mailSink.js' import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, UNGUELTIG, TOKEN_NEU, abDokument, kunde } from './auftraegeBestaetigungHilfe.js' // bestaetigung.js: Bestätigen (idempotent, unter Sperre) und Widerrufen einer AB; Mails über die Senke const aw = await startAppwriteMock({ schema: AUFTRAEGE_SCHEMA, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 }) useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) const { config } = await import('../server/config.js') const { auftragToDoc, getAuftrag, updateAuftrag } = await import('../server/services/auftraege/auftragStore.js') const { AuftragError } = await import('../server/services/auftraege/fehler.js') const { FRUEHBEGINN_FASSUNG, FRUEHBEGINN_TEXT } = await import('../server/services/auftraege/texte.js') const { sperreAuftrag } = await import('../server/services/auftraege/konstanten.js') const { withLocks } = await import('../server/services/keyedLock.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const { bestaetigeAuftrag, bestaetigungsLage, widerrufeAuftrag } = await import('../server/services/auftraege/bestaetigung.js') after(() => { setMailTransportForTests(null) return aw.close() }) const JETZT = new Date('2026-10-07T08:30:00.000Z') // 10:30 Uhr in Berlin (MESZ) const ms = (iso) => new Date(iso).getTime() let sink let protokoll beforeEach(() => { sink = mailSink() setMailTransportForTests(sink) protokoll = mock.method(console, 'error', () => {}) }) afterEach(() => mock.restoreAll()) const seed = (over = {}) => { const ab = abDokument(over) aw.seed('auftraege', { $id: ab.id, ...auftragToDoc(ab) }) return ab } const betreffe = () => sink.sent.map((mail) => mail.subject) const K32 = 'Ihre Bestätigung zu AB-2026-0001 ist eingegangen' const T22 = '[Aufträge] Auftrag AB-2026-0001 bestätigt: Erika Muster' const norm = (text) => text.replace(/\s+/g, ' ').trim() test('bestaetigungsLage: offen, bestaetigt oder gesperrt', () => { const lage = (over) => bestaetigungsLage(abDokument(over)) assert.equal(lage({}), 'offen') assert.equal(lage({ status: 'abgerechnet' }), 'offen') assert.equal(lage({ status: 'abgerechnet', bestaetigtAm: '2026-10-07T08:30:00.000Z' }), 'bestaetigt') assert.equal(lage({ status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z' }), 'bestaetigt') for (const status of ['entwurf', 'ersetzt', 'storniert', 'widerrufen']) assert.equal(lage({ status, bestaetigtAm: '2026-10-07T08:30:00.000Z' }), 'gesperrt', status) }) test('bestaetigeAuftrag: Privatperson über den Link mit Frühbeginn – Protokoll am Dokument, K32 und T22', async () => { const ab = seed() const neu = await bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: true, jetzt: JETZT }) assert.equal(neu.status, 'bestaetigt') assert.equal(ms(neu.bestaetigtAm), JETZT.getTime()) assert.equal(neu.bestaetigtUeber, 'link') assert.equal(neu.bestaetigtPdfSha256, ab.pdfSha256) assert.equal(neu.fruehbeginn, true) assert.equal(neu.fruehbeginnFassung, FRUEHBEGINN_FASSUNG) const doc = await getAuftrag('ab1') assert.deepEqual([doc.status, doc.bestaetigtUeber, doc.fruehbeginn, ms(doc.bestaetigtAm)], ['bestaetigt', 'link', true, JETZT.getTime()]) assert.deepEqual(betreffe(), [K32, T22]) assert.ok(norm(sink.sent[0].text).includes(norm(FRUEHBEGINN_TEXT)), 'K32 gibt die Erklärung wörtlich wieder') assert.ok(sink.sent[1].text.includes(`${config.womsBaseUrl}/customers/c1?tab=invoices`)) }) test('bestaetigeAuftrag: Privatperson über das Portal ohne Frühbeginn – frühester Beginn nach der Widerrufsfrist', async () => { const neu = await bestaetigeAuftrag(seed(), { ueber: 'portal', fruehbeginn: false, jetzt: JETZT }) assert.equal(neu.bestaetigtUeber, 'portal') assert.equal(neu.fruehbeginn, false) assert.equal(neu.fruehbeginnFassung, FRUEHBEGINN_FASSUNG, 'die angezeigte Fassung der Erklärung wird auch ohne Haken festgehalten') assert.ok(sink.sent[0].text.includes('Wir beginnen daher frühestens am 21.10.2026 mit der Arbeit.')) assert.ok(sink.sent[1].text.includes('Arbeitsbeginn frühestens am 21.10.2026')) assert.ok(sink.sent[1].text.includes('Weg: Kundenportal')) }) test('bestaetigeAuftrag: Unternehmen – fruehbeginn wird ignoriert, keine Beginn-Hinweise in K32 und T22', async () => { const neu = await bestaetigeAuftrag(seed({ kunde: kunde('unternehmen') }), { ueber: 'link', fruehbeginn: true, jetzt: JETZT }) assert.equal(neu.fruehbeginn, false) assert.equal(neu.fruehbeginnFassung, '') for (const mail of sink.sent) assert.doesNotMatch(mail.text, /Beginn der Arbeiten|frühestens|Erklärung zum früheren Beginn|Die Arbeit darf sofort/) assert.equal(sink.sent[1].subject, '[Aufträge] Auftrag AB-2026-0001 bestätigt: Muster GmbH') }) test('bestaetigeAuftrag: abgerechnete AB ohne frühere Bestätigung – Status bleibt abgerechnet', async () => { const neu = await bestaetigeAuftrag(seed({ status: 'abgerechnet' }), { ueber: 'portal', fruehbeginn: false, jetzt: JETZT }) assert.equal(neu.status, 'abgerechnet') assert.equal(ms(neu.bestaetigtAm), JETZT.getTime()) assert.equal((await getAuftrag('ab1')).status, 'abgerechnet') assert.equal(sink.sent.length, 2) }) test('bestaetigeAuftrag: die zweite Bestätigung ändert nichts und verschickt nichts (die erste zählt, auch bei veraltetem Eingabeobjekt)', async () => { const ab = seed() const erste = await bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: true, jetzt: JETZT }) const zweite = await bestaetigeAuftrag(ab, { ueber: 'portal', fruehbeginn: false, jetzt: new Date(JETZT.getTime() + 3_600_000) }) assert.equal(ms(zweite.bestaetigtAm), ms(erste.bestaetigtAm)) assert.equal(zweite.bestaetigtUeber, 'link') assert.equal(zweite.fruehbeginn, true) assert.equal(sink.sent.length, 2, 'nur die Mails der ersten Bestätigung') }) test('bestaetigeAuftrag: zwei gleichzeitige Bestätigungen (Doppelklick, Link und Portal) – eine zählt, genau zwei Mails', async () => { const ab = seed() const [a, b] = await Promise.all([ bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }), bestaetigeAuftrag(ab, { ueber: 'portal', fruehbeginn: false, jetzt: new Date(JETZT.getTime() + 1000) }), ]) assert.equal(ms(a.bestaetigtAm), ms(b.bestaetigtAm)) assert.equal(a.bestaetigtUeber, 'link', 'der zuerst gestartete Aufruf gewinnt') assert.equal(b.bestaetigtUeber, 'link') assert.equal(sink.sent.length, 2) }) test('bestaetigeAuftrag: läuft unter der Sperre sperreAuftrag(id); K32 und T22 gehen erst nach ihrer Freigabe raus', async () => { const ab = seed() let freigeben const gehalten = new Promise((resolve) => { freigeben = resolve }) const halter = withLocks(sperreAuftrag('ab1'), () => gehalten) let fertig = false const bestaetigt = bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }).then((neu) => { fertig = true return neu }) await new Promise((resolve) => setTimeout(resolve, 100)) assert.equal(fertig, false, 'wartet, solange ein anderer die Sperre der AB hält') assert.equal((await getAuftrag('ab1')).status, 'versendet') freigeben() await halter assert.equal((await bestaetigt).status, 'bestaetigt') // Mails erst nach der Freigabe: im Versand ist die Sperre der AB wieder frei (sonst käme das Rennen nach 300 ms mit false zurück) seed() sink.sent.length = 0 const frei = [] setMailTransportForTests({ async sendMail(mail) { frei.push(await Promise.race([withLocks(sperreAuftrag('ab1'), async () => true), new Promise((resolve) => setTimeout(() => resolve(false), 300))])) return sink.sendMail(mail) }, }) await bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }) assert.deepEqual(frei, [true, true]) assert.equal(sink.sent.length, 2) }) test('bestaetigeAuftrag: Entwurf, ersetzt, storniert und widerrufen sind nicht bestätigbar (409), nichts ändert sich, keine Mail', async () => { const faelle = [['entwurf', 'Diese Auftragsbestätigung kann nicht bestätigt werden.'], ['ersetzt', 'Diese Auftragsbestätigung wurde ersetzt.'], ['storniert', 'Diese Auftragsbestätigung wurde storniert.'], ['widerrufen', 'Diese Auftragsbestätigung wurde widerrufen.']] for (const [status, meldung] of faelle) { const ab = seed({ status }) await assert.rejects(bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }), (err) => err instanceof AuftragError && err.status === 409 && err.message === meldung, status) const doc = await getAuftrag('ab1') assert.deepEqual([doc.status, doc.bestaetigtAm], [status, null], status) } assert.equal(sink.sent.length, 0) }) test('bestaetigeAuftrag: scheitert der Mailversand, bleibt die Bestätigung bestehen (Fehler nur im Protokoll)', async () => { setMailTransportForTests({ async sendMail() { throw new Error('SMTP 451') } }) const neu = await bestaetigeAuftrag(seed(), { ueber: 'link', fruehbeginn: false, jetzt: JETZT }) assert.equal(neu.status, 'bestaetigt') assert.equal((await getAuftrag('ab1')).status, 'bestaetigt') assert.ok(protokoll.mock.calls.some((c) => c.arguments.join(' ').includes('AB-2026-0001')), 'Fehler mit Nummer im Protokoll') }) test('bestaetigeAuftrag: ungültiger Weg oder Zeitpunkt wirft TypeError, unbekannte AB 404', async () => { const ab = seed() await assert.rejects(bestaetigeAuftrag(ab, { ueber: 'telefon', fruehbeginn: false, jetzt: JETZT }), TypeError) await assert.rejects(bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: 'gestern' }), TypeError) await assert.rejects(bestaetigeAuftrag(abDokument({ id: 'gibtsnicht' }), { ueber: 'link', fruehbeginn: false, jetzt: JETZT }), (err) => err instanceof AuftragError && err.status === 404) assert.equal(sink.sent.length, 0) }) test('bestaetigeAuftrag über den Link: ein Token-Tausch (Erinnerung) oder Ablauf zwischen Prüfung und Sperre ergibt 404 wie bei ungültigem Link', async () => { const alt = seed() const nichtsGeaendert = async () => { assert.equal((await getAuftrag('ab1')).status, 'versendet') assert.equal(sink.sent.length, 0) } // die Erinnerung tauscht den Token, nachdem die Route den alten geprüft hat const { hashToken } = await import('../server/services/auftraege/token.js') await updateAuftrag('ab1', { tokenHash: hashToken(TOKEN_NEU) }) await assert.rejects(bestaetigeAuftrag(alt, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }), (err) => err instanceof AuftragError && err.status === 404 && err.message === UNGUELTIG) await nichtsGeaendert() // der Link ist inzwischen abgelaufen await updateAuftrag('ab1', { tokenHash: alt.tokenHash, tokenGueltigBis: '2026-10-07T08:29:59.000Z' }) await assert.rejects(bestaetigeAuftrag(alt, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }), (err) => err instanceof AuftragError && err.status === 404 && err.message === UNGUELTIG) await nichtsGeaendert() // das Portal braucht keinen Link: dort zaehlt der Token nicht assert.equal((await bestaetigeAuftrag(alt, { ueber: 'portal', fruehbeginn: false, jetzt: JETZT })).status, 'bestaetigt') }) // gesendet am 06.10.2026 12:00 Berlin: die Frist endet am 20.10.2026 um 24 Uhr (= 21:59:59,999 UTC) const FRIST_ENDE = new Date('2026-10-20T21:59:59.999Z') test('widerrufeAuftrag: Privatperson – am letzten Tag bis 24 Uhr (eingeschlossen) widerrufen, eine Millisekunde später nach_frist', async () => { const ab = seed() const ok = await widerrufeAuftrag(ab, { jetzt: FRIST_ENDE }) assert.equal(ok.ergebnis, 'widerrufen') assert.equal(ok.auftrag.status, 'widerrufen') assert.equal(ms(ok.auftrag.widerrufenAm), FRIST_ENDE.getTime()) assert.equal(ok.fristEnde.getTime(), FRIST_ENDE.getTime()) assert.equal((await getAuftrag('ab1')).status, 'widerrufen') seed() const spaet = await widerrufeAuftrag(ab, { jetzt: new Date(FRIST_ENDE.getTime() + 1) }) assert.equal(spaet.ergebnis, 'nach_frist') assert.equal(spaet.auftrag.status, 'versendet') assert.equal((await getAuftrag('ab1')).widerrufenAm, null) }) test('widerrufeAuftrag: bestätigte AB wird widerrufen, bestaetigtAm bleibt als Beleg; Unternehmen: kein_widerrufsrecht', async () => { const bestaetigt = seed({ status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z', bestaetigtUeber: 'link', fruehbeginn: true }) const r = await widerrufeAuftrag(bestaetigt, { jetzt: JETZT }) assert.equal(r.ergebnis, 'widerrufen') assert.equal(r.auftrag.fruehbeginn, true) assert.equal(ms((await getAuftrag('ab1')).bestaetigtAm), JETZT.getTime()) const firma = seed({ kunde: kunde('unternehmen') }) const k = await widerrufeAuftrag(firma, { jetzt: JETZT }) assert.equal(k.ergebnis, 'kein_widerrufsrecht') assert.equal((await getAuftrag('ab1')).status, 'versendet') }) test('widerrufeAuftrag: bereits widerrufen, abgerechnet, ersetzt, storniert, Entwurf – nichts ändert sich', async () => { const faelle = [['widerrufen', 'bereits_widerrufen'], ['abgerechnet', 'nicht_widerrufbar'], ['ersetzt', 'nicht_widerrufbar'], ['storniert', 'nicht_widerrufbar'], ['entwurf', 'nicht_widerrufbar']] for (const [status, ergebnis] of faelle) { const ab = seed({ status, widerrufenAm: status === 'widerrufen' ? '2026-10-08T08:00:00.000Z' : null }) const r = await widerrufeAuftrag(ab, { jetzt: JETZT }) assert.equal(r.ergebnis, ergebnis, status) const doc = await getAuftrag('ab1') assert.equal(doc.status, status, status) assert.equal(doc.widerrufenAm === null ? null : ms(doc.widerrufenAm), status === 'widerrufen' ? new Date('2026-10-08T08:00:00.000Z').getTime() : null, status) } }) test('widerrufeAuftrag: wartet auf die Sperre sperreAuftrag(id) und liest den Stand erst darin (veraltetes Eingabeobjekt)', async () => { const veraltet = seed() let freigeben const gehalten = new Promise((resolve) => { freigeben = resolve }) const halter = withLocks(sperreAuftrag('ab1'), () => gehalten) let fertig = false const widerruf = widerrufeAuftrag(veraltet, { jetzt: JETZT }).then((r) => { fertig = true return r }) await new Promise((resolve) => setTimeout(resolve, 100)) assert.equal(fertig, false, 'wartet, solange ein anderer die Sperre der AB hält') seed({ status: 'storniert' }) // währenddessen von Hand storniert (L3) freigeben() await halter const r = await widerruf assert.equal(r.ergebnis, 'nicht_widerrufbar', 'maßgeblich ist der Stand innerhalb der Sperre') assert.equal((await getAuftrag('ab1')).status, 'storniert') assert.equal(sink.sent.length, 0, 'widerrufeAuftrag verschickt keine Mails') }) test('Bestätigen und Widerrufen gleichzeitig: der Widerruf geht nie verloren, in jeder Reihenfolge', async () => { const ab = seed() const [bestaetigt, widerrufen] = await Promise.all([ bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }), widerrufeAuftrag(ab, { jetzt: JETZT }), ]) assert.equal(bestaetigt.status, 'bestaetigt') assert.equal(widerrufen.ergebnis, 'widerrufen') assert.equal((await getAuftrag('ab1')).status, 'widerrufen') seed() const umgekehrt = await Promise.allSettled([ widerrufeAuftrag(ab, { jetzt: JETZT }), bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }), ]) assert.equal(umgekehrt[0].value.ergebnis, 'widerrufen') assert.ok(umgekehrt[1].reason instanceof AuftragError && umgekehrt[1].reason.status === 409) assert.equal((await getAuftrag('ab1')).status, 'widerrufen') })