From ef646f12de70ca1dcf83b816942223c7dcd56553 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:12:47 +0000 Subject: [PATCH] =?UTF-8?q?AB-Kern:=20Eingabepr=C3=BCfung=20f=C3=BCr=20Auf?= =?UTF-8?q?tragsbest=C3=A4tigungen=20(pruefeEingabe)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- server/services/auftraege/auftraege.js | 189 +++++++++++++++++++++++++ test/auftraegeAbEingabe.test.js | 189 +++++++++++++++++++++++++ 2 files changed, 378 insertions(+) create mode 100644 server/services/auftraege/auftraege.js create mode 100644 test/auftraegeAbEingabe.test.js diff --git a/server/services/auftraege/auftraege.js b/server/services/auftraege/auftraege.js new file mode 100644 index 0000000..53f8702 --- /dev/null +++ b/server/services/auftraege/auftraege.js @@ -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 +} diff --git a/test/auftraegeAbEingabe.test.js b/test/auftraegeAbEingabe.test.js new file mode 100644 index 0000000..42f79b4 --- /dev/null +++ b/test/auftraegeAbEingabe.test.js @@ -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) +})