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>
279 lines
15 KiB
JavaScript
279 lines
15 KiB
JavaScript
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')
|
||
})
|