From 31aa80affd36d895cbfcd1a903d05d0173690c51 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 19:14:22 +0000 Subject: [PATCH] Terminplaner TP2: Fragebogen im Kontext und Speicher kontext.js: ctx.fragebogen (normalisiert; fehlt er oder ist er kaputt, der leere, kaputt mit einer Logzeile und ohne Wurf), ladeEinstellung liefert zusaetzlich fragebogen, neuer kontextFuerPortal(feature) fuer das Portal in jedem Zustand. speichereFragebogen schreibt unter termine-cfg: nur fragebogen, updatedAt und updatedBy (ohne version; fehlt das Dokument: 409 keine_einstellung). Neue Option fragebogen bei speichereEinstellung schreibt Einstellung und Fragebogen in derselben Sperre. kontextPerSiteKey bleibt unveraendert (Entwurf nie oeffentlich), Tests dazu fuer off, ordered, setup, disabled und nach activate. Der Test ladeEntwurf in funktionenCheckout.test.js schaltet die neue Logzeile stumm. Co-Authored-By: Claude Sonnet 5.5 --- server/services/termine/kontext.js | 129 ++++++++++- test/funktionenCheckout.test.js | 39 ++-- test/termineKontext.test.js | 344 ++++++++++++++++++++++++++++- 3 files changed, 481 insertions(+), 31 deletions(-) diff --git a/server/services/termine/kontext.js b/server/services/termine/kontext.js index fca8f08..45a8433 100644 --- a/server/services/termine/kontext.js +++ b/server/services/termine/kontext.js @@ -4,6 +4,7 @@ import { analyseHosts } from '../analyse.js' import { withLocks } from '../keyedLock.js' import { findActiveBySiteKey } from '../siteFeatures.js' import { fehlendePflichtangaben } from './einstellungen.js' +import { leerFragebogen, normalisiereFragebogen } from './fragebogen.js' import { termineAus } from './schalter.js' import { pruefeNow, pruefeText } from './util.js' @@ -11,11 +12,12 @@ import { pruefeNow, pruefeText } from './util.js' * Kontext einer Terminplaner-Funktion (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitte 2 und 8) und * Speicher der Betriebs-Einstellung (termineConfig, Dokument-ID = ID des portalFeatures-Dokuments). * - * ctx = { feature, project, einstellung, version } + * ctx = { feature, project, einstellung, version, fragebogen } * feature portalFeatures-Dokument (featureKey 'termine') * project websiteProjects-Dokument oder null (Projekt fehlt) * einstellung gespeicherte Einstellung (Objekt) oder null (noch nie gespeichert / unlesbar) * version Zaehler fuer die optimistische Sperre (0 = noch nie gespeichert; bei unlesbarem JSON die Version des Dokuments) + * fragebogen Bestell-Fragebogen (TP2), immer normalisiert; fehlt er oder ist er unlesbar (Logzeile), der leere (leerFragebogen) * Ein ctx ist nur zum Lesen: kontextPerSiteKey gibt innerhalb von 30 Sekunden dasselbe Objekt zurueck. * * - kontextPerSiteKey (oeffentliche Schnittstelle): nur Funktionen im Zustand active und nur mit gespeicherter @@ -25,6 +27,11 @@ import { pruefeNow, pruefeText } from './util.js' * leert den Speicher. Eine Abfrage, die waehrend eines Speicherns lief, landet nie im Speicher (generation). * - kontextPerFeature (Portal, Gaesteseiten per Token): active und disabled (Gaeste duerfen weiter absagen und widerrufen), * ohne Speicher; liefert auch ohne gespeicherte Einstellung einen ctx (einstellung null, version 0). + * - kontextFuerPortal (Portal, Entwurf und Bestellung): das uebergebene portalFeatures-Dokument in jedem Zustand (off, ordered, + * setup, active, disabled) ohne Modus- und Zustandspruefung (die macht der Aufrufer), ohne Speicher. + * Der Fragebogen steht im selben Dokument wie die Einstellung (termineConfig.fragebogen, JSON-Text) und gehoert nur dem + * Entwurf: speichereFragebogen aendert ihn ohne die Version der Einstellung anzufassen (der Fragebogen-Editor und der + * Einstellungs-Editor stoeren sich so nicht), beide Schreibwege laufen unter derselben Sperre termine-cfg:. * Fehlt eine Collection (404 collection_not_found), wird der Fehler weitergereicht und nicht als "nicht gefunden" gelesen. */ @@ -69,15 +76,34 @@ function liesDokument(doc) { } /** - * Die gespeicherte Einstellung einer Funktion. + * Der Fragebogen aus einem termineConfig-Dokument, normalisiert. Fehlt das Attribut (Altbestand, noch nie gespeichert), ist es + * der leere Fragebogen ohne Meldung; kaputtes JSON oder ein Inhalt, den die Normalisierung ablehnt, ergibt ebenfalls den leeren + * mit genau einer Logzeile (Dokument-ID und Grund) und wirft nie: der Fragebogen darf Einstellung und Kontext nicht blockieren. + */ +function fragebogenAus(doc) { + const roh = doc.fragebogen + if (roh === undefined || roh === null || roh === '') return leerFragebogen() + try { + const erg = normalisiereFragebogen(JSON.parse(roh)) + if (erg.fehler) throw new Error(`ungueltige Angaben (${Object.keys(erg.fehler).join(', ')})`) + return erg.fragebogen + } catch (err) { + console.error(`[termine-kontext] Fragebogen ${doc.$id} ist unlesbar: ${err.message}`) + return leerFragebogen() + } +} + +/** + * Die gespeicherte Einstellung einer Funktion samt Fragebogen. * @param {string} featureId ID des portalFeatures-Dokuments - * @returns {Promise<{ einstellung: object, version: number }|null>} null, wenn es noch keine (lesbare) gibt + * @returns {Promise<{ einstellung: object, version: number, fragebogen: object }|null>} null, wenn es noch keine (lesbare) + * Einstellung gibt; fragebogen ist immer normalisiert (leer, wenn nichts oder nichts Lesbares gespeichert ist) */ export async function ladeEinstellung(featureId) { pruefeText(featureId, 'featureId') const doc = await holeOderNull(config.collections.termineConfig, featureId) const gelesen = doc ? liesDokument(doc) : null - return gelesen?.einstellung ? gelesen : null + return gelesen?.einstellung ? { ...gelesen, fragebogen: fragebogenAus(doc) } : null } async function baueKontext(feature) { @@ -86,7 +112,13 @@ async function baueKontext(feature) { holeOderNull(config.collections.termineConfig, feature.$id), ]) const gelesen = doc ? liesDokument(doc) : null - return { feature, project, einstellung: gelesen?.einstellung ?? null, version: gelesen?.version ?? 0 } + return { + feature, + project, + einstellung: gelesen?.einstellung ?? null, + version: gelesen?.version ?? 0, + fragebogen: doc ? fragebogenAus(doc) : leerFragebogen(), + } } /** @@ -132,12 +164,37 @@ export async function kontextPerFeature(featureId) { return baueKontext(feature) } +/** + * Kontext fuer das Portal (Entwurf, Bestellung, Einbau): das uebergebene portalFeatures-Dokument in jedem Zustand, ohne Speicher. + * Es gibt keine Modus- und keine Zustandspruefung und keinen Abgleich mit der Datenbank: das Dokument ist das, das der Aufrufer + * schon (frisch) gelesen und geprueft hat. Oeffentlich ist dieser Kontext nie, dafuer gibt es kontextPerSiteKey. + * @param {{ $id: string, projectId?: string }} feature portalFeatures-Dokument + * @returns {Promise} ctx; ohne termineConfig-Dokument einstellung null, version 0, leerer Fragebogen + */ +export async function kontextFuerPortal(feature) { + pruefeText(feature?.$id, 'feature.$id') + return baueKontext(feature) +} + const veraltet = () => Object.assign(new Error('Die Einstellungen wurden inzwischen geändert. Bitte neu laden.'), { status: 409, code: 'EINSTELLUNG_VERALTET', }) +const keineEinstellung = () => + Object.assign(new Error('Es gibt noch keinen Entwurf für den Terminplaner. Bitte wähle zuerst eine Branche.'), { + status: 409, + code: 'keine_einstellung', + }) + +/** Eingabe normalisieren und als JSON-Text fuer termineConfig.fragebogen bereitstellen; ungueltig => 400 mit err.fehler. */ +function pruefeFragebogen(eingabe) { + const erg = normalisiereFragebogen(eingabe) + if (erg.fehler) throw Object.assign(new Error('Der Fragebogen ist ungültig.'), { status: 400, fehler: erg.fehler }) + return { fragebogen: erg.fragebogen, json: JSON.stringify(erg.fragebogen) } +} + /** * Speichert die (bereits geprueften) Einstellungen. Optimistisch gesperrt: version ist die Version, auf der die * Aenderung beruht (0 = noch nie gespeichert); weicht sie von der gespeicherten ab, passiert nichts und es folgt @@ -149,12 +206,19 @@ const veraltet = () => * die den gespeicherten Stand und die Buchungen brauchen (Entfernen mit kuenftigen Terminen), unter derselben Sperre wie * das Schreiben. version null ueberspringt die Versionspruefung (nur fuer Aenderungen, die auf dem aktuellen Stand * aufbauen, z. B. neuer Kalender-Link); die Version zaehlt trotzdem hoch. + * Mit der Option `fragebogen` (TP2: Neuanlage mit Branche, "Vorlage ersetzen" mit zurueckgesetzten Bestaetigungen) schreibt + * derselbe Vorgang unter derselben Sperre auch den Fragebogen: entweder die Eingabe (Objekt, wird normalisiert) oder eine + * Funktion `(bisher) => Eingabe` (bisher = gespeicherter Fragebogen, leer wenn keiner da ist), die wie die Einstellungs-Funktion + * nach der Versionspruefung laeuft, sodass ein Einbau-Wunsch, der zwischendurch gespeichert wurde, nicht verloren geht. Ist sie + * ungueltig, gibt es { status: 400, fehler } und es wird nichts geschrieben (weder Einstellung noch Version noch Dokument). + * Ohne die Option bleibt der gespeicherte Fragebogen unberuehrt. * @param {{ $id: string, customerId: string, projectId: string }} feature portalFeatures-Dokument * @param {object|((bisher: object|null) => object|Promise)} einstellungOderFn - * @param {{ version: number|null, by?: string, now?: Date }} optionen - * @returns {Promise<{ einstellung: object, version: number }>} wie gespeichert, mit der neuen Version + * @param {{ version: number|null, by?: string, now?: Date, fragebogen?: object|((bisher: object) => object|Promise) }} optionen + * @returns {Promise<{ einstellung: object, version: number, fragebogen?: object }>} wie gespeichert, mit der neuen Version; + * fragebogen (normalisiert) nur, wenn die Option gesetzt war */ -export async function speichereEinstellung(feature, einstellungOderFn, { version, by = '', now = new Date() } = {}) { +export async function speichereEinstellung(feature, einstellungOderFn, { version, by = '', now = new Date(), fragebogen } = {}) { pruefeText(feature?.$id, 'feature.$id') const alsFunktion = typeof einstellungOderFn === 'function' if (!alsFunktion && !istObjekt(einstellungOderFn)) throw new TypeError('einstellung fehlt') @@ -170,6 +234,13 @@ export async function speichereEinstellung(feature, einstellungOderFn, { version if (!istObjekt(einstellung)) throw new TypeError('einstellung fehlt') const json = JSON.stringify(einstellung) + // der Fragebogen wird vor dem ersten Schreibzugriff geprueft: ein ungueltiger laesst auch die Einstellung ungespeichert + let neuerFragebogen + if (fragebogen !== undefined) { + const eingabe = typeof fragebogen === 'function' ? await fragebogen(vorhanden ? fragebogenAus(vorhanden) : leerFragebogen()) : fragebogen + neuerFragebogen = pruefeFragebogen(eingabe) + } + const felder = { customerId: feature.customerId, projectId: feature.projectId, @@ -177,6 +248,7 @@ export async function speichereEinstellung(feature, einstellungOderFn, { version version: aktuell + 1, updatedAt: new Date(nowMs).toISOString(), updatedBy: String(by ?? '').slice(0, 100), + ...(neuerFragebogen ? { fragebogen: neuerFragebogen.json } : {}), } try { if (vorhanden) await updateDocument(col, feature.$id, felder) @@ -186,7 +258,46 @@ export async function speichereEinstellung(feature, einstellungOderFn, { version generation += 1 cache.clear() } - return { einstellung: JSON.parse(json), version: aktuell + 1 } + return { einstellung: JSON.parse(json), version: aktuell + 1, ...(neuerFragebogen ? { fragebogen: neuerFragebogen.fragebogen } : {}) } + }) +} + +/** + * Speichert den Fragebogen einer Funktion (die Eingaben des Kunden im Bestell-Fragebogen). Gleiche Sperre wie + * speichereEinstellung (termine-cfg:); `aendere(bisher)` laeuft darin, bekommt den gespeicherten Fragebogen + * (leer, wenn keiner gespeichert oder lesbar ist) und liefert die zu speichernde Eingabe, die normalisiert wird. Sie darf werfen + * (der Fehler geht unveraendert durch, es wird nichts geschrieben). Eine ungueltige Eingabe wirft { status: 400, fehler }. + * Geschrieben werden nur fragebogen, updatedAt und updatedBy: `version` und `einstellung` bleiben unberuehrt, ein offener + * Einstellungs-Editor mit der gelesenen Version kann also weiter speichern. Gibt es noch kein termineConfig-Dokument, wird keins + * angelegt, sondern { status: 409, code: 'keine_einstellung' } geworfen. Danach ist der Speicher von kontextPerSiteKey leer. + * @param {{ $id: string }} feature portalFeatures-Dokument + * @param {(bisher: object) => object|Promise} aendere + * @param {{ by?: string, now?: Date }} optionen + * @returns {Promise<{ fragebogen: object }>} der gespeicherte, normalisierte Fragebogen + */ +export async function speichereFragebogen(feature, aendere, { by = '', now = new Date() } = {}) { + pruefeText(feature?.$id, 'feature.$id') + if (typeof aendere !== 'function') throw new TypeError('aendere fehlt') + const nowMs = pruefeNow(now, 'speichereFragebogen') + + return withLocks([`termine-cfg:${feature.$id}`], async () => { + const col = config.collections.termineConfig + const vorhanden = await holeOderNull(col, feature.$id) + if (!vorhanden) throw keineEinstellung() + + const { fragebogen, json } = pruefeFragebogen(await aendere(fragebogenAus(vorhanden))) + try { + await updateDocument(col, feature.$id, { + fragebogen: json, + updatedAt: new Date(nowMs).toISOString(), + updatedBy: String(by ?? '').slice(0, 100), + }) + } finally { + // auch bei einem Fehler: unklar, was in der Datenbank steht + generation += 1 + cache.clear() + } + return { fragebogen } }) } diff --git a/test/funktionenCheckout.test.js b/test/funktionenCheckout.test.js index 52a1a73..577b6a1 100644 --- a/test/funktionenCheckout.test.js +++ b/test/funktionenCheckout.test.js @@ -1155,28 +1155,29 @@ test('ladeEntwurf: Einstellung und Fragebogen aus termineConfig, fehlend oder ka const doc = (extra) => aw.seed('termineConfig', { $id: 'f_p1_termine', customerId: 'c1', projectId: 'p1', einstellung: JSON.stringify(einstellung), version: 3, updatedAt: new Date().toISOString(), updatedBy: 'x', ...extra }) assert.deepEqual(await order.ladeEntwurf('f_p1_termine'), { einstellung: null, fragebogen: null }, 'kein Dokument') - doc({ fragebogen: JSON.stringify(fragebogen) }) - assert.deepEqual(await order.ladeEntwurf('f_p1_termine'), { einstellung, fragebogen }) - doc({}) - assert.deepEqual(await order.ladeEntwurf('f_p1_termine'), { einstellung, fragebogen: null }, 'Attribut fehlt') - for (const kaputt of ['{kaputt', '', '[1,2]', '42', '"text"', 'null']) { - doc({ fragebogen: kaputt }) - assert.deepEqual(await order.ladeEntwurf('f_p1_termine'), { einstellung, fragebogen: null }, `fragebogen ${JSON.stringify(kaputt)}`) - } - // Einstellung unlesbar: null (der Fehler wird im Kontext-Modul geloggt), der Fragebogen bleibt - const log = stumm() + // ladeEinstellung (Kontext-Modul) meldet einen Fragebogen, den die Normalisierung ablehnt, mit einer Logzeile: hier stumm + const leise = stumm() try { + doc({ fragebogen: JSON.stringify(fragebogen) }) + assert.deepEqual(await order.ladeEntwurf('f_p1_termine'), { einstellung, fragebogen }) + doc({}) + assert.deepEqual(await order.ladeEntwurf('f_p1_termine'), { einstellung, fragebogen: null }, 'Attribut fehlt') + for (const kaputt of ['{kaputt', '', '[1,2]', '42', '"text"', 'null']) { + doc({ fragebogen: kaputt }) + assert.deepEqual(await order.ladeEntwurf('f_p1_termine'), { einstellung, fragebogen: null }, `fragebogen ${JSON.stringify(kaputt)}`) + } + // Einstellung unlesbar: null (der Fehler wird im Kontext-Modul geloggt), der Fragebogen bleibt doc({ einstellung: '{kaputt', fragebogen: JSON.stringify(fragebogen) }) assert.deepEqual(await order.ladeEntwurf('f_p1_termine'), { einstellung: null, fragebogen }) + // Lesefehler werden nicht verschluckt + doc({ fragebogen: JSON.stringify(fragebogen) }) + aw.failNext({ collection: 'termineConfig', status: 500 }) + await assert.rejects(order.ladeEntwurf('f_p1_termine')) + aw.failNext({ collection: 'termineConfig', method: 'GET', status: 500, after: 1 }) + await assert.rejects(order.ladeEntwurf('f_p1_termine')) + await assert.doesNotReject(order.ladeEntwurf('f_p1_termine')) + await assert.rejects(order.ladeEntwurf(''), TypeError) } finally { - log.mock.restore() + leise.mock.restore() } - // Lesefehler werden nicht verschluckt - doc({ fragebogen: JSON.stringify(fragebogen) }) - aw.failNext({ collection: 'termineConfig', status: 500 }) - await assert.rejects(order.ladeEntwurf('f_p1_termine')) - aw.failNext({ collection: 'termineConfig', method: 'GET', status: 500, after: 1 }) - await assert.rejects(order.ladeEntwurf('f_p1_termine')) - await assert.doesNotReject(order.ladeEntwurf('f_p1_termine')) - await assert.rejects(order.ladeEntwurf(''), TypeError) }) diff --git a/test/termineKontext.test.js b/test/termineKontext.test.js index 3b75bca..6c38865 100644 --- a/test/termineKontext.test.js +++ b/test/termineKontext.test.js @@ -24,12 +24,14 @@ const { config } = await import('../server/config.js') const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const { renderMail, loadTemplate } = await import('../server/services/mailTemplates.js') -const { normalisiereEinstellung } = await import('../server/services/termine/einstellungen.js') +const { normalisiereEinstellung, fehlendePflichtangaben } = await import('../server/services/termine/einstellungen.js') const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js') const { buchungsNummer, verwaltungsToken } = await import('../server/services/termine/token.js') +const { leerFragebogen, normalisiereFragebogen, FRAGEBOGEN_MAX } = await import('../server/services/termine/fragebogen.js') +const { activate } = await import('../server/services/siteFeatures.js') const K = await import('../server/services/termine/kontext.js') const M = await import('../server/services/termine/mails.js') -const { ladeEinstellung, speichereEinstellung, kontextPerSiteKey, kontextPerFeature, websiteUrl, resetKontextForTests } = K +const { ladeEinstellung, speichereEinstellung, speichereFragebogen, kontextPerSiteKey, kontextPerFeature, kontextFuerPortal, websiteUrl, resetKontextForTests } = K const { BETREFFE, werteFuer, anGast, anBetrieb } = M after(() => setMailTransportForTests(null)) @@ -205,7 +207,7 @@ test('speichereEinstellung: erstes Speichern legt das Dokument unter der Funktio assert.equal(typeof doc.einstellung, 'string', 'JSON-Text im Attribut einstellung') assert.deepEqual(JSON.parse(doc.einstellung), e) assert.equal(Object.hasOwn(doc, 'data'), false, 'nie ein Attribut data') - assert.deepEqual(await ladeEinstellung(FEATURE), { einstellung: e, version: 1 }) + assert.deepEqual(await ladeEinstellung(FEATURE), { einstellung: e, version: 1, fragebogen: leerFragebogen() }, 'ohne gespeicherten Fragebogen der leere') }) test('speichereEinstellung: zweites Speichern mit der gelesenen Version zaehlt hoch und ersetzt die Einstellung', async () => { @@ -552,6 +554,342 @@ test('websiteUrl: betrieb.websiteUrl, sonst Live-Domain, sonst Vorschau-Subdomai assert.equal(websiteUrl(null), config.publicBaseUrl) }) +// ---------------------------------------------------------------- Fragebogen im Speicher und im Kontext (TP2) + +/** Normalisierter Fragebogen aus einer (teilweisen) Eingabe; die Eingabe muss gueltig sein. */ +const frage = (over = {}) => { + const erg = normalisiereFragebogen({ ...leerFragebogen(), ...over }) + assert.equal(erg.fehler, undefined, JSON.stringify(erg.fehler)) + return erg.fragebogen +} + +test('speichereFragebogen: aendert nur fragebogen, updatedAt und updatedBy; einstellung und version bleiben bytegleich, ladeEinstellung liefert den neuen Stand', async () => { + const e = einstellung() + await speichereEinstellung(feature(), e, { version: 0, by: 'kunde:c_kunde01', now: JETZT }) + const vorher = { ...gespeichert() } + + const gesehen = [] + const erg = await speichereFragebogen(feature(), (bisher) => { + gesehen.push(structuredClone(bisher)) + return { ...bisher, branche: 'salon', bestaetigt: ['wer', 'branche'], einbau: { ...bisher.einbau, modus: 'knopf', knopfText: 'Termin buchen' } } + }, { by: 'kunde:zweiter', now: SEKUNDEN(5) }) + const erwartet = frage({ branche: 'salon', bestaetigt: ['branche', 'wer'], einbau: { modus: 'knopf', knopfText: 'Termin buchen' } }) + assert.deepEqual(gesehen, [leerFragebogen()], 'ohne gespeicherten Fragebogen bekommt aendere den leeren') + assert.deepEqual(erg, { fragebogen: erwartet }, 'zurueck kommt der normalisierte Fragebogen') + + const doc = gespeichert() + assert.equal(doc.einstellung, vorher.einstellung, 'einstellung bytegleich') + assert.equal(doc.version, vorher.version, 'version bleibt') + assert.equal(doc.version, 1) + assert.equal(doc.customerId, KUNDE) + assert.equal(doc.projectId, PROJEKT) + assert.equal(typeof doc.fragebogen, 'string', 'JSON-Text') + assert.deepEqual(JSON.parse(doc.fragebogen), erwartet) + assert.equal(doc.updatedBy, 'kunde:zweiter') + assert.equal(new Date(doc.updatedAt).getTime(), SEKUNDEN(5).getTime()) + assert.deepEqual(await ladeEinstellung(FEATURE), { einstellung: e, version: 1, fragebogen: erwartet }) + + // zweites Mal: aendere sieht den gespeicherten Stand; updatedBy wird auf 100 Zeichen gekuerzt + await speichereFragebogen(feature(), (bisher) => { + gesehen.push(structuredClone(bisher)) + return { ...bisher, preiseGeprueft: true } + }, { by: 'x'.repeat(300) }) + assert.deepEqual(gesehen[1], erwartet) + assert.equal(gespeichert().updatedBy.length, 100) + assert.equal(gespeichert().einstellung, vorher.einstellung) + assert.equal((await ladeEinstellung(FEATURE)).fragebogen.preiseGeprueft, true) + assert.equal((await ladeEinstellung(FEATURE)).version, 1) +}) + +test('speichereFragebogen: auch eine asynchrone aendere-Funktion; unbekannte Schluessel und __proto__ aus der Eingabe landen nie im Speicher', async () => { + await speichereEinstellung(feature(), einstellung(), { version: 0 }) + const boese = JSON.parse('{"branche":"salon","__proto__":{"admin":true},"fremd":1,"einbau":{"stelle":"Startseite","__proto__":{"x":1},"zusatz":"y"}}') + const erg = await speichereFragebogen(feature(), async () => boese, {}) + assert.deepEqual(erg.fragebogen, frage({ branche: 'salon', einbau: { stelle: 'Startseite' } })) + assert.deepEqual(JSON.parse(gespeichert().fragebogen), erg.fragebogen) + assert.equal(Object.hasOwn(JSON.parse(gespeichert().fragebogen), 'fremd'), false) +}) + +test('speichereFragebogen: ungueltige Eingabe => 400 mit err.fehler, eigene Fehler gehen unveraendert durch, nichts wird geschrieben; falsche Argumente => TypeError', async () => { + await speichereEinstellung(feature(), einstellung(), { version: 0 }) + await speichereFragebogen(feature(), () => frage({ branche: 'salon' }), {}) + const vorher = { ...gespeichert() } + + await assert.rejects( + () => speichereFragebogen(feature(), (bisher) => ({ ...bisher, einbau: { ...bisher.einbau, modus: 'popup' } }), {}), + (err) => err.status === 400 && typeof err.fehler['einbau.modus'] === 'string', + ) + for (const unsinn of [null, undefined, 'x', 5, []]) { + await assert.rejects( + () => speichereFragebogen(feature(), () => unsinn, {}), + (err) => err.status === 400 && typeof err.fehler._ === 'string', + String(unsinn), + ) + } + const eigener = Object.assign(new Error('Eingabe'), { status: 400, fehler: { branche: 'x' } }) + await assert.rejects(() => speichereFragebogen(feature(), async () => { throw eigener }, {}), (err) => err === eigener) + assert.deepEqual(gespeichert(), vorher, 'kein Schreibzugriff') + + for (const unsinn of [undefined, null, 'x', {}]) await assert.rejects(() => speichereFragebogen(feature(), unsinn, {}), TypeError) + await assert.rejects(() => speichereFragebogen({}, () => frage(), {}), TypeError) + await assert.rejects(() => speichereFragebogen(feature(), () => frage(), { now: 'gestern' }), TypeError) + assert.deepEqual(gespeichert(), vorher) + // das Sperrschloss ist wieder frei + assert.equal((await speichereFragebogen(feature(), () => frage({ branche: 'kosmetik' }), {})).fragebogen.branche, 'kosmetik') +}) + +test('speichereFragebogen ohne termineConfig-Dokument: 409 keine_einstellung, aendere laeuft nie, nichts wird angelegt', async () => { + seedFeature() + let aufrufe = 0 + await assert.rejects( + () => speichereFragebogen(feature(), () => { aufrufe += 1; return frage() }, {}), + (err) => err.status === 409 && err.code === 'keine_einstellung' && typeof err.message === 'string' && err.message !== '', + ) + assert.equal(aufrufe, 0) + assert.equal(aw.docs('termineConfig').size, 0) + assert.equal(await ladeEinstellung(FEATURE), null) +}) + +test('speichereFragebogen: der groesste zulaessige Fragebogen bleibt im Speicher unter FRAGEBOGEN_MAX', async () => { + await speichereEinstellung(feature(), einstellung(), { version: 0 }) + const alle = ['branche', 'wer', 'leistungen', 'zeiten', 'regeln', 'gastfelder', 'benachrichtigung', 'website'] + await speichereFragebogen(feature(), () => frage({ + branche: 'sonstiges', + bestaetigt: alle, + bundeslandGewaehlt: true, + preiseGeprueft: true, + // Zeichen, die im JSON-Text verdoppelt werden + einbau: { modus: 'inline', knopfText: '"'.repeat(40), stelle: '\\'.repeat(300), gestaltung: '"'.repeat(1000), bemerkung: '\\'.repeat(1000) }, + }), {}) + assert.ok(gespeichert().fragebogen.length < FRAGEBOGEN_MAX, `${gespeichert().fragebogen.length} Zeichen`) + assert.equal(JSON.parse(gespeichert().fragebogen).einbau.bemerkung.length, 1000) +}) + +test('speichereEinstellung ohne Option fragebogen laesst den gespeicherten Fragebogen stehen (bytegleich), auch in der Funktionsform', async () => { + await speichereEinstellung(feature(), einstellung(), { version: 0 }) + await speichereFragebogen(feature(), () => frage({ branche: 'salon', bestaetigt: ['branche'] }), {}) + const roh = gespeichert().fragebogen + assert.ok(roh) + + await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ name: 'Neu' }) }), { version: 1 }) + assert.equal(gespeichert().fragebogen, roh) + await speichereEinstellung(feature(), async (bisher) => ({ ...bisher, farbe: '#112233' }), { version: null }) + assert.equal(gespeichert().fragebogen, roh) + + const geladen = await ladeEinstellung(FEATURE) + assert.equal(geladen.version, 3) + assert.equal(geladen.einstellung.betrieb.name, 'Neu') + assert.deepEqual(geladen.fragebogen, frage({ branche: 'salon', bestaetigt: ['branche'] })) +}) + +test('speichereEinstellung mit Option fragebogen: Neuanlage auf leerer Collection schreibt Einstellung und Fragebogen in ein Dokument', async () => { + const e = einstellung() + const erg = await speichereEinstellung(feature(), e, { version: 0, by: 'kunde:c_kunde01', now: JETZT, fragebogen: { branche: 'salon' } }) + assert.equal(erg.version, 1) + assert.deepEqual(erg.einstellung, e) + assert.equal(aw.docs('termineConfig').size, 1) + const doc = gespeichert() + assert.deepEqual(JSON.parse(doc.einstellung), e) + assert.deepEqual(JSON.parse(doc.fragebogen), frage({ branche: 'salon' }), 'normalisiert (alle Felder) gespeichert') + assert.equal(doc.version, 1) + assert.equal(doc.updatedBy, 'kunde:c_kunde01') + assert.deepEqual(await ladeEinstellung(FEATURE), { einstellung: e, version: 1, fragebogen: frage({ branche: 'salon' }) }) +}) + +test('speichereEinstellung mit Option fragebogen: ersetzt den Fragebogen in derselben Sperre (Vorlage ersetzen), Objekt- und Funktionsform, Version zaehlt einmal', async () => { + await speichereEinstellung(feature(), einstellung(), { version: 0 }) + const einbau = { modus: 'knopf', knopfText: 'Termin buchen', stelle: 'Startseite' } + await speichereFragebogen(feature(), () => frage({ branche: 'salon', bestaetigt: ['branche', 'wer'], preiseGeprueft: true, bundeslandGewaehlt: true, einbau }), {}) + + // Funktionsform: sieht den gespeicherten Fragebogen und setzt Bestaetigungen zurueck, der Einbau bleibt + const gesehen = [] + const ersatz = einstellung({ betrieb: betriebMit({ name: 'Ersatz' }) }) + const erg = await speichereEinstellung(feature(), ersatz, { + version: 1, + fragebogen: (bisher) => { + gesehen.push(structuredClone(bisher)) + return { ...bisher, branche: 'kosmetik', bestaetigt: [], preiseGeprueft: false, bundeslandGewaehlt: false } + }, + }) + assert.equal(erg.version, 2) + assert.equal(gesehen.length, 1) + assert.equal(gesehen[0].branche, 'salon') + const geladen = await ladeEinstellung(FEATURE) + assert.equal(geladen.version, 2) + assert.equal(geladen.einstellung.betrieb.name, 'Ersatz') + assert.deepEqual(geladen.fragebogen, frage({ branche: 'kosmetik', einbau })) + + // Objektform ersetzt vollstaendig + await speichereEinstellung(feature(), ersatz, { version: 2, fragebogen: { branche: 'beratung' } }) + assert.deepEqual((await ladeEinstellung(FEATURE)).fragebogen, frage({ branche: 'beratung' })) + assert.equal((await ladeEinstellung(FEATURE)).version, 3) +}) + +test('speichereEinstellung mit ungueltigem fragebogen: 400 mit err.fehler, weder Einstellung noch Version noch Dokument werden geschrieben', async () => { + const falsch = (err) => err.status === 400 && typeof err.fehler === 'object' && Object.keys(err.fehler).length > 0 + await assert.rejects(() => speichereEinstellung(feature(), einstellung(), { version: 0, fragebogen: { branche: 'gibtsnicht' } }), (err) => falsch(err) && typeof err.fehler.branche === 'string') + assert.equal(aw.docs('termineConfig').size, 0, 'Neuanlage scheitert ganz') + + await speichereEinstellung(feature(), einstellung(), { version: 0, fragebogen: { branche: 'salon' } }) + const vorher = { ...gespeichert() } + const neu = einstellung({ betrieb: betriebMit({ name: 'Anders' }) }) + await assert.rejects(() => speichereEinstellung(feature(), neu, { version: 1, fragebogen: { einbau: { modus: 'popup' } } }), (err) => falsch(err) && typeof err.fehler['einbau.modus'] === 'string') + for (const unsinn of ['x', 5, [], null]) { + await assert.rejects(() => speichereEinstellung(feature(), neu, { version: 1, fragebogen: unsinn }), falsch, String(unsinn)) + } + assert.deepEqual(gespeichert(), vorher) + assert.equal((await ladeEinstellung(FEATURE)).version, 1) + // eine veraltete Version meldet weiter zuerst 409 + await assert.rejects(() => speichereEinstellung(feature(), neu, { version: 0, fragebogen: { branche: 'gibtsnicht' } }), (err) => err.status === 409 && err.code === 'EINSTELLUNG_VERALTET') +}) + +test('speichereEinstellung (Version 1) und speichereFragebogen gleichzeitig: nacheinander unter derselben Sperre, beide Aenderungen gelten, die Version steigt genau einmal', async () => { + for (const zuerst of ['einstellung', 'fragebogen']) { + await leere() + resetKontextForTests() + await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ name: 'Start' }) }), { version: 0 }) + const ablauf = [] + const langsam = async (name) => { + ablauf.push(`${name} start`) + await new Promise((resolve) => setTimeout(resolve, 20)) // der Zweite haette Zeit, dazwischenzufunken + ablauf.push(`${name} ende`) + } + const alsEinstellung = () => speichereEinstellung(feature(), async (bisher) => { + await langsam('einstellung') + return { ...bisher, betrieb: { ...bisher.betrieb, name: 'Neu' } } + }, { version: 1 }) + const alsFragebogen = () => speichereFragebogen(feature(), async (bisher) => { + await langsam('fragebogen') + return { ...bisher, branche: 'salon' } + }, {}) + await Promise.all(zuerst === 'einstellung' ? [alsEinstellung(), alsFragebogen()] : [alsFragebogen(), alsEinstellung()]) + + const zweiter = zuerst === 'einstellung' ? 'fragebogen' : 'einstellung' + assert.deepEqual(ablauf, [`${zuerst} start`, `${zuerst} ende`, `${zweiter} start`, `${zweiter} ende`], zuerst) + const geladen = await ladeEinstellung(FEATURE) + assert.equal(geladen.version, 2, `Version steigt genau einmal (${zuerst} zuerst)`) + assert.equal(geladen.einstellung.betrieb.name, 'Neu', zuerst) + assert.equal(geladen.fragebogen.branche, 'salon', zuerst) + } +}) + +test('kaputter oder unbrauchbarer Fragebogen im Dokument: leerer Fragebogen mit einer Logzeile ohne Wurf, die Einstellung bleibt gueltig; fehlend oder leer ohne Logzeile', async () => { + seedFeature() + seedProjekt() + const e = einstellung() + const basis = { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, einstellung: JSON.stringify(e), version: 2 } + const kaputt = ['{kaputt', '[]', '"x"', 'null', '5', '{"branche":"gibtsnicht"}', '{"einbau":{"modus":"popup"}}'] + for (const roh of kaputt) { + log.error.mock.resetCalls() + aw.seed('termineConfig', { ...basis, fragebogen: roh }) + assert.deepEqual(await ladeEinstellung(FEATURE), { einstellung: e, version: 2, fragebogen: leerFragebogen() }, roh) + assert.equal(log.error.mock.callCount(), 1, `eine Logzeile: ${roh}`) + const zeile = String(log.error.mock.calls[0].arguments[0]) + assert.match(zeile, /Fragebogen f_p_projekt01_termine/) + const ctx = await kontextFuerPortal(feature()) + assert.deepEqual(ctx.fragebogen, leerFragebogen(), roh) + assert.deepEqual(ctx.einstellung, e, roh) + assert.equal(ctx.version, 2, roh) + } + + for (const ohne of [{}, { fragebogen: '' }, { fragebogen: null }]) { + log.error.mock.resetCalls() + aw.seed('termineConfig', { ...basis, ...ohne }) + assert.deepEqual((await ladeEinstellung(FEATURE)).fragebogen, leerFragebogen(), JSON.stringify(ohne)) + assert.equal(log.error.mock.callCount(), 0, JSON.stringify(ohne)) + } + + // der Schaden laesst sich ueberschreiben: aendere bekommt den leeren Fragebogen, die Einstellung bleibt unberuehrt + aw.seed('termineConfig', { ...basis, fragebogen: '{kaputt' }) + let gesehen + await speichereFragebogen(feature(), (bisher) => { gesehen = bisher; return frage({ branche: 'salon' }) }, {}) + assert.deepEqual(gesehen, leerFragebogen()) + assert.equal(gespeichert().einstellung, basis.einstellung) + assert.equal(gespeichert().version, 2) + assert.deepEqual((await ladeEinstellung(FEATURE)).fragebogen, frage({ branche: 'salon' })) +}) + +test('ctx.fragebogen: Kontext per siteKey, per Feature und fuers Portal tragen ihn (ohne Dokument den leeren); speichereFragebogen leert den Speicher', async () => { + seedFeature() + seedProjekt() + assert.deepEqual((await kontextPerFeature(FEATURE)).fragebogen, leerFragebogen(), 'kein termineConfig-Dokument') + await speichereEinstellung(feature(), einstellung(), { version: 0 }) + assert.deepEqual((await kontextPerSiteKey(SITE_KEY, T0)).fragebogen, leerFragebogen()) + + await speichereFragebogen(feature(), () => frage({ branche: 'salon', bestaetigt: ['branche'] }), {}) + const neu = await kontextPerSiteKey(SITE_KEY, SEKUNDEN(1)) + assert.deepEqual(neu.fragebogen, frage({ branche: 'salon', bestaetigt: ['branche'] }), 'sofort sichtbar: Speicher geleert') + assert.equal(neu.version, 1, 'die Version der Einstellung zaehlt nicht hoch') + assert.equal((await kontextPerFeature(FEATURE)).fragebogen.branche, 'salon') + assert.equal((await kontextFuerPortal(feature())).fragebogen.branche, 'salon') +}) + +test('kontextFuerPortal: das uebergebene Dokument in jedem Zustand und bei jedem Modus, ohne Nachschlagen und ohne Speicher', async () => { + seedProjekt() + const e = einstellung() + // bewusst kein portalFeatures-Dokument in der Datenbank: der Kontext nimmt das uebergebene, Modus und Zustand prueft der Aufrufer + await speichereEinstellung(feature(), e, { version: 0 }) + await speichereFragebogen(feature(), () => frage({ branche: 'salon' }), {}) + assert.equal(aw.docs('portalFeatures').size, 0) + + for (const modus of ['pilot', 'off']) { + config.termine.mode = modus + for (const state of ['off', 'ordered', 'setup', 'active', 'disabled']) { + const doc = feature({ state }) + const ctx = await kontextFuerPortal(doc) + const was = `${modus}/${state}` + assert.equal(ctx.feature, doc, was) + assert.equal(ctx.feature.state, state, was) + assert.equal(ctx.project.$id, PROJEKT, was) + assert.deepEqual(ctx.einstellung, e, was) + assert.equal(ctx.version, 1, was) + assert.equal(ctx.fragebogen.branche, 'salon', was) + } + } + + // kein Speicher: eine Aenderung ist sofort sichtbar, auch ohne dass jemand den Speicher leert + await speichereFragebogen(feature(), (b) => ({ ...b, branche: 'kosmetik' }), {}) + assert.equal((await kontextFuerPortal(feature())).fragebogen.branche, 'kosmetik') + + // ohne termineConfig-Dokument und ohne Projekt + await deleteDocument('termineConfig', FEATURE) + const leer = await kontextFuerPortal(feature({ projectId: 'p_gibt_es_nicht', state: 'off' })) + assert.equal(leer.einstellung, null) + assert.equal(leer.version, 0) + assert.equal(leer.project, null) + assert.deepEqual(leer.fragebogen, leerFragebogen()) + + for (const unsinn of [undefined, null, {}, { $id: '' }, 'f_x']) await assert.rejects(() => kontextFuerPortal(unsinn), TypeError, String(unsinn)) +}) + +test('Entwurf nie oeffentlich: kontextPerSiteKey ist fuer off, ordered, setup und disabled trotz vollstaendiger Einstellung null, kontextFuerPortal liefert sie; nach activate gibt es den Kontext', async () => { + const e = einstellung() + assert.deepEqual(fehlendePflichtangaben(e), [], 'die Einstellung ist fuer Gaeste vollstaendig') + await speichereEinstellung(feature(), e, { version: 0 }) + await speichereFragebogen(feature(), () => frage({ branche: 'salon', bestaetigt: ['branche', 'wer', 'leistungen'] }), {}) + seedProjekt() + + for (const state of ['off', 'ordered', 'setup', 'disabled']) { + resetKontextForTests() + seedFeature({ state, ...(state === 'disabled' ? { disabledAt: JETZT.toISOString(), disabledReason: 'abo_ende' } : {}) }) + assert.equal(await kontextPerSiteKey(SITE_KEY, T0), null, `oeffentlich: ${state}`) + const portal = await kontextFuerPortal(feature({ state })) + assert.deepEqual(portal.einstellung, e, `Portal: ${state}`) + assert.equal(portal.fragebogen.branche, 'salon', `Portal: ${state}`) + await deleteDocument('portalFeatures', FEATURE) + } + + // Freischaltung: aus setup wird active, der Kontext folgt (hoechstens 30 Sekunden spaeter, so lange haelt der Speicher das null) + resetKontextForTests() + seedFeature({ state: 'setup' }) + assert.equal(await kontextPerSiteKey(SITE_KEY, T0), null) + await activate(FEATURE, { by: 'team' }) + const ctx = await kontextPerSiteKey(SITE_KEY, SEKUNDEN(31)) + assert.equal(ctx.feature.state, 'active') + assert.deepEqual(ctx.einstellung, e) + assert.equal(ctx.fragebogen.branche, 'salon') +}) + // ---------------------------------------------------------------- Betreffzeilen const FAELLE = [