From ab47fabcb2584699d0631706b0a39026c9660290 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 18:49:06 +0000 Subject: [PATCH] Terminplaner TP2: Fragebogen-Attribut, Branchen, Schritte, Normalisierung termineConfig bekommt das Attribut fragebogen (Text 20000, Inhalt bis FRAGEBOGEN_MAX 16000). fragebogen.js: SCHRITTE, BRANCHEN (Vorlagen plus Gesundheitsbereich), leerFragebogen und normalisiereFragebogen. Co-Authored-By: Claude Sonnet 5.5 --- server/services/termine/fragebogen.js | 109 ++++++++++ server/services/termine/schema.js | 3 + test/termineFragebogen.test.js | 278 ++++++++++++++++++++++++++ test/termineSetup.test.js | 27 ++- 4 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 test/termineFragebogen.test.js diff --git a/server/services/termine/fragebogen.js b/server/services/termine/fragebogen.js index 7143f66..f223563 100644 --- a/server/services/termine/fragebogen.js +++ b/server/services/termine/fragebogen.js @@ -1,4 +1,113 @@ // STUB (Block B) – ersetzt Task C2 (bestellpruefung); Task C1 legt in dieser Datei den Rest des Fragebogen-Dienstes an +import { text } from './einstellungen.js' +import { vorlagenListe } from './vorlagen.js' + +/** + * Bestell-Fragebogen des Terminplaners (TP2, Spezifikation 2026-10-03-terminplaner-tp2-verkauf-design.md): Der Kunde + * richtet den Terminplaner vor der Bestellung ein; der Entwurf steht in termineConfig.einstellung, der Fragebogen + * (Branche, bestaetigte Schritte, Wuensche zum Einbau) im Attribut termineConfig.fragebogen als JSON-Text. + * Dieser Teil: Schritte, Branchen, Grundform und Normalisierung. Reine Funktionen ohne Appwrite und ohne Uhr. + * Fehler sind { [pfad]: text } wie bei normalisiereEinstellung (Pfad '_' = das Ganze); Texte in Du-Form. + */ + +/** Die acht Schritte des Fragebogens in fester Reihenfolge; `bestaetigt` nennt die vom Kunden abgehakten. */ +export const SCHRITTE = Object.freeze(['branche', 'wer', 'leistungen', 'zeiten', 'regeln', 'gastfelder', 'benachrichtigung', 'website']) + +/** + * Branchen zur Auswahl: alle Vorlagen in der Reihenfolge von vorlagenListe(), danach der Gesundheitsbereich + * (ohne Vorlage; er fuehrt in den Hinweis- und Pruefweg fuer Gesundheitsangaben). Schluessel und Name der letzten + * Branche gelten woertlich fuer Bestellpruefung, Oberflaeche, Einbauanleitung und Ticket. + */ +export const BRANCHEN = Object.freeze([ + ...vorlagenListe().map(({ key, name }) => Object.freeze({ key, name, vorlage: key, gesundheit: false })), + Object.freeze({ key: 'gesundheit', name: 'Gesundheitsbereich', vorlage: null, gesundheit: true }), +]) + +/** Obergrenze fuer den JSON-Text des Fragebogens (Zeichen); die Spalte in Appwrite ist mit 20000 groesser. */ +export const FRAGEBOGEN_MAX = 16000 + +const EINBAU_MODI = ['inline', 'knopf'] +const TEXTGRENZEN = { knopfText: 40, stelle: 300, gestaltung: 1000, bemerkung: 1000 } +const MEHRZEILIG = new Set(['stelle', 'gestaltung', 'bemerkung']) + +const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) + +/** Ein leerer Fragebogen; bei jedem Aufruf ein neues Objekt. */ +export function leerFragebogen() { + return { + schema: 1, + branche: null, + bestaetigt: [], + bundeslandGewaehlt: false, + preiseGeprueft: false, + einbau: { modus: null, knopfText: '', stelle: '', gestaltung: '', bemerkung: '' }, + } +} + +function wahrheit(fehl, pfad, wert) { + if (wert === undefined || wert === null) return false + if (typeof wert !== 'boolean') { + fehl(pfad, 'Bitte „ja“ oder „nein“ wählen.') + return false + } + return wert +} + +function einbauFelder(fehl, eingabe) { + const quelle = eingabe === undefined || eingabe === null ? {} : eingabe + if (!istObjekt(quelle)) { + fehl('einbau', 'Die Angaben zum Einbau sind ungültig.') + return leerFragebogen().einbau + } + const einbau = { modus: null } + if (quelle.modus !== undefined && quelle.modus !== null) { + if (EINBAU_MODI.includes(quelle.modus)) einbau.modus = quelle.modus + else fehl('einbau.modus', 'Bitte wähle, ob der Terminplaner im Text oder als Knopf erscheint.') + } + for (const [feld, max] of Object.entries(TEXTGRENZEN)) { + const wert = quelle[feld] + if (wert === undefined || wert === null) einbau[feld] = '' + else if (typeof wert !== 'string') { + fehl(`einbau.${feld}`, 'Der Text ist ungültig.') + einbau[feld] = '' + } else einbau[feld] = text(wert, max, { mehrzeilig: MEHRZEILIG.has(feld) }) + } + return einbau +} + +/** + * Prueft und bereinigt einen Fragebogen aus Kundeneingabe oder gespeichertem JSON. + * Baut ein neues Objekt Feld fuer Feld (unbekannte Schluessel und __proto__ fallen weg), kuerzt Texte, entfernt + * Steuerzeichen und ordnet `bestaetigt` nach SCHRITTE ohne Doppelte und Fremde. Fehlende Felder sind leer/false. + * Ergebnis: { fragebogen } oder { fehler: { [pfad]: text } }; idempotent. + */ +export function normalisiereFragebogen(eingabe) { + if (!istObjekt(eingabe)) return { fehler: { _: 'Der Fragebogen ist ungültig.' } } + const fehler = {} + const fehl = (pfad, meldung) => { if (!Object.hasOwn(fehler, pfad)) fehler[pfad] = meldung } + + let branche = null + if (eingabe.branche !== undefined && eingabe.branche !== null) { + if (BRANCHEN.some((b) => b.key === eingabe.branche)) branche = eingabe.branche + else fehl('branche', 'Bitte wähle eine Branche aus der Liste.') + } + + let bestaetigt = [] + if (eingabe.bestaetigt !== undefined && eingabe.bestaetigt !== null) { + if (Array.isArray(eingabe.bestaetigt)) bestaetigt = SCHRITTE.filter((schritt) => eingabe.bestaetigt.includes(schritt)) + else fehl('bestaetigt', 'Die bestätigten Schritte sind ungültig.') + } + + const fragebogen = { + schema: 1, + branche, + bestaetigt, + bundeslandGewaehlt: wahrheit(fehl, 'bundeslandGewaehlt', eingabe.bundeslandGewaehlt), + preiseGeprueft: wahrheit(fehl, 'preiseGeprueft', eingabe.preiseGeprueft), + einbau: einbauFelder(fehl, eingabe.einbau), + } + return Object.keys(fehler).length ? { fehler } : { fragebogen } +} /** * Bestellpruefung des Terminplaners: Liste der offenen Punkte `[{ schritt, text }]`, leer = bestellbar. diff --git a/server/services/termine/schema.js b/server/services/termine/schema.js index 3a85e55..9a7b42e 100644 --- a/server/services/termine/schema.js +++ b/server/services/termine/schema.js @@ -26,6 +26,9 @@ export const TERMINE_COLLECTIONS = { int('version'), dt('updatedAt'), str('updatedBy', 100), + // TP2: Bestell-Fragebogen als JSON-Text. Der Inhalt ist auf 16000 Zeichen begrenzt (FRAGEBOGEN_MAX in + // termine/fragebogen.js); die groessere Spalte wird in Appwrite eine Textspalte und zaehlt nicht zur Zeilenbreite. + str('fragebogen', 20000), ], indexes: [], }, diff --git a/test/termineFragebogen.test.js b/test/termineFragebogen.test.js new file mode 100644 index 0000000..0d9c49b --- /dev/null +++ b/test/termineFragebogen.test.js @@ -0,0 +1,278 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { VORLAGEN, vorlagenListe } from '../server/services/termine/vorlagen.js' +import { + SCHRITTE, BRANCHEN, FRAGEBOGEN_MAX, leerFragebogen, normalisiereFragebogen, +} from '../server/services/termine/fragebogen.js' + +const norm = normalisiereFragebogen +const ok = (eingabe) => { + const ergebnis = norm(eingabe) + assert.equal(ergebnis.fehler, undefined, `unerwarteter Fehler: ${JSON.stringify(ergebnis.fehler)}`) + return ergebnis.fragebogen +} + +// ---------------------------------------------------------------- SCHRITTE und BRANCHEN + +test('SCHRITTE: genau die acht Schritte in fester Reihenfolge, unveraenderlich', () => { + assert.deepEqual(SCHRITTE, ['branche', 'wer', 'leistungen', 'zeiten', 'regeln', 'gastfelder', 'benachrichtigung', 'website']) + assert.ok(Object.isFrozen(SCHRITTE)) +}) + +test('BRANCHEN: Reihenfolge von vorlagenListe(), danach genau der Eintrag Gesundheitsbereich', () => { + const liste = vorlagenListe() + assert.equal(BRANCHEN.length, liste.length + 1) + BRANCHEN.slice(0, liste.length).forEach((branche, i) => { + assert.deepEqual(branche, { key: liste[i].key, name: liste[i].name, vorlage: liste[i].key, gesundheit: false }) + }) + assert.deepEqual(BRANCHEN.at(-1), { key: 'gesundheit', name: 'Gesundheitsbereich', vorlage: null, gesundheit: true }) +}) + +test('BRANCHEN: jede Vorlage steht in VORLAGEN, alle neun Vorlagen kommen genau einmal vor', () => { + const mitVorlage = BRANCHEN.filter((b) => b.vorlage !== null) + assert.ok(mitVorlage.every((b) => Object.hasOwn(VORLAGEN, b.vorlage))) + assert.equal(Object.keys(VORLAGEN).length, 9) + assert.deepEqual(mitVorlage.map((b) => b.vorlage).sort(), Object.keys(VORLAGEN).sort()) + assert.equal(new Set(mitVorlage.map((b) => b.vorlage)).size, 9) + assert.equal(new Set(BRANCHEN.map((b) => b.key)).size, BRANCHEN.length, 'Schluessel eindeutig') +}) + +test('BRANCHEN: genau ein Eintrag mit gesundheit true und ohne Vorlage; sonstiges bleibt waehlbar (D6)', () => { + const gesundheit = BRANCHEN.filter((b) => b.gesundheit) + assert.equal(gesundheit.length, 1) + assert.equal(gesundheit[0].vorlage, null) + assert.equal(gesundheit[0].key, 'gesundheit') + assert.equal(gesundheit[0].name, 'Gesundheitsbereich') + assert.ok(BRANCHEN.filter((b) => b.vorlage === null).every((b) => b.gesundheit), 'ohne Vorlage nur der Gesundheitsbereich') + const sonstiges = BRANCHEN.find((b) => b.key === 'sonstiges') + assert.deepEqual(sonstiges, { key: 'sonstiges', name: VORLAGEN.sonstiges.name, vorlage: 'sonstiges', gesundheit: false }) + assert.equal(ok({ branche: 'sonstiges' }).branche, 'sonstiges') +}) + +test('BRANCHEN: jeder Eintrag hat genau die vier Felder, die Liste ist unveraenderlich', () => { + for (const branche of BRANCHEN) { + assert.deepEqual(Object.keys(branche), ['key', 'name', 'vorlage', 'gesundheit']) + assert.ok(typeof branche.name === 'string' && branche.name.length > 2) + assert.equal(typeof branche.gesundheit, 'boolean') + } + assert.ok(Object.isFrozen(BRANCHEN)) + assert.ok(BRANCHEN.every((b) => Object.isFrozen(b))) +}) + +test('FRAGEBOGEN_MAX ist 16000', () => { + assert.equal(FRAGEBOGEN_MAX, 16000) +}) + +// ---------------------------------------------------------------- leerFragebogen + +test('leerFragebogen: Grundform, bei jedem Aufruf ein neues Objekt', () => { + assert.deepEqual(leerFragebogen(), { + schema: 1, + branche: null, + bestaetigt: [], + bundeslandGewaehlt: false, + preiseGeprueft: false, + einbau: { modus: null, knopfText: '', stelle: '', gestaltung: '', bemerkung: '' }, + }) + const a = leerFragebogen() + a.bestaetigt.push('wer') + a.einbau.stelle = 'x' + assert.deepEqual(leerFragebogen().bestaetigt, []) + assert.equal(leerFragebogen().einbau.stelle, '') +}) + +// ---------------------------------------------------------------- normalisiereFragebogen + +test('normalisiere: {} ergibt leerFragebogen()', () => { + assert.deepEqual(norm({}), { fragebogen: leerFragebogen() }) +}) + +test('normalisiere: Nicht-Objekt ergibt fehler._ und kein fragebogen', () => { + for (const eingabe of [null, undefined, 'text', 42, true, [], [{}]]) { + const ergebnis = norm(eingabe) + assert.equal(typeof ergebnis.fehler._, 'string', `Eingabe ${JSON.stringify(eingabe)}`) + assert.deepEqual(Object.keys(ergebnis.fehler), ['_']) + assert.equal('fragebogen' in ergebnis, false) + } +}) + +test('normalisiere: unbekannte Schluessel und __proto__ fliegen raus, ohne den Prototyp anzufassen', () => { + const eingabe = JSON.parse( + '{"__proto__":{"vergiftet":1},"fremd":"x","constructor":{"prototype":{"vergiftet":2}},' + + '"einbau":{"__proto__":{"vergiftet":3},"fremd":"y","stelle":"Startseite"}}', + ) + const fragebogen = ok(eingabe) + assert.deepEqual(fragebogen, { ...leerFragebogen(), einbau: { ...leerFragebogen().einbau, stelle: 'Startseite' } }) + assert.deepEqual(Object.keys(fragebogen), ['schema', 'branche', 'bestaetigt', 'bundeslandGewaehlt', 'preiseGeprueft', 'einbau']) + assert.deepEqual(Object.keys(fragebogen.einbau), ['modus', 'knopfText', 'stelle', 'gestaltung', 'bemerkung']) + assert.equal(Object.hasOwn(fragebogen, '__proto__'), false) + assert.equal(Object.getPrototypeOf(fragebogen), Object.prototype) + assert.equal({}.vergiftet, undefined) + assert.equal(fragebogen.vergiftet, undefined) +}) + +test('normalisiere: schema ist immer 1, egal was kommt', () => { + assert.equal(ok({ schema: 7 }).schema, 1) + assert.equal(ok({ schema: 'x' }).schema, 1) +}) + +test('normalisiere: bestaetigt in Reihenfolge der Schritte, ohne Doppelte und Fremde', () => { + assert.deepEqual(ok({ bestaetigt: ['website', 'branche', 'branche', 'quatsch'] }).bestaetigt, ['branche', 'website']) + assert.deepEqual(ok({ bestaetigt: [...SCHRITTE].reverse() }).bestaetigt, SCHRITTE) + assert.deepEqual(ok({ bestaetigt: [1, null, {}, ['wer'], 'wer'] }).bestaetigt, ['wer']) + assert.deepEqual(ok({ bestaetigt: [] }).bestaetigt, []) + assert.deepEqual(ok({ bestaetigt: null }).bestaetigt, []) + assert.deepEqual(ok({ bestaetigt: ['__proto__', 'constructor', 'toString'] }).bestaetigt, []) +}) + +test('normalisiere: bestaetigt, das keine Liste ist, ergibt fehler.bestaetigt', () => { + for (const wert of ['branche', 5, true, { 0: 'branche' }]) { + const ergebnis = norm({ bestaetigt: wert }) + assert.deepEqual(Object.keys(ergebnis.fehler), ['bestaetigt'], JSON.stringify(wert)) + } +}) + +test('normalisiere: branche nur aus BRANCHEN oder null', () => { + for (const branche of BRANCHEN) assert.equal(ok({ branche: branche.key }).branche, branche.key) + assert.equal(ok({ branche: 'gesundheit' }).branche, 'gesundheit') + assert.equal(ok({ branche: null }).branche, null) + assert.equal(ok({}).branche, null) + for (const wert of ['zahnarzt', '', 'Salon', 'SALON', 'constructor', '__proto__', 7, ['salon'], {}]) { + assert.deepEqual(Object.keys(norm({ branche: wert }).fehler ?? {}), ['branche'], JSON.stringify(wert)) + } +}) + +test('normalisiere: knopfText mit 41 Zeichen wird auf 40 gekuerzt, einzeilig', () => { + assert.equal(ok({ einbau: { knopfText: 'x'.repeat(41) } }).einbau.knopfText, 'x'.repeat(40)) + assert.equal(ok({ einbau: { knopfText: 'x'.repeat(40) } }).einbau.knopfText, 'x'.repeat(40)) + assert.equal(ok({ einbau: { knopfText: '😀'.repeat(41) } }).einbau.knopfText, '😀'.repeat(40), 'Codepoints, nicht UTF-16-Einheiten') + assert.equal(ok({ einbau: { knopfText: ' Jetzt\nTermin\tbuchen ' } }).einbau.knopfText, 'Jetzt Termin buchen') +}) + +test('normalisiere: Textlaengen stelle 300, gestaltung 1000, bemerkung 1000', () => { + const einbau = ok({ einbau: { stelle: 'a'.repeat(301), gestaltung: 'b'.repeat(1001), bemerkung: 'c'.repeat(1001) } }).einbau + assert.equal(einbau.stelle, 'a'.repeat(300)) + assert.equal(einbau.gestaltung, 'b'.repeat(1000)) + assert.equal(einbau.bemerkung, 'c'.repeat(1000)) +}) + +test('normalisiere: Steuerzeichen verschwinden, Absaetze bleiben (stelle, gestaltung, bemerkung)', () => { + const roh = 'Farbe\u0000 dunkel‮, rund\r\n\r\nZweiter Absatz​\n\n\n\nDritter' + const erwartet = 'Farbe dunkel, rund\n\nZweiter Absatz\n\nDritter' + const einbau = ok({ einbau: { stelle: roh, gestaltung: roh, bemerkung: roh } }).einbau + assert.equal(einbau.gestaltung, erwartet) + assert.equal(einbau.stelle, erwartet) + assert.equal(einbau.bemerkung, erwartet) + assert.ok(!/[\u0000‮​\r]/.test(einbau.gestaltung)) +}) + +test('normalisiere: Texte werden getrimmt, ein Text nur aus Steuerzeichen wird leer', () => { + const einbau = ok({ einbau: { knopfText: '\u0000‮', stelle: ' ', gestaltung: '\n\n', bemerkung: ' x ' } }).einbau + assert.deepEqual(einbau, { modus: null, knopfText: '', stelle: '', gestaltung: '', bemerkung: 'x' }) +}) + +test('normalisiere: Textfelder, die keine Zeichenkette sind, ergeben einen Fehler am Pfad', () => { + for (const feld of ['knopfText', 'stelle', 'gestaltung', 'bemerkung']) { + for (const wert of [5, true, ['a'], {}]) { + assert.deepEqual(Object.keys(norm({ einbau: { [feld]: wert } }).fehler ?? {}), [`einbau.${feld}`], `${feld}=${JSON.stringify(wert)}`) + } + assert.equal(ok({ einbau: { [feld]: null } }).einbau[feld], '') + } +}) + +test('normalisiere: einbau.modus nur inline, knopf oder null; popup ergibt fehler[einbau.modus]', () => { + assert.equal(ok({ einbau: { modus: 'inline' } }).einbau.modus, 'inline') + assert.equal(ok({ einbau: { modus: 'knopf' } }).einbau.modus, 'knopf') + assert.equal(ok({ einbau: { modus: null } }).einbau.modus, null) + assert.equal(ok({ einbau: {} }).einbau.modus, null) + const ergebnis = norm({ einbau: { modus: 'popup' } }) + assert.deepEqual(Object.keys(ergebnis.fehler), ['einbau.modus']) + assert.equal(typeof ergebnis.fehler['einbau.modus'], 'string') + assert.equal('fragebogen' in ergebnis, false) + for (const wert of ['', 'INLINE', 1, ['inline'], {}]) { + assert.deepEqual(Object.keys(norm({ einbau: { modus: wert } }).fehler ?? {}), ['einbau.modus'], JSON.stringify(wert)) + } +}) + +test('normalisiere: einbau, das kein Objekt ist, ergibt fehler.einbau', () => { + for (const wert of ['inline', 5, [], true]) { + assert.deepEqual(Object.keys(norm({ einbau: wert }).fehler ?? {}), ['einbau'], JSON.stringify(wert)) + } + assert.deepEqual(ok({ einbau: null }).einbau, leerFragebogen().einbau) +}) + +test('normalisiere: bundeslandGewaehlt und preiseGeprueft nur als Wahrheitswert; ja ergibt Fehler', () => { + assert.equal(ok({ bundeslandGewaehlt: true }).bundeslandGewaehlt, true) + assert.equal(ok({ preiseGeprueft: true }).preiseGeprueft, true) + assert.equal(ok({ bundeslandGewaehlt: false, preiseGeprueft: false }).bundeslandGewaehlt, false) + assert.equal(ok({ bundeslandGewaehlt: null }).bundeslandGewaehlt, false) + assert.deepEqual(Object.keys(norm({ bundeslandGewaehlt: 'ja' }).fehler), ['bundeslandGewaehlt']) + assert.deepEqual(Object.keys(norm({ preiseGeprueft: 1 }).fehler), ['preiseGeprueft']) + for (const wert of ['true', 'ja', 0, 1, [], {}]) { + assert.deepEqual(Object.keys(norm({ bundeslandGewaehlt: wert }).fehler ?? {}), ['bundeslandGewaehlt'], JSON.stringify(wert)) + } +}) + +test('normalisiere: mehrere Fehler zugleich, jeder am eigenen Pfad', () => { + const ergebnis = norm({ branche: 'x', bestaetigt: 3, bundeslandGewaehlt: 'ja', preiseGeprueft: 'nein', einbau: { modus: 'popup', stelle: 4 } }) + assert.deepEqual(Object.keys(ergebnis.fehler).sort(), ['bestaetigt', 'branche', 'bundeslandGewaehlt', 'einbau.modus', 'einbau.stelle', 'preiseGeprueft']) + assert.ok(Object.values(ergebnis.fehler).every((text) => typeof text === 'string' && text.length > 5)) +}) + +test('normalisiere: das Ergebnis teilt nichts mit der Eingabe', () => { + const eingabe = { bestaetigt: ['wer'], einbau: { stelle: 'oben' } } + const fragebogen = ok(eingabe) + eingabe.bestaetigt.push('zeiten') + eingabe.einbau.stelle = 'unten' + assert.deepEqual(fragebogen.bestaetigt, ['wer']) + assert.equal(fragebogen.einbau.stelle, 'oben') + assert.notEqual(fragebogen.einbau, eingabe.einbau) +}) + +test('normalisiere: voll ausgefuellter Fragebogen bleibt unveraendert', () => { + const voll = { + schema: 1, + branche: 'salon', + bestaetigt: ['branche', 'wer', 'leistungen', 'zeiten', 'regeln', 'gastfelder', 'benachrichtigung', 'website'], + bundeslandGewaehlt: true, + preiseGeprueft: true, + einbau: { modus: 'knopf', knopfText: 'Termin buchen', stelle: 'Unterseite Kontakt', gestaltung: 'Lila Knopf\n\nRund', bemerkung: 'Bitte vor 10 Uhr' }, + } + assert.deepEqual(norm(voll), { fragebogen: voll }) +}) + +test('normalisiere: idempotent', () => { + const eingaben = [ + {}, + { branche: 'gesundheit', bestaetigt: ['website', 'wer', 'wer', 'x'], einbau: { modus: 'inline', knopfText: 'y'.repeat(60), stelle: ' a\u0000\n\n\n\nb ' } }, + { schema: 3, fremd: 1, bundeslandGewaehlt: true, einbau: { gestaltung: '😀'.repeat(1200), bemerkung: '\r\nzwei\r\n\r\nAbsätze‮' } }, + JSON.parse('{"__proto__":{"a":1},"branche":"werkstatt"}'), + ] + for (const eingabe of eingaben) { + const einmal = ok(eingabe) + assert.deepEqual(norm(einmal), { fragebogen: einmal }) + } +}) + +test('normalisiere: groesste gueltige Eingabe bleibt unter FRAGEBOGEN_MAX (auch mit Zeichen, die JSON verdoppelt)', () => { + // Anfuehrungszeichen werden als \" gespeichert, Absatzumbrueche als \n\n: ungünstigster Fall fuer die Zeichenzahl + const gross = (zeichen, max) => zeichen.repeat(max) + for (const zeichen of ['"', '\\', '😀', 'ä', 'a\n\n']) { + const fragebogen = ok({ + branche: 'werkstatt', + bestaetigt: SCHRITTE, + bundeslandGewaehlt: true, + preiseGeprueft: true, + einbau: { + modus: 'knopf', + knopfText: gross(zeichen, 40), + stelle: gross(zeichen, 300), + gestaltung: gross(zeichen, 1000), + bemerkung: gross(zeichen, 1000), + }, + }) + const json = JSON.stringify(fragebogen) + assert.ok(json.length < FRAGEBOGEN_MAX, `${JSON.stringify(zeichen)}: ${json.length} Zeichen`) + assert.ok(Buffer.byteLength(json, 'utf8') < FRAGEBOGEN_MAX, `${JSON.stringify(zeichen)}: ${Buffer.byteLength(json, 'utf8')} Bytes`) + } +}) diff --git a/test/termineSetup.test.js b/test/termineSetup.test.js index 5821f5b..219ff97 100644 --- a/test/termineSetup.test.js +++ b/test/termineSetup.test.js @@ -75,7 +75,7 @@ const expectedIndexes = NAMES.flatMap((name) => TERMINE_COLLECTIONS[name].indexe test('Schema: Attribute je Collection wie in der Spezifikation', () => { assert.deepEqual(Object.keys(TERMINE_COLLECTIONS), NAMES) - assert.deepEqual(keysOf('termineConfig'), ['customerId', 'projectId', 'einstellung', 'version', 'updatedAt', 'updatedBy']) + assert.deepEqual(keysOf('termineConfig'), ['customerId', 'projectId', 'einstellung', 'version', 'updatedAt', 'updatedBy', 'fragebogen']) assert.deepEqual(keysOf('termineBookings'), [ 'featureId', 'customerId', 'projectId', 'leistungId', 'leistungName', 'ressourceId', 'ressourceName', 'startsAt', 'endsAt', 'blockEndsAt', 'status', 'quelle', @@ -101,6 +101,8 @@ test('Schema: Typen und Groessen', () => { assert.equal(settings.updatedAt.type, 'datetime') assert.equal(settings.updatedBy.size, 100) assert.equal(settings.projectId.size, 64) + // TP2: Fragebogen als JSON-Text; der Inhalt ist auf 16000 Zeichen begrenzt (FRAGEBOGEN_MAX), die Spalte ist groesser + assert.deepEqual(settings.fragebogen, { key: 'fragebogen', type: 'string', size: 20000, required: false }) const bookings = byKey('termineBookings') const sizes = { @@ -313,6 +315,29 @@ test('main: legt idx_booking der Mail-Warteschlange an; eine vorhandene termineO assert.deepEqual(client.calls.attrs.filter((k) => k.startsWith('termineOutbox.')), []) }) +test('main --check meldet in einer vorhandenen termineConfig ohne fragebogen genau dieses Attribut; der Lauf ohne --check legt es an, der zweite nichts', async () => { + const ohneFragebogen = keysOf('termineConfig').filter((key) => key !== 'fragebogen') + const vorhanden = () => fakeClient({ termineConfig: { attrs: ohneFragebogen } }) + + const lines = [] + const pruefung = vorhanden() + const offen = await main({ check: true, client: pruefung, log: (line) => lines.push(line) }) + assert.ok(lines.some((line) => line.includes('termineConfig.fragebogen FEHLT')), lines.join('\n')) + assert.deepEqual(offen.missing.filter((a) => a.collection === 'termineConfig').map((a) => a.key), ['fragebogen']) + assert.deepEqual(offen.missingCollections.includes('termineConfig'), false) + assert.deepEqual(pruefung.calls.attrs, [], '--check legt nichts an') + + const client = vorhanden() + await main({ client, log: quiet }) + assert.deepEqual(client.calls.attrs.filter((k) => k.startsWith('termineConfig.')), ['termineConfig.fragebogen']) + assert.deepEqual(client.state.get('termineConfig').attrs.get('fragebogen'), { key: 'fragebogen', type: 'string', size: 20000, required: false }) + assert.ok(!client.calls.collections.some((c) => c.id === 'termineConfig'), 'vorhandene Collection wird nicht neu angelegt') + + client.calls.attrs.length = 0 + await main({ client, log: quiet }) + assert.deepEqual(client.calls.attrs, [], 'der zweite Lauf legt nichts an') +}) + test('main --check legt nichts an und meldet Fehlendes', async () => { const client = fakeClient() const lines = []