AB-Kern: Eingabeprüfung für Auftragsbestätigungen (pruefeEingabe)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e3df5383d1
commit
ef646f12de
2 files changed
+378
No files matched your search
@@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* Abläufe der Auftragsbestätigung (AB): Eingabeprüfung, Entwurf, Vorschau, Senden, Erinnern, neue Fassung, Stornieren
|
||||||
|
* und nachträgliches Zuordnen eines Projekts (Spec Abschnitt 2, Übersicht V4 „L3 – AB-Kern“).
|
||||||
|
* Speicher nur über auftragStore.js und projektRechnungenStore.js, PDFs nur über ablage.js; PDF, Token und Mail kommen
|
||||||
|
* über `deps` (L4/L5, Standard: dynamischer Import beim Aufruf). Jede Änderung an einer bestehenden AB läuft unter
|
||||||
|
* sperreAuftrag(id) (konstanten.js), das Senden zusätzlich unter SPERRE_NUMMER; Mails gehen immer erst nach der Sperre.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
AUFTRAG_ARTEN, AUFTRAG_WEGE, KUNDENARTEN, LIMITS, STUNDENSATZ_STANDARD_CENTS, VIERTELSTUNDE, ZAHLUNGSPLAN_ARTEN,
|
||||||
|
} from './konstanten.js'
|
||||||
|
import { AuftragError } from './fehler.js'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------------------------------
|
||||||
|
// Eingabeprüfung (rein, synchron)
|
||||||
|
// ---------------------------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Appwrite-Dokument-ID (wie in auftragStore.js). */
|
||||||
|
export function istDokumentId(wert) {
|
||||||
|
return typeof wert === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/.test(wert)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fehler400 = (meldung) => new AuftragError(400, meldung)
|
||||||
|
const MELDUNG_POSITIONEN = 'Eine Auftragsbestätigung braucht 1 bis 20 Positionen.'
|
||||||
|
const MELDUNG_ART = 'Art: Bitte Festpreis oder Nach Aufwand wählen.'
|
||||||
|
const MELDUNG_PLAN = 'Zahlungsplan: Bitte eine Zahlungsart wählen.'
|
||||||
|
const MELDUNG_VOM = 'Auftrag vom: Bitte ein gültiges Datum angeben (JJJJ-MM-TT).'
|
||||||
|
const MELDUNG_WEG = 'Weg: Bitte den Weg der Absprache wählen.'
|
||||||
|
const MELDUNG_SUMME = 'Die Auftragssumme ist zu hoch (höchstens 999.999,99 €).'
|
||||||
|
const PREIS_GRENZE = 'zwischen 0,01 € und 100.000,00 € liegen.'
|
||||||
|
|
||||||
|
const istEinfachesObjekt = (wert) => Object.prototype.toString.call(wert) === '[object Object]'
|
||||||
|
|
||||||
|
function istKalendertag(wert) {
|
||||||
|
if (typeof wert !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(wert)) return false
|
||||||
|
const datum = new Date(`${wert}T00:00:00Z`)
|
||||||
|
return !Number.isNaN(datum.getTime()) && datum.toISOString().slice(0, 10) === wert
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruefePosition(roh, n) {
|
||||||
|
const p = istEinfachesObjekt(roh) ? roh : {}
|
||||||
|
const beschreibung = typeof p.beschreibung === 'string' ? p.beschreibung.trim() : ''
|
||||||
|
if (beschreibung.length < 1 || beschreibung.length > LIMITS.maxBeschreibung) {
|
||||||
|
throw fehler400(`Position ${n}: Die Beschreibung braucht 1 bis ${LIMITS.maxBeschreibung} Zeichen.`)
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(p.menge) || p.menge < 1 || p.menge > LIMITS.maxMenge) {
|
||||||
|
throw fehler400(`Position ${n}: Die Menge muss eine ganze Zahl von 1 bis ${LIMITS.maxMenge} sein.`)
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(p.einzelpreisCents) || p.einzelpreisCents < 1 || p.einzelpreisCents > LIMITS.maxEinzelpreisCents) {
|
||||||
|
throw fehler400(`Position ${n}: Der Einzelpreis muss ${PREIS_GRENZE}`)
|
||||||
|
}
|
||||||
|
return { beschreibung, menge: p.menge, einzelpreisCents: p.einzelpreisCents }
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruefePlan(roh) {
|
||||||
|
if (!istEinfachesObjekt(roh) || !ZAHLUNGSPLAN_ARTEN.includes(roh.art)) throw fehler400(MELDUNG_PLAN)
|
||||||
|
let prozent = null
|
||||||
|
let text = ''
|
||||||
|
if (roh.art === 'anzahlung') {
|
||||||
|
prozent = roh.prozent === undefined || roh.prozent === null ? LIMITS.standardAnzahlungProzent : roh.prozent
|
||||||
|
if (!Number.isInteger(prozent) || prozent < 1 || prozent > 99) throw fehler400('Zahlungsplan: Die Anzahlung muss eine ganze Zahl von 1 bis 99 Prozent sein.')
|
||||||
|
}
|
||||||
|
if (roh.art === 'frei') {
|
||||||
|
text = typeof roh.text === 'string' ? roh.text.trim() : ''
|
||||||
|
if (text.length < 1 || text.length > LIMITS.maxZahlungsplanText) throw fehler400(`Zahlungsplan: Der freie Text braucht 1 bis ${LIMITS.maxZahlungsplanText} Zeichen.`)
|
||||||
|
}
|
||||||
|
return { art: roh.art, prozent, text }
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruefeOptionalesDatum(wert, meldung) {
|
||||||
|
if (wert === null || wert === '') return ''
|
||||||
|
if (!istKalendertag(wert)) throw fehler400(meldung)
|
||||||
|
return wert
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feldregeln: jedes übergebene Feld (Wert !== undefined) wird geprüft und bereinigt. */
|
||||||
|
const FELDREGELN = {
|
||||||
|
art: (w) => {
|
||||||
|
if (!AUFTRAG_ARTEN.includes(w)) throw fehler400(MELDUNG_ART)
|
||||||
|
return w
|
||||||
|
},
|
||||||
|
projektId: (w) => {
|
||||||
|
if (w === null || w === '') return ''
|
||||||
|
if (!istDokumentId(w)) throw fehler400('Projekt: Ungültige Projekt-Kennung.')
|
||||||
|
return w
|
||||||
|
},
|
||||||
|
titel: (w) => {
|
||||||
|
if (w === null) return ''
|
||||||
|
if (typeof w !== 'string' || w.trim().length > LIMITS.maxTitel) throw fehler400(`Titel: Höchstens ${LIMITS.maxTitel} Zeichen.`)
|
||||||
|
return w.trim()
|
||||||
|
},
|
||||||
|
kundenart: (w) => {
|
||||||
|
if (w === null || w === '') return ''
|
||||||
|
if (!KUNDENARTEN.includes(w)) throw fehler400('Kundenart: Bitte Unternehmen oder Privatperson wählen.')
|
||||||
|
return w
|
||||||
|
},
|
||||||
|
positionen: (w) => {
|
||||||
|
if (!Array.isArray(w) || w.length > LIMITS.maxPositionen) throw fehler400(MELDUNG_POSITIONEN)
|
||||||
|
return w.map((p, i) => pruefePosition(p, i + 1))
|
||||||
|
},
|
||||||
|
stundensatzCents: (w) => {
|
||||||
|
if (w === null) return null
|
||||||
|
if (!Number.isInteger(w) || w < 1 || w > LIMITS.maxEinzelpreisCents) throw fehler400(`Stundensatz: Der Stundensatz muss ${PREIS_GRENZE}`)
|
||||||
|
return w
|
||||||
|
},
|
||||||
|
schaetzungMinuten: (w) => {
|
||||||
|
if (w === null || w === '' || w === 0) return null
|
||||||
|
if (!Number.isInteger(w) || w % VIERTELSTUNDE !== 0 || w < VIERTELSTUNDE || w > LIMITS.maxSchaetzungMinuten) {
|
||||||
|
throw fehler400(`Schätzung: Die Schätzung muss ein Vielfaches von ${VIERTELSTUNDE} Minuten sein (15 bis 99.960).`)
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
},
|
||||||
|
zahlungsplan: pruefePlan,
|
||||||
|
zahlungszielTage: (w) => {
|
||||||
|
if (w === null) return null
|
||||||
|
if (!Number.isInteger(w) || w < 0 || w > LIMITS.maxZahlungszielTage) {
|
||||||
|
throw fehler400(`Zahlungsziel: Das Zahlungsziel muss eine ganze Zahl von 0 bis ${LIMITS.maxZahlungszielTage} Tagen sein.`)
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
},
|
||||||
|
auftragVom: (w) => {
|
||||||
|
if (!istKalendertag(w)) throw fehler400(MELDUNG_VOM)
|
||||||
|
return w
|
||||||
|
},
|
||||||
|
auftragWeg: (w) => {
|
||||||
|
if (typeof w !== 'string' || !Object.hasOwn(AUFTRAG_WEGE, w)) throw fehler400(MELDUNG_WEG)
|
||||||
|
return w
|
||||||
|
},
|
||||||
|
startAm: (w) => pruefeOptionalesDatum(w, 'Start: Bitte ein gültiges Datum angeben (JJJJ-MM-TT) oder leer lassen.'),
|
||||||
|
fertigBis: (w) => pruefeOptionalesDatum(w, 'Fertigstellung: Bitte ein gültiges Datum angeben (JJJJ-MM-TT) oder leer lassen.'),
|
||||||
|
zeitquelle: (w) => {
|
||||||
|
if (w === null) return []
|
||||||
|
if (!Array.isArray(w)) throw fehler400('Zeitquelle: Eine Liste von Ticket-Kennungen erwartet.')
|
||||||
|
if (!w.every(istDokumentId)) throw fehler400('Zeitquelle: Ungültige Ticket-Kennung.')
|
||||||
|
const ohneDoppelte = [...new Set(w)]
|
||||||
|
if (ohneDoppelte.length > LIMITS.maxZeitquellen) throw fehler400(`Zeitquelle: Höchstens ${LIMITS.maxZeitquellen} Tickets.`)
|
||||||
|
return ohneDoppelte
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const FELD_REIHENFOLGE = Object.keys(FELDREGELN)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft und bereinigt die Eingabe einer AB. Nur Felder der Whitelist (FELDREGELN) werden übernommen; status, nummer,
|
||||||
|
* tokenHash & Co. landen nie im Ergebnis. teilweise: nur die übergebenen Felder, ohne Pflicht-, Vorgabe- und Querprüfung.
|
||||||
|
*/
|
||||||
|
export function pruefeEingabe(eingabe, { teilweise = false } = {}) {
|
||||||
|
if (!istEinfachesObjekt(eingabe)) throw fehler400('Ungültige Eingabe.')
|
||||||
|
const felder = {}
|
||||||
|
for (const name of FELD_REIHENFOLGE) {
|
||||||
|
if (eingabe[name] !== undefined) felder[name] = FELDREGELN[name](eingabe[name])
|
||||||
|
}
|
||||||
|
if (teilweise) return felder
|
||||||
|
|
||||||
|
// Pflichtfelder: fehlt eines, kommt dieselbe Meldung wie bei einem ungültigen Wert
|
||||||
|
if (felder.art === undefined) throw fehler400(MELDUNG_ART)
|
||||||
|
if (felder.zahlungsplan === undefined) throw fehler400(MELDUNG_PLAN)
|
||||||
|
if (felder.auftragVom === undefined) throw fehler400(MELDUNG_VOM)
|
||||||
|
if (felder.auftragWeg === undefined) throw fehler400(MELDUNG_WEG)
|
||||||
|
|
||||||
|
const ergebnis = {
|
||||||
|
projektId: felder.projektId ?? '',
|
||||||
|
titel: felder.titel ?? '',
|
||||||
|
kundenart: felder.kundenart ?? '',
|
||||||
|
art: felder.art,
|
||||||
|
positionen: [],
|
||||||
|
stundensatzCents: null,
|
||||||
|
schaetzungMinuten: null,
|
||||||
|
summeCents: null,
|
||||||
|
zahlungsplan: felder.zahlungsplan,
|
||||||
|
zahlungszielTage: felder.zahlungszielTage ?? LIMITS.standardZahlungszielTage,
|
||||||
|
auftragVom: felder.auftragVom,
|
||||||
|
auftragWeg: felder.auftragWeg,
|
||||||
|
startAm: felder.startAm ?? '',
|
||||||
|
fertigBis: felder.fertigBis ?? '',
|
||||||
|
zeitquelle: felder.zeitquelle ?? [],
|
||||||
|
}
|
||||||
|
if (felder.art === 'festpreis') {
|
||||||
|
if (!felder.positionen || felder.positionen.length === 0) throw fehler400(MELDUNG_POSITIONEN)
|
||||||
|
const summe = felder.positionen.reduce((s, p) => s + p.menge * p.einzelpreisCents, 0)
|
||||||
|
if (summe > LIMITS.maxSummeCents) throw fehler400(MELDUNG_SUMME)
|
||||||
|
if (felder.zahlungsplan.art === 'monatlich') throw fehler400('Zahlungsplan: „Monatlich nach Aufwand“ geht nur bei einem Auftrag nach Aufwand.')
|
||||||
|
ergebnis.positionen = felder.positionen
|
||||||
|
ergebnis.summeCents = summe
|
||||||
|
} else {
|
||||||
|
if (felder.zahlungsplan.art === 'anzahlung') throw fehler400('Zahlungsplan: Eine Anzahlung geht nur bei einem Festpreis.')
|
||||||
|
ergebnis.stundensatzCents = felder.stundensatzCents ?? STUNDENSATZ_STANDARD_CENTS
|
||||||
|
ergebnis.schaetzungMinuten = felder.schaetzungMinuten ?? null
|
||||||
|
}
|
||||||
|
return ergebnis
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { useTestEnv } from './support/testEnv.js'
|
||||||
|
import { festpreisEingabe, aufwandEingabe, wirftFehler, MELDUNG } from './helpers/auftraegeAb.js'
|
||||||
|
|
||||||
|
// Rein: kein Appwrite-Zugriff. Die Umgebung zeigt trotzdem auf tote lokale Adressen, damit nie ein echtes System erreicht wird.
|
||||||
|
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1/v1', stripeUrl: 'http://127.0.0.1:1' })
|
||||||
|
const { pruefeEingabe } = await import('../server/services/auftraege/auftraege.js')
|
||||||
|
const { AuftragError } = await import('../server/services/auftraege/fehler.js')
|
||||||
|
|
||||||
|
const pos = (patch = {}) => ({ beschreibung: 'Webdesign', menge: 1, einzelpreisCents: 100, ...patch })
|
||||||
|
const neunPositionen = () => Array.from({ length: 9 }, () => pos({ einzelpreisCents: 10_000_000 }))
|
||||||
|
const E = {
|
||||||
|
art: 'Art: Bitte Festpreis oder Nach Aufwand wählen.',
|
||||||
|
beschreibung: (n = 1) => `Position ${n}: Die Beschreibung braucht 1 bis 200 Zeichen.`,
|
||||||
|
menge: 'Position 1: Die Menge muss eine ganze Zahl von 1 bis 999 sein.',
|
||||||
|
preis: (n = 1) => `Position ${n}: Der Einzelpreis muss zwischen 0,01 € und 100.000,00 € liegen.`,
|
||||||
|
summe: 'Die Auftragssumme ist zu hoch (höchstens 999.999,99 €).',
|
||||||
|
plan: 'Zahlungsplan: Bitte eine Zahlungsart wählen.',
|
||||||
|
prozent: 'Zahlungsplan: Die Anzahlung muss eine ganze Zahl von 1 bis 99 Prozent sein.',
|
||||||
|
frei: 'Zahlungsplan: Der freie Text braucht 1 bis 500 Zeichen.',
|
||||||
|
ziel: 'Zahlungsziel: Das Zahlungsziel muss eine ganze Zahl von 0 bis 60 Tagen sein.',
|
||||||
|
datum: 'Auftrag vom: Bitte ein gültiges Datum angeben (JJJJ-MM-TT).',
|
||||||
|
titel: 'Titel: Höchstens 200 Zeichen.',
|
||||||
|
zeitquelle: 'Zeitquelle: Eine Liste von Ticket-Kennungen erwartet.',
|
||||||
|
stundensatz: 'Stundensatz: Der Stundensatz muss zwischen 0,01 € und 100.000,00 € liegen.',
|
||||||
|
schaetzung: 'Schätzung: Die Schätzung muss ein Vielfaches von 15 Minuten sein (15 bis 99.960).',
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Festpreis: bereinigte Felder – Texte getrimmt, Summe berechnet, Aufwandsfelder leer, Vorgaben gesetzt', () => {
|
||||||
|
const felder = pruefeEingabe(
|
||||||
|
festpreisEingabe({
|
||||||
|
titel: ' Neue Website ',
|
||||||
|
positionen: [{ beschreibung: ' Webdesign ', menge: 2, einzelpreisCents: 30000 }],
|
||||||
|
zahlungsplan: { art: 'nach_abnahme' },
|
||||||
|
zahlungszielTage: undefined,
|
||||||
|
stundensatzCents: 12345, // gehört nicht zu einem Festpreis und fällt weg
|
||||||
|
schaetzungMinuten: 30,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
assert.deepEqual(felder, {
|
||||||
|
projektId: 'p_projekt01',
|
||||||
|
titel: 'Neue Website',
|
||||||
|
kundenart: '',
|
||||||
|
art: 'festpreis',
|
||||||
|
positionen: [{ beschreibung: 'Webdesign', menge: 2, einzelpreisCents: 30000 }],
|
||||||
|
stundensatzCents: null,
|
||||||
|
schaetzungMinuten: null,
|
||||||
|
summeCents: 60000,
|
||||||
|
zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' },
|
||||||
|
zahlungszielTage: 14,
|
||||||
|
auftragVom: '2026-10-05',
|
||||||
|
auftragWeg: 'telefon',
|
||||||
|
startAm: '',
|
||||||
|
fertigBis: '',
|
||||||
|
zeitquelle: [],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Aufwand: Stundensatz-Vorgabe 100 €, Schätzung optional, Positionen fallen weg, Summe leer', () => {
|
||||||
|
const felder = pruefeEingabe(aufwandEingabe({ stundensatzCents: undefined, schaetzungMinuten: null, positionen: festpreisEingabe().positionen }))
|
||||||
|
assert.deepEqual(felder, {
|
||||||
|
projektId: 'p_projekt01', titel: '', kundenart: '', art: 'aufwand', positionen: [], stundensatzCents: 10000, schaetzungMinuten: null,
|
||||||
|
summeCents: null, zahlungsplan: { art: 'monatlich', prozent: null, text: '' }, zahlungszielTage: 14, auftragVom: '2026-10-05',
|
||||||
|
auftragWeg: 'email', startAm: '', fertigBis: '', zeitquelle: [],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Zahlungsplan: Anzahlung nimmt 50 % vor, frei trägt den Text, die anderen Arten verwerfen prozent und text', () => {
|
||||||
|
const plan = (zahlungsplan, basis = festpreisEingabe) => pruefeEingabe(basis({ zahlungsplan })).zahlungsplan
|
||||||
|
assert.deepEqual(plan({ art: 'anzahlung' }), { art: 'anzahlung', prozent: 50, text: '' })
|
||||||
|
assert.deepEqual(plan({ art: 'anzahlung', prozent: 30, text: 'ignoriert' }), { art: 'anzahlung', prozent: 30, text: '' })
|
||||||
|
assert.deepEqual(plan({ art: 'frei', prozent: 40, text: ' Zahlbar in zwei Raten. ' }), { art: 'frei', prozent: null, text: 'Zahlbar in zwei Raten.' })
|
||||||
|
assert.deepEqual(plan({ art: 'monatlich', text: 'x' }, aufwandEingabe), { art: 'monatlich', prozent: null, text: '' })
|
||||||
|
assert.deepEqual(plan({ art: 'nach_abnahme', prozent: 10 }, aufwandEingabe), { art: 'nach_abnahme', prozent: null, text: '' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Grenzwerte, die noch gelten: 20 Positionen, Summe genau 999.999,99 €, Zahlungsziel 0 und 60, Schätzung 15 und 99.960 Minuten', () => {
|
||||||
|
const lang = (cents = 100, menge = 1) => ({ beschreibung: 'x'.repeat(200), menge, einzelpreisCents: cents })
|
||||||
|
assert.equal(pruefeEingabe(festpreisEingabe({ positionen: Array.from({ length: 20 }, () => lang()) })).positionen.length, 20)
|
||||||
|
assert.equal(pruefeEingabe(festpreisEingabe({ positionen: [...neunPositionen(), pos({ einzelpreisCents: 9_999_999 })] })).summeCents, 99_999_999)
|
||||||
|
assert.equal(pruefeEingabe(festpreisEingabe({ positionen: [lang(100, 999)] })).summeCents, 99_900)
|
||||||
|
assert.equal(pruefeEingabe(festpreisEingabe({ zahlungszielTage: 0 })).zahlungszielTage, 0)
|
||||||
|
assert.equal(pruefeEingabe(festpreisEingabe({ zahlungszielTage: 60 })).zahlungszielTage, 60)
|
||||||
|
assert.equal(pruefeEingabe(aufwandEingabe({ schaetzungMinuten: 15 })).schaetzungMinuten, 15)
|
||||||
|
assert.equal(pruefeEingabe(aufwandEingabe({ schaetzungMinuten: 99_960 })).schaetzungMinuten, 99_960)
|
||||||
|
for (const keine of [0, '', null]) assert.equal(pruefeEingabe(aufwandEingabe({ schaetzungMinuten: keine })).schaetzungMinuten, null)
|
||||||
|
assert.equal(pruefeEingabe(aufwandEingabe({ stundensatzCents: 1 })).stundensatzCents, 1)
|
||||||
|
assert.equal(pruefeEingabe(aufwandEingabe({ stundensatzCents: 10_000_000 })).stundensatzCents, 10_000_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
const FESTPREIS_FEHLER = [
|
||||||
|
['Art fehlt', { art: undefined }, E.art],
|
||||||
|
['Art unbekannt', { art: 'pauschal' }, E.art],
|
||||||
|
['keine Positionen', { positionen: [] }, MELDUNG.positionen],
|
||||||
|
['Positionen fehlen', { positionen: undefined }, MELDUNG.positionen],
|
||||||
|
['21 Positionen', { positionen: Array.from({ length: 21 }, () => pos()) }, MELDUNG.positionen],
|
||||||
|
['Position ist kein Objekt', { positionen: ['x'] }, E.beschreibung()],
|
||||||
|
['Beschreibung leer', { positionen: [pos({ beschreibung: ' ' })] }, E.beschreibung()],
|
||||||
|
['Beschreibung 201 Zeichen (zweite Position)', { positionen: [pos(), pos({ beschreibung: 'x'.repeat(201) })] }, E.beschreibung(2)],
|
||||||
|
['Menge 0', { positionen: [pos({ menge: 0 })] }, E.menge],
|
||||||
|
['Menge 1000', { positionen: [pos({ menge: 1000 })] }, E.menge],
|
||||||
|
['Menge 1,5', { positionen: [pos({ menge: 1.5 })] }, E.menge],
|
||||||
|
['Menge als Text', { positionen: [pos({ menge: '2' })] }, E.menge],
|
||||||
|
['Einzelpreis 0 (zweite Position)', { positionen: [pos(), pos({ einzelpreisCents: 0 })] }, E.preis(2)],
|
||||||
|
['Einzelpreis über 100.000 €', { positionen: [pos({ einzelpreisCents: 10_000_001 })] }, E.preis()],
|
||||||
|
['Einzelpreis mit Nachkommastelle', { positionen: [pos({ einzelpreisCents: 99.5 })] }, E.preis()],
|
||||||
|
['Summe zu hoch', { positionen: [pos({ menge: 11, einzelpreisCents: 10_000_000 })] }, E.summe],
|
||||||
|
['Summe ein Cent über dem Höchstwert', { positionen: [...neunPositionen(), pos({ einzelpreisCents: 10_000_000 })] }, E.summe],
|
||||||
|
['Zahlungsplan fehlt', { zahlungsplan: undefined }, E.plan],
|
||||||
|
['Zahlungsart unbekannt', { zahlungsplan: { art: 'bar' } }, E.plan],
|
||||||
|
['Zahlungsplan kein Objekt', { zahlungsplan: 'anzahlung' }, E.plan],
|
||||||
|
['monatlich bei Festpreis', { zahlungsplan: { art: 'monatlich' } }, 'Zahlungsplan: „Monatlich nach Aufwand“ geht nur bei einem Auftrag nach Aufwand.'],
|
||||||
|
['Anzahlung 0 %', { zahlungsplan: { art: 'anzahlung', prozent: 0 } }, E.prozent],
|
||||||
|
['Anzahlung 100 %', { zahlungsplan: { art: 'anzahlung', prozent: 100 } }, E.prozent],
|
||||||
|
['Anzahlung 12,5 %', { zahlungsplan: { art: 'anzahlung', prozent: 12.5 } }, E.prozent],
|
||||||
|
['Anzahlung als Text', { zahlungsplan: { art: 'anzahlung', prozent: '50' } }, E.prozent],
|
||||||
|
['freier Text leer', { zahlungsplan: { art: 'frei', text: ' ' } }, E.frei],
|
||||||
|
['freier Text 501 Zeichen', { zahlungsplan: { art: 'frei', text: 'x'.repeat(501) } }, E.frei],
|
||||||
|
['Zahlungsziel -1', { zahlungszielTage: -1 }, E.ziel],
|
||||||
|
['Zahlungsziel 61', { zahlungszielTage: 61 }, E.ziel],
|
||||||
|
['Zahlungsziel 14,5', { zahlungszielTage: 14.5 }, E.ziel],
|
||||||
|
['Zahlungsziel als Text', { zahlungszielTage: '14' }, E.ziel],
|
||||||
|
['Auftragsdatum fehlt', { auftragVom: undefined }, E.datum],
|
||||||
|
['30. Februar', { auftragVom: '2026-02-30' }, E.datum],
|
||||||
|
['deutsches Datumsformat', { auftragVom: '05.10.2026' }, E.datum],
|
||||||
|
['Datum als Zahl', { auftragVom: 20261005 }, E.datum],
|
||||||
|
['Weg fehlt', { auftragWeg: undefined }, 'Weg: Bitte den Weg der Absprache wählen.'],
|
||||||
|
['Weg unbekannt', { auftragWeg: 'brieftaube' }, 'Weg: Bitte den Weg der Absprache wählen.'],
|
||||||
|
['Start kein Datum', { startAm: '2026-13-01' }, 'Start: Bitte ein gültiges Datum angeben (JJJJ-MM-TT) oder leer lassen.'],
|
||||||
|
['Fertigstellung kein Datum', { fertigBis: 'bald' }, 'Fertigstellung: Bitte ein gültiges Datum angeben (JJJJ-MM-TT) oder leer lassen.'],
|
||||||
|
['Titel 201 Zeichen', { titel: 'x'.repeat(201) }, E.titel],
|
||||||
|
['Titel keine Zeichenkette', { titel: 5 }, E.titel],
|
||||||
|
['Projekt-Kennung mit ../', { projektId: '../x' }, 'Projekt: Ungültige Projekt-Kennung.'],
|
||||||
|
['Kundenart unbekannt', { kundenart: 'firma' }, 'Kundenart: Bitte Unternehmen oder Privatperson wählen.'],
|
||||||
|
['Zeitquelle keine Liste', { zeitquelle: 't_1' }, E.zeitquelle],
|
||||||
|
['11 Tickets', { zeitquelle: Array.from({ length: 11 }, (_, i) => `t_${i}`) }, 'Zeitquelle: Höchstens 10 Tickets.'],
|
||||||
|
['Ticket-Kennung mit ../', { zeitquelle: ['t_ok', '../x'] }, 'Zeitquelle: Ungültige Ticket-Kennung.'],
|
||||||
|
]
|
||||||
|
const AUFWAND_FEHLER = [
|
||||||
|
['Stundensatz 0', { stundensatzCents: 0 }, E.stundensatz],
|
||||||
|
['Stundensatz über 100.000 €', { stundensatzCents: 10_000_001 }, E.stundensatz],
|
||||||
|
['Stundensatz mit Nachkommastelle', { stundensatzCents: 100.5 }, E.stundensatz],
|
||||||
|
['Stundensatz als Text', { stundensatzCents: '100' }, E.stundensatz],
|
||||||
|
['Schätzung 10 Minuten', { schaetzungMinuten: 10 }, E.schaetzung],
|
||||||
|
['Schätzung negativ', { schaetzungMinuten: -15 }, E.schaetzung],
|
||||||
|
['Schätzung über dem Höchstwert', { schaetzungMinuten: 99_975 }, E.schaetzung],
|
||||||
|
['Schätzung als Text', { schaetzungMinuten: '15' }, E.schaetzung],
|
||||||
|
['Anzahlung bei Aufwand', { zahlungsplan: { art: 'anzahlung', prozent: 50 } }, 'Zahlungsplan: Eine Anzahlung geht nur bei einem Festpreis.'],
|
||||||
|
]
|
||||||
|
|
||||||
|
test('Ablehnungen beim Festpreis: jede Eingabe scheitert mit genau ihrer Meldung (400)', () => {
|
||||||
|
for (const [name, patch, meldung] of FESTPREIS_FEHLER) wirftFehler(() => pruefeEingabe(festpreisEingabe(patch)), 400, meldung, name)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Ablehnungen bei Aufwand: Stundensatz, Schätzung und Anzahlung', () => {
|
||||||
|
for (const [name, patch, meldung] of AUFWAND_FEHLER) wirftFehler(() => pruefeEingabe(aufwandEingabe(patch)), 400, meldung, name)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Eingaben, die kein einfaches Objekt sind, ergeben „Ungültige Eingabe.“ als AuftragError', () => {
|
||||||
|
for (const eingabe of [null, undefined, 'x', 42, [], [festpreisEingabe()]]) {
|
||||||
|
for (const teilweise of [false, true]) {
|
||||||
|
assert.throws(() => pruefeEingabe(eingabe, { teilweise }), (err) => err instanceof AuftragError && err.status === 400 && err.message === 'Ungültige Eingabe.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Unbekannte und geschützte Felder landen nie in den bereinigten Feldern', () => {
|
||||||
|
const sauber = pruefeEingabe(festpreisEingabe())
|
||||||
|
const mitMuell = pruefeEingabe({
|
||||||
|
...festpreisEingabe(), status: 'bestaetigt', nummer: 'AB-2026-0001', jahr: 2026, tokenHash: 'x', ersetztId: 'a_alt',
|
||||||
|
erstelltVon: 'boese@example.invalid', pdfDatei: 'c/a.pdf', kunde: { name: 'x' }, id: 'a_fremd',
|
||||||
|
})
|
||||||
|
assert.deepEqual(mitMuell, sauber)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('teilweise: nur übergebene Felder werden geprüft und bereinigt, nichts wird ergänzt', () => {
|
||||||
|
assert.deepEqual(pruefeEingabe({}, { teilweise: true }), {})
|
||||||
|
assert.deepEqual(pruefeEingabe({ kundenId: 'c_x', status: 'bestaetigt' }, { teilweise: true }), {})
|
||||||
|
assert.deepEqual(pruefeEingabe({ titel: ' Neu ', zeitquelle: ['t_1', 't_1', 't_2'] }, { teilweise: true }), { titel: 'Neu', zeitquelle: ['t_1', 't_2'] })
|
||||||
|
assert.deepEqual(pruefeEingabe({ art: 'aufwand' }, { teilweise: true }), { art: 'aufwand' })
|
||||||
|
assert.deepEqual(pruefeEingabe({ zahlungsplan: { art: 'anzahlung' } }, { teilweise: true }), { zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' } })
|
||||||
|
assert.deepEqual(
|
||||||
|
pruefeEingabe({ positionen: [{ beschreibung: ' A ', menge: 1, einzelpreisCents: 5 }] }, { teilweise: true }),
|
||||||
|
{ positionen: [{ beschreibung: 'A', menge: 1, einzelpreisCents: 5 }] },
|
||||||
|
)
|
||||||
|
wirftFehler(() => pruefeEingabe({ zahlungszielTage: 99 }, { teilweise: true }), 400, E.ziel)
|
||||||
|
wirftFehler(() => pruefeEingabe({ auftragVom: null }, { teilweise: true }), 400, E.datum)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user