Files
Webklar-Kundenbereich/test/auftraegeAbFolge.test.js
T

227 lines
13 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, 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), [])
})