Files
Webklar-Kundenbereich/test/auftraegeBestaetigung.test.js
T
ClaudeandClaude Sonnet 5.5 b6c1d81342 Kontrolle: Bestaetigen per Link prueft Token und Ablauf in der Sperre erneut
Ein Token-Tausch durch die Erinnerung zwischen Pruefung und Sperre ergibt 404 wie bei
einem ungueltigen Link; das Portal ist nicht betroffen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 15:20:24 +00:00

279 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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')
})