389 lines
24 KiB
JavaScript
389 lines
24 KiB
JavaScript
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)
|
||
})
|