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

389 lines
24 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), [])
})
test('storniereAuftrag: versendet und bestätigt werden storniert (ohne Mail); Token, Nummer und PDF bleiben, damit der Link „storniert“ erklären kann', async () => {
const { deps, calls } = baueDeps()
const a = await gesendet(deps)
const b = await storniereAuftrag(a.id)
assert.equal(b.status, 'storniert')
assert.ok(Math.abs(new Date(b.storniertAm).getTime() - Date.now()) < 60_000)
assert.deepEqual([b.nummer, b.tokenHash, b.pdfDatei], [a.nummer, a.tokenHash, a.pdfDatei])
assert.equal((await findAuftragByTokenHash(a.tokenHash)).id, a.id)
assert.equal(calls.mail.length, 1) // nur die Mail des Sendens
const c = await gesendet(deps)
await updateAuftrag(c.id, { status: 'bestaetigt', bestaetigtAm: new Date().toISOString(), bestaetigtUeber: 'link' })
assert.equal((await storniereAuftrag(c.id)).status, 'storniert')
})
test('storniereAuftrag: sonst 409 (auch ein zweites Mal), unbekannt 404', async () => {
const a = await gesendet(baueDeps().deps)
for (const status of ['entwurf', 'abgerechnet', 'ersetzt', 'storniert', 'widerrufen']) {
await updateAuftrag(a.id, { status })
await abgelehnt(storniereAuftrag(a.id), 409, MELDUNG.keinStorno, status)
}
await abgelehnt(storniereAuftrag('a_gibtsnicht'), 404, MELDUNG.auftragFehlt)
})
test('storniereAuftrag wartet auf die Sperre der AB (L5 hält sie beim Widerruf) und sieht danach den neuen Stand', async () => {
const a = await gesendet(baueDeps().deps)
const lauf = await unterFremderSperre(a.id, {
aktion: () => storniereAuftrag(a.id),
waehrend: async () => {
assert.equal((await getAuftrag(a.id)).status, 'versendet') // noch nicht storniert
await updateAuftrag(a.id, { status: 'widerrufen', widerrufenAm: new Date().toISOString() })
},
})
assert.deepEqual([lauf.fehler?.status, lauf.fehler?.message], [409, MELDUNG.keinStorno])
assert.equal((await getAuftrag(a.id)).status, 'widerrufen')
})
const OHNE_PROJEKT = { projektId: '', titel: 'Beratung ohne Projekt' }
const ZWEITES_PROJEKT = 'p_zweites02'
const legeZweitesProjektAn = () => aw.seed(config.collections.websiteProjects, projektDoc({ $id: ZWEITES_PROJEKT, projectName: 'Zweites Projekt', ticketId: ZWEITES_TICKET }))
/** Alle Felder außer den genannten (für den Vergleich „sonst hat sich nichts geändert“). */
const ohne = (auftrag, ...felder) => Object.fromEntries(Object.entries(auftrag).filter(([name]) => !felder.includes(name)))
test('ordneProjektZu: eine versendete AB ohne Projekt bekommt Projekt und Zeitquelle des Projekts; Nummer, Status, PDF, Prüfsumme, Token und Versanddaten bleiben', async () => {
const { deps, calls } = baueDeps()
const alt = await gesendet(deps, OHNE_PROJEKT)
assert.deepEqual([alt.projektId, alt.zeitquelle], ['', []])
const neu = await ordneProjektZu(alt.id, PROJEKT_ID)
assert.deepEqual([neu.projektId, neu.zeitquelle], [PROJEKT_ID, [TICKET_ID]])
assert.deepEqual(ohne(neu, 'projektId', 'zeitquelle', 'geaendertAm'), ohne(alt, 'projektId', 'zeitquelle', 'geaendertAm'))
assert.deepEqual(await getAuftrag(alt.id), neu)
assert.equal((await findAuftragByTokenHash(alt.tokenHash)).id, alt.id) // der Link gilt weiter
assert.deepEqual(dateienIn(uploadDir), [alt.pdfDatei]) // keine neue Datei
assert.deepEqual(await lesePdf(alt.pdfDatei), Buffer.from(`%PDF-1.4 ${alt.nummer}`))
assert.deepEqual([calls.pdf.length, calls.token.length, calls.mail.length], [1, 1, 1]) // nichts neu erzeugt, keine Mail
})
/** Projektrechnung einer AB, wie L2 sie anlegt (hier ohne Projekt); die Rechnungs-ID muss zu /^in_\w{1,33}$/ passen. */
const projektRechnung = (auftragId, patch = {}) => ({
kundenId: KUNDE_ID, projektId: '', auftragId, art: 'anzahlung', betragCents: 40000, arbeitsblattIds: [],
rechnungsnummer: 'WEBKLAR-0007', aktiv: true, erstelltVon: VON, ...patch,
})
test('ordneProjektZu: hängt auch die Projektrechnungen der AB ohne Projekt um (aktive und stornierte); schon gesetzte Projekte, andere ABs und alle übrigen Felder bleiben', async () => {
const a = await gesendet(baueDeps().deps, OHNE_PROJEKT)
const andere = await gesendet(baueDeps().deps, OHNE_PROJEKT)
await createProjektRechnung('in_OhneProjekt1', projektRechnung(a.id))
await createProjektRechnung('in_OhneProjekt2', projektRechnung(a.id, { aktiv: false, rechnungsnummer: '' })) // storniert
await createProjektRechnung('in_MitProjekt', projektRechnung(a.id, { projektId: 'p_anderes', art: 'aufwand' }))
await createProjektRechnung('in_AndereAb', projektRechnung(andere.id))
const mitProjekt = await getProjektRechnung('in_MitProjekt')
const vorher = await getProjektRechnung('in_OhneProjekt1')
await ordneProjektZu(a.id, PROJEKT_ID)
const nachher = await getProjektRechnung('in_OhneProjekt1')
assert.equal(nachher.projektId, PROJEKT_ID)
assert.deepEqual(ohne(nachher, 'projektId'), ohne(vorher, 'projektId')) // sonst hat sich nichts geändert
assert.equal((await getProjektRechnung('in_OhneProjekt2')).projektId, PROJEKT_ID, 'auch eine stornierte Projektrechnung')
assert.deepEqual(await getProjektRechnung('in_MitProjekt'), mitProjekt) // schon ein anderes Projekt: bleibt
assert.equal((await getProjektRechnung('in_AndereAb')).projektId, '') // andere AB: bleibt
assert.equal((await getAuftrag(a.id)).projektId, PROJEKT_ID)
})
test('ordneProjektZu: scheitert das Umhängen einer Projektrechnung, bleibt die AB ohne Projekt, und die Wiederholung führt alles zu Ende', async () => {
mock.method(console, 'error', () => {})
const a = await gesendet(baueDeps().deps, OHNE_PROJEKT)
await createProjektRechnung('in_Wiederholung1', projektRechnung(a.id))
await createProjektRechnung('in_Wiederholung2', projektRechnung(a.id))
aw.failNext({ collection: config.collections.projektRechnungen, method: 'PATCH', status: 500, after: 1 }) // die erste geht durch, die zweite scheitert
await assert.rejects(ordneProjektZu(a.id, PROJEKT_ID), (err) => err.status === 500)
assert.deepEqual([(await getAuftrag(a.id)).projektId, (await getProjektRechnung('in_Wiederholung1')).projektId, (await getProjektRechnung('in_Wiederholung2')).projektId], ['', PROJEKT_ID, ''])
assert.equal((await ordneProjektZu(a.id, PROJEKT_ID)).projektId, PROJEKT_ID) // kein 409: die AB hatte noch kein Projekt
for (const id of ['in_Wiederholung1', 'in_Wiederholung2']) assert.equal((await getProjektRechnung(id)).projektId, PROJEKT_ID, id)
})
test('ordneProjektZu: auch ein Entwurf bekommt das Projekt (der Titel bleibt); eine gewählte Zeitquelle bleibt; ein Projekt ohne Ticket ergibt keine Leereinträge', async () => {
const entwurf = await legeEntwurfAn(festpreisEingabe({ ...OHNE_PROJEKT, titel: 'Freier Titel', zeitquelle: [ZWEITES_TICKET] }), { von: VON })
const a = await ordneProjektZu(entwurf.id, PROJEKT_ID)
assert.deepEqual([a.status, a.nummer, a.projektId, a.titel, a.zeitquelle], ['entwurf', '', PROJEKT_ID, 'Freier Titel', [ZWEITES_TICKET]])
aw.seed(config.collections.websiteProjects, projektDoc({ $id: 'p_ohneticket', projectName: 'Projekt ohne Ticket', ticketId: '' }))
const frei = await legeEntwurfAn(festpreisEingabe(OHNE_PROJEKT), { von: VON })
const b = await ordneProjektZu(frei.id, 'p_ohneticket')
assert.deepEqual([b.projektId, b.zeitquelle], ['p_ohneticket', []])
})
test('ordneProjektZu: fehlende oder ungültige Kennung, Projekt eines anderen Kunden und unbekanntes Projekt sind 400, eine unbekannte AB ist 404 – nichts ändert sich', async () => {
const a = await gesendet(baueDeps().deps, OHNE_PROJEKT)
const faelle = [
['Kennung fehlt', undefined, MELDUNG.projektFehlt],
['Kennung nur Leerzeichen', ' ', MELDUNG.projektFehlt],
['Kennung keine Zeichenkette', 5, MELDUNG.projektFehlt],
['Kennung mit ../', '../x', MELDUNG.projektUngueltig],
['Projekt eines anderen Kunden', FREMD.projekt, MELDUNG.projektFremd],
['unbekanntes Projekt', 'p_gibtsnicht', MELDUNG.projektFremd],
]
for (const [name, projektId, meldung] of faelle) await abgelehnt(ordneProjektZu(a.id, projektId), 400, meldung, name)
await abgelehnt(ordneProjektZu('a_gibtsnicht', PROJEKT_ID), 404, MELDUNG.auftragFehlt)
await abgelehnt(ordneProjektZu('../x', PROJEKT_ID), 404, MELDUNG.auftragFehlt)
assert.deepEqual(await getAuftrag(a.id), a)
})
test('ordneProjektZu: eine AB mit Projekt (auch dasselbe) sowie eine stornierte, ersetzte oder widerrufene AB lassen sich nicht zuordnen (409); versendet, bestätigt und abgerechnet gehen', async () => {
const mit = await gesendet(baueDeps().deps) // gehört schon zu PROJEKT_ID
legeZweitesProjektAn()
await abgelehnt(ordneProjektZu(mit.id, PROJEKT_ID), 409, MELDUNG.schonProjekt, 'dasselbe Projekt')
await abgelehnt(ordneProjektZu(mit.id, ZWEITES_PROJEKT), 409, MELDUNG.schonProjekt, 'anderes Projekt')
await abgelehnt(ordneProjektZu(mit.id, FREMD.projekt), 409, MELDUNG.schonProjekt, 'fremdes Projekt: der Stand der AB gilt zuerst')
assert.deepEqual(await getAuftrag(mit.id), mit)
const frei = await gesendet(baueDeps().deps, OHNE_PROJEKT)
for (const status of ['storniert', 'ersetzt', 'widerrufen']) {
await updateAuftrag(frei.id, { status })
await abgelehnt(ordneProjektZu(frei.id, PROJEKT_ID), 409, MELDUNG.keinProjekt, status)
}
assert.equal((await getAuftrag(frei.id)).projektId, '')
for (const status of ['versendet', 'bestaetigt', 'abgerechnet']) {
await updateAuftrag(frei.id, { status, projektId: '', zeitquelle: [] })
assert.equal((await ordneProjektZu(frei.id, PROJEKT_ID)).projektId, PROJEKT_ID, status)
}
})
test('ordneProjektZu wartet auf die Sperre der AB und prüft den Stand danach (L5 widerruft gerade: 409)', async () => {
const a = await gesendet(baueDeps().deps, OHNE_PROJEKT)
const lauf = await unterFremderSperre(a.id, {
aktion: () => ordneProjektZu(a.id, PROJEKT_ID),
waehrend: async () => {
assert.equal((await getAuftrag(a.id)).projektId, '') // noch nichts geschrieben
await updateAuftrag(a.id, { status: 'widerrufen', widerrufenAm: new Date().toISOString() })
},
})
assert.deepEqual([lauf.fehler?.status, lauf.fehler?.message], [409, MELDUNG.keinProjekt])
assert.equal((await getAuftrag(a.id)).projektId, '')
})
test('ordneProjektZu: zwei Aufrufe gleichzeitig (zwei Admins) – genau einer gelingt, der andere ist 409', async () => {
const a = await gesendet(baueDeps().deps, OHNE_PROJEKT)
legeZweitesProjektAn()
const ergebnisse = await Promise.allSettled([ordneProjektZu(a.id, PROJEKT_ID), ordneProjektZu(a.id, ZWEITES_PROJEKT)])
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.schonProjekt])
const gewinner = ergebnisse.find((r) => r.status === 'fulfilled').value
assert.deepEqual(await getAuftrag(a.id), gewinner)
})