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 = [