diff --git a/server/routes/termine.js b/server/routes/termine.js index 899ffa8..aba52d0 100644 --- a/server/routes/termine.js +++ b/server/routes/termine.js @@ -14,8 +14,8 @@ import { aktion, entsperre, manuell, sperre } from '../services/termine/ablaufBe import { EXPORT_SEITE, exportSeite, hole, imZeitraum, zaehleAnfragen, zaehleKuenftige } from '../services/termine/buchungen.js' import { fehlendePflichtangaben, neuerFeedToken, normalisiereEinstellung } from '../services/termine/einstellungen.js' import { loeschTag, loeschzeit } from '../services/termine/frist.js' -import { BRANCHEN, bestellpruefung, bestellungAnsicht, leerFragebogen } from '../services/termine/fragebogen.js' -import { kontextFuerPortal, kontextPerFeature, liveHost, speichereEinstellung } from '../services/termine/kontext.js' +import { BRANCHEN, bestellpruefung, bestellungAnsicht, leerFragebogen, normalisiereFragebogen } from '../services/termine/fragebogen.js' +import { kontextFuerPortal, kontextPerFeature, liveHost, speichereEinstellung, speichereFragebogen } from '../services/termine/kontext.js' import { zustellFehler } from '../services/termine/outbox.js' import { termineFuerKunde } from '../services/termine/schalter.js' import { belegungAus, freieZeiten } from '../services/termine/slots.js' @@ -31,7 +31,9 @@ import { ausUtc, datumKurz, plusTage, uhrzeit, zuUtc } from '../services/termine * Ausnahme Entwurf (TP2): GET /einstellungen liest in jedem Zustand (off, ordered, setup, active, disabled) und auch ohne * Funktionsdokument (virtueller Kontext `off`, nichts wird angelegt), siehe withTermine({ entwurf: true }). PATCH /einstellungen * schreibt den Entwurf nach eigenen Regeln (off und active ja; ordered und setup nur in der Kundenansicht; disabled nie; in off - * nur mit laufendem Wartungs-Abo), speichert ihn ohne Pflichtangaben für Gäste und meldet sie in `fehlt`. + * nur mit laufendem Wartungs-Abo), speichert ihn ohne Pflichtangaben für Gäste und meldet sie in `fehlt`. POST /einstellungen/vorlage + * legt den Entwurf aus einer Branche an (oder ersetzt ihn, solange nichts bestellt ist) und schreibt dabei den Fragebogen mit; + * PUT /fragebogen speichert nur den Fragebogen (nie die Version der Einstellung) und nie in der Kundenansicht. * Schreib-Routen zählen gegen 120 Änderungen pro Kunde und Stunde (im Speicher). Die Geschäftsregeln stehen in * services/termine/*; hier wird geprüft, übersetzt und protokolliert (Kundenansicht: termin_einstellungen, termin_aktion, * termin_manuell, nur nach Erfolg und ohne Gastdaten im Detail). @@ -72,7 +74,12 @@ const MELDUNG = { keineEinstellung: 'Bitte richte den Terminplaner zuerst ein.', zuViele: 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.', vorlage: 'Bitte wähle eine Vorlage aus der Liste.', + branche: 'Bitte wähle eine Branche aus der Liste.', + gesundheitsbereich: 'Für Betriebe im Gesundheitsbereich ist der Terminplaner nicht verfügbar. Wähle eine andere Branche, wenn dein Angebot nicht dazugehört.', + betrieb: 'Die Betriebsangaben sind ungültig.', vorlageVorhanden: 'Es gibt schon Einstellungen. Eine Vorlage kannst du nur beim ersten Öffnen wählen.', + nichtErsetzbar: 'Die Vorlage kannst du nur wechseln, solange du den Terminplaner noch nicht bestellt hast.', + kundenansichtBestaetigen: 'In der Kundenansicht kann nichts bestätigt werden.', version: 'Die Version der Einstellungen fehlt. Bitte lade die Seite neu.', art: 'Bitte wähle „Termin eintragen“ oder „Zeit sperren“.', zeitraum: 'Bitte gib von und bis als Datum an (JJJJ-MM-TT).', @@ -361,28 +368,128 @@ async function betriebVorschlag(customerId, project) { return { name, adresse, email, telefon, websiteUrl: liveWebsite(project) } } -router.post('/einstellungen/vorlage', requireSession, withTermine({ schreiben: true }), limitWrites, route(async (req, res) => { - const key = req.body?.vorlage - if (typeof key !== 'string' || !Object.hasOwn(VORLAGEN, key)) throw stop(400, MELDUNG.vorlage, undefined, 'vorlage') - if (req.ctx.einstellung) throw stop(409, MELDUNG.vorlageVorhanden, 'einstellung_vorhanden') +// Betriebsfelder, die der Kunde beim Anlegen der Vorlage mitschicken darf (Anschrift und Telefon u. a. aus dem Konto vorbelegt) +const BETRIEB_FELDER = ['name', 'adresse', 'telefon', 'email', 'websiteUrl'] - const betrieb = await betriebAusKonto(getSessionCustomerId(req)) +/** + * Die Betriebsfelder aus dem Rumpf der Vorlage-Route: nur die fünf bekannten, nur nichtleere Texte (ein leeres Feld lässt den + * Wert aus dem Konto stehen). Kein Objekt oder ein Feld, das kein Text ist => 400. Prüft nur die Form; Länge, Adresse und + * Mail-Format prüft die Einstellung selbst (ausVorlage). + */ +function betriebFelder(eingabe) { + if (eingabe === undefined || eingabe === null) return {} + if (!istObjekt(eingabe)) throw stop(400, MELDUNG.betrieb, undefined, 'betrieb') + const felder = {} + for (const feld of BETRIEB_FELDER) { + const wert = eingabe[feld] + if (wert === undefined || wert === null) continue + if (typeof wert !== 'string') throw stop(400, MELDUNG.betrieb, undefined, `betrieb.${feld}`) + if (wert.trim() !== '') felder[feld] = wert.trim() + } + return felder +} + +/** + * Entwurf aus einer Vorlage anlegen (TP2: aus einer Branche, auch als Ersatz für den bestehenden Entwurf). + * { projectId, branche } Branche laut BRANCHEN; unbekannt => 400; Gesundheitsbereich => 400 `gesundheitsbereich` (harter + * Ausschluss, nichts wird angelegt, auch kein Funktionsdokument); die Branche steht danach im Fragebogen + * { projectId, vorlage } wie in TP1: Vorlage nach Schlüssel, der Fragebogen bleibt unberührt + * betrieb { name, adresse, telefon, email, websiteUrl }: überschreibt nur mit nichtleeren Werten das Konto + * ersetzen: true + version eine bestehende Einstellung ersetzen: nur im Zustand `off` und nur mit der gelesenen Version (sonst 409 + * `EINSTELLUNG_VERALTET`); neue IDs und Kalender-Token, Fragebogen neu (Branche, keine bestätigten + * Schritte, Preise und Bundesland ungeprüft), die Einbauwünsche bleiben. Einstellung und Fragebogen + * werden in einem Vorgang unter der Sperre geschrieben (speichereEinstellung, Option fragebogen). + * Ohne `ersetzen` gilt wie in TP1: gibt es schon Einstellungen, ist es 409 `einstellung_vorhanden`. + */ +router.post('/einstellungen/vorlage', requireSession, withTermine({ schreiben: true, entwurf: true }), limitWrites, route(async (req, res) => { + const body = istObjekt(req.body) ? req.body : {} + let key + let brancheKey = null + if (body.branche !== undefined && body.branche !== null) { + const branche = typeof body.branche === 'string' ? BRANCHEN.find((b) => b.key === body.branche) : undefined + if (!branche) throw stop(400, MELDUNG.branche, undefined, 'branche') + if (branche.gesundheit) throw stop(400, MELDUNG.gesundheitsbereich, 'gesundheitsbereich', 'branche') + key = branche.vorlage + brancheKey = branche.key + } else { + key = body.vorlage + if (typeof key !== 'string' || !Object.hasOwn(VORLAGEN, key)) throw stop(400, MELDUNG.vorlage, undefined, 'vorlage') + } + const eigene = betriebFelder(body.betrieb) + + const ersetzen = body.ersetzen === true + if (req.ctx.einstellung && !ersetzen) throw stop(409, MELDUNG.vorlageVorhanden, 'einstellung_vorhanden') + if (ersetzen) { + if (!Number.isInteger(body.version) || body.version < 0) throw stop(400, MELDUNG.version, undefined, 'version') + if (req.ctx.feature.state !== 'off') throw stop(409, MELDUNG.nichtErsetzbar, 'nicht_ersetzbar') + } + + const customerId = getSessionCustomerId(req) + const betrieb = { ...(await betriebAusKonto(customerId)), ...eigene } const now = uhr() + const baue = () => { + try { + return ausVorlage(key, betrieb, now) + } catch (err) { + throw Object.assign(new Error(err.message), { status: 400, fehler: err.fehler ?? { _: err.message } }) + } + } + + // Ohne Funktionsdokument legt erst dieser Handler es an, nach der Eingabeprüfung (eine abgelehnte Vorlage hinterlässt nichts) + let feature = req.ctx.feature + if (!feature.$id) { + baue() + feature = await ensureFeatureDoc(customerId, req.projectId, 'termine') + } + + // Branche oder Ersetzen: der Fragebogen beginnt neu, nur die Einbauwünsche bleiben (aus dem Stand unter der Sperre) + const fragebogen = brancheKey !== null || ersetzen + ? (bisher) => ({ ...leerFragebogen(), branche: brancheKey ?? key, einbau: bisher.einbau }) + : undefined + let gespeichert try { - // version 0: nur wenn noch nichts gespeichert ist, auch bei zwei gleichzeitigen Anfragen - gespeichert = await speichereEinstellung(req.ctx.feature, () => { - try { - return ausVorlage(key, betrieb, now) - } catch (err) { - throw Object.assign(new Error(err.message), { status: 400, fehler: err.fehler ?? { _: err.message } }) - } - }, { version: 0, by: von(req), now }) + // ohne ersetzen version 0: nur wenn noch nichts gespeichert ist, auch bei zwei gleichzeitigen Anfragen + gespeichert = await speichereEinstellung(feature, baue, { version: ersetzen ? body.version : 0, by: von(req), now, fragebogen }) } catch (err) { - throw err?.code === 'EINSTELLUNG_VERALTET' ? stop(409, MELDUNG.vorlageVorhanden, 'einstellung_vorhanden') : err + throw err?.code === 'EINSTELLUNG_VERALTET' && !ersetzen ? stop(409, MELDUNG.vorlageVorhanden, 'einstellung_vorhanden') : err } - await logIfKundenansicht(req, 'termin_einstellungen', `Vorlage ${key}`) - res.status(201).json({ einstellung: fuerKunde(gespeichert.einstellung), version: gespeichert.version, fehlt: fehlendePflichtangaben(gespeichert.einstellung) }) + await logIfKundenansicht(req, 'termin_einstellungen', ersetzen ? `Vorlage ${key} ersetzt` : `Vorlage ${key}`) + res.status(201).json({ + einstellung: fuerKunde(gespeichert.einstellung), + version: gespeichert.version, + fehlt: fehlendePflichtangaben(gespeichert.einstellung), + fragebogen: gespeichert.fragebogen ?? req.ctx.fragebogen, + }) +})) + +/** Schritte bestätigt nur der Kunde selbst: jede Sitzung der Kundenansicht (viewAs) bekommt 403, vor jeder anderen Prüfung. */ +const nichtInKundenansicht = route(async (req, _res, next) => { + if (req.session?.viewAs) throw stop(403, MELDUNG.kundenansichtBestaetigen) + return next() +}) + +/** + * Fragebogen speichern (Branche, bestätigte Schritte, Einbauwünsche; PUT ersetzt den ganzen Fragebogen). Nie in der Kundenansicht: + * Schritte bestätigt nur der Kunde selbst (die Erlaubnisliste des Wächters kennt den Pfad nicht, hier gilt es noch einmal). + * Zustandsregeln wie beim Entwurf (off mit laufendem Wartungs-Abo und active ja, ordered, setup und disabled 409). Schreibt nur + * fragebogen, updatedAt und updatedBy: die Version der Einstellung bleibt, ein offener Einstellungs-Editor kann weiter speichern. + * Ohne Einstellung 409 `keine_einstellung`. Antwort { fragebogen, offen }; `offen` wie im GET nur in `off`. Kein Protokolleintrag. + */ +router.put('/fragebogen', requireSession, nichtInKundenansicht, withTermine({ schreiben: true, entwurf: true }), limitWrites, route(async (req, res) => { + const geprueft = normalisiereFragebogen(req.body?.fragebogen) + if (geprueft.fehler) throw Object.assign(new Error(Object.values(geprueft.fehler)[0]), { status: 400, fehler: geprueft.fehler }) + const { einstellung, feature } = mitEinstellung(req) + + let gespeichert + try { + gespeichert = await speichereFragebogen(feature, () => geprueft.fragebogen, { by: von(req), now: uhr() }) + } catch (err) { + throw err?.code === 'keine_einstellung' ? stop(409, MELDUNG.keineEinstellung, 'keine_einstellung') : err + } + const abo = feature.state === 'off' ? await aboFuerVorschau(getSessionCustomerId(req)) : null + const offen = feature.state === 'off' ? bestellpruefung({ einstellung, fragebogen: gespeichert.fragebogen, feature: featureConfig('termine'), abo }) : null + res.json({ fragebogen: gespeichert.fragebogen, offen }) })) /** diff --git a/test/kundenansichtGuard.test.js b/test/kundenansichtGuard.test.js index 79c5a4e..7929da4 100644 --- a/test/kundenansichtGuard.test.js +++ b/test/kundenansichtGuard.test.js @@ -146,22 +146,29 @@ test('Erlaubnisliste: genau News, Terminplaner, Analyse-Link, Ende, Logout und L }) // Spec 2026-10-03, Abschnitt 3: unbekannte Schreib-Pfade unter /api/termine sind in der Kundenansicht gesperrt, bevor der Router -// sie sieht (auch Pfade ohne Route und Gross-/Kleinschreibung); normale Kunden beruehrt der Waechter nicht +// sie sieht (auch Pfade ohne Route und Gross-/Kleinschreibung); normale Kunden beruehrt der Waechter nicht. +// PUT /fragebogen hat seit C5 eine echte Route: in der Kundenansicht sperrt sie der Waechter (403 view_blocked, der Router kommt nie +// dran), ein normaler Kunde mit eigenem Projekt bekommt die Antwort der Route (hier 409 "noch nicht verfuegbar": der Pilot-Schalter +// steht im Test auf off; weder 403 noch 404). Der 403 am Router selbst (ohne Waechter) steht in test/termineFragebogenRoutes.test.js. test('unbekanntes POST/PUT unter /api/termine in der Kundenansicht -> 403 view_blocked, normale Kunden nicht', async () => { const faelle = [ ['POST', '/api/termine/zukunft'], - ['PUT', '/api/termine/fragebogen'], + ['PUT', '/api/termine/fragebogen', { projectId: 'p1', fragebogen: {} }], ['POST', '/api/termine/buchungen/abc/aktion/x'], ['POST', '/API/Termine/Zukunft'], ] - for (const [method, p] of faelle) { - const gesperrt = await call(method, p, { ...kv(), body: {} }) + for (const [method, p, body = {}] of faelle) { + const gesperrt = await call(method, p, { ...kv(), body }) assert.equal(gesperrt.status, 403, `${method} ${p}`) assert.equal(gesperrt.body.code, 'view_blocked', `${method} ${p}`) - const kunde = await call(method, p, { cookie: customerCookie('c1'), body: {} }) + const kunde = await call(method, p, { cookie: customerCookie('c1'), body }) assert.notEqual(kunde.status, 403, `${method} ${p}: normaler Kunde`) - // PUT /fragebogen bekommt in Block C eine echte Route (dann antwortet sie selbst); die anderen Pfade gibt es nie - if (!p.endsWith('/fragebogen')) assert.equal(kunde.status, 404, `${method} ${p}: der Router kennt den Pfad nicht`) + if (p.endsWith('/fragebogen')) { + assert.equal(kunde.status, 409, `${method} ${p}: die Route antwortet selbst`) + assert.equal(kunde.body.code, 'nicht_verfuegbar', `${method} ${p}`) + } else { + assert.equal(kunde.status, 404, `${method} ${p}: der Router kennt den Pfad nicht`) + } } }) diff --git a/test/termineFragebogenRoutes.test.js b/test/termineFragebogenRoutes.test.js index f43b779..cb2cb86 100644 --- a/test/termineFragebogenRoutes.test.js +++ b/test/termineFragebogenRoutes.test.js @@ -12,7 +12,9 @@ import { TERMINE_DATETIMES } from '../server/services/termine/schema.js' // kundenansicht, abo, offen, bestellung, betriebVorschlag, einbau/feeds nur in active/disabled). Teil 1: nur Lesen. // Teil 1b (Task C4b): Schreibregeln des Entwurfs fuer PATCH /einstellungen (off und active ja, ordered und setup nur in der // Kundenansicht, disabled nie; in off nur mit laufendem Wartungs-Abo), PATCH mit `fehlt`, und die Gegenprobe an der -// oeffentlichen Schnittstelle (ein Entwurf ist nie oeffentlich, Einbauwuensche nie). Vorlage und PUT fragebogen folgen mit C5/C6. +// oeffentlichen Schnittstelle (ein Entwurf ist nie oeffentlich, Einbauwuensche nie). +// Teil 2 (Task C5): POST /einstellungen/vorlage mit Branche (Fragebogen mit, Gesundheitsbereich ausgeschlossen, Ersetzen im Entwurf) +// und PUT /fragebogen (speichert nur den Fragebogen, nie in der Kundenansicht), dazu das Schreibbudget. const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures'), @@ -33,7 +35,7 @@ useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) const { config } = await import('../server/config.js') const { startTestApp, send } = await import('./support/testApp.js') -const { customerCookie, kundenansichtCookie, seedFixtures, subscriptionDoc, wartungDoc, customers } = await import('./support/fixtures.js') +const { customerCookie, kundenansichtCookie, viewAsCookie, seedFixtures, subscriptionDoc, wartungDoc, customers } = await import('./support/fixtures.js') const { bestellbarerStand, entwurfDoc } = await import('./support/fragebogenStand.js') const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js') const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js') @@ -41,15 +43,18 @@ const { default: termineRoutes, setzeUhrFuerTests, resetTermineRoutesForTests } const { default: publicTermineRoutes, resetPublicTermineForTests } = await import('../server/routes/publicTermine.js') const { resetKontextForTests } = await import('../server/services/termine/kontext.js') const { resetTermineLimitsForTests } = await import('../server/services/termine/limits.js') -const { BRANCHEN, bestellpruefung, bestellungAnsicht, leerFragebogen } = await import('../server/services/termine/fragebogen.js') +const { BRANCHEN, SCHRITTE, bestellpruefung, bestellungAnsicht, leerFragebogen } = await import('../server/services/termine/fragebogen.js') const { aboMeldung } = await import('../server/services/featureOrder.js') +// Der Router allein, ohne Waechter der Kundenansicht (Teil 2: der Router prueft die Kundenansicht bei PUT /fragebogen selbst). +// Zuerst gestartet: send() spricht die zuletzt gestartete App an, das ist die mit Waechter. +const direkt = await startTestApp({ '/api/termine': termineRoutes }) const app = await startTestApp({ '/': kundenansichtGuard, '/api/public/termine/v1': publicTermineRoutes, '/api/termine': termineRoutes, }) -after(() => Promise.all([app.close(), aw.close(), st.close()])) +after(() => Promise.all([app.close(), direkt.close(), aw.close(), st.close()])) const T = config.features.termine const JETZT = new Date('2026-10-05T08:00:00.000Z') @@ -819,14 +824,13 @@ test('Abo: gelesen wird nur in off (und ohne Dokument); in active und in der Kun assert.equal(aw.listCalls('subscriptions').length, 1, 'in off genau eine Abfrage') }) -test('Schreibregeln: die anderen Schreib-Routen behalten die Schranke active (Vorlage und Feed-Link im Entwurf bleiben 409 "nicht aktiv")', async () => { +test('Schreibregeln: die übrigen Schreib-Routen behalten die Schranke active (der Feed-Link im Entwurf bleibt 409 "nicht aktiv"; Vorlage und PUT fragebogen: Teil 2)', async () => { for (const zustand of ['keins', 'off', 'ordered', 'setup']) { await deleteDocument('portalFeatures', FEATURE).catch(() => {}) await deleteDocument('termineConfig', FEATURE).catch(() => {}) setzeZustand(zustand) const vorher = dokumente() for (const [method, pfad, body] of [ - ['POST', '/api/termine/einstellungen/vorlage', { projectId: 'p1', vorlage: 'salon' }], ['POST', '/api/termine/ressourcen/r_aaaaaaa1/feed', { projectId: 'p1' }], ]) { const res = await send(method, pfad, { cookie, body }) @@ -920,3 +924,688 @@ test('Einbauwünsche nie öffentlich: fragebogen.einbau.stelle und knopfText ste assert.deepEqual((await oeffentlichConfig()).body, { aktiv: false }, zustand) } }) + +// ================================================================ Teil 2 (C5): Branche und Vorlage im Entwurf, PUT /fragebogen + +const VORLAGE_URL = '/api/termine/einstellungen/vorlage' +const FRAGEBOGEN_URL = '/api/termine/fragebogen' +const KUNDENANSICHT_TEXT = 'In der Kundenansicht kann nichts bestätigt werden.' +const GESUNDHEIT_TEXT = 'Für Betriebe im Gesundheitsbereich ist der Terminplaner nicht verfügbar. Wähle eine andere Branche, wenn dein Angebot nicht dazugehört.' + +const vorlage = (body, auth = { cookie }) => send('POST', VORLAGE_URL, { ...auth, body: { projectId: 'p1', ...body } }) +const putFragebogen = (fragebogen, auth = { cookie }, extra = {}) => send('PUT', FRAGEBOGEN_URL, { ...auth, body: { projectId: 'p1', fragebogen, ...extra } }) +/** Anfrage an den Router ohne Waechter der Kundenansicht. */ +async function sendDirekt(method, pfad, { body, cookie: c } = {}) { + const res = await fetch(direkt.url + pfad, { + method, + headers: { 'content-type': 'application/json', ...(c ? { cookie: c } : {}) }, + body: JSON.stringify(body), + }) + const text = await res.text() + return { status: res.status, body: text ? JSON.parse(text) : null } +} +const gespeicherteEinstellung = () => JSON.parse(termineConfigDoc().einstellung) +const gespeicherterFragebogen = () => JSON.parse(termineConfigDoc().fragebogen) +const ids = (e) => [...e.ressourcen.map((r) => r.id), ...e.leistungen.map((l) => l.id)] +const feedTokens = (e) => e.ressourcen.map((r) => r.feedToken) +const EINBAU = { modus: 'knopf', knopfText: 'Termin buchen', stelle: 'Unter den Öffnungszeiten', gestaltung: 'Dunkel mit goldener Schrift', bemerkung: 'Bitte vor Freitag' } +/** Bestellbarer Stand mit Einbauwünschen und Zustand des Funktionsdokuments (Version 1). */ +const standMitEinbau = (zustand = 'off') => { + const stand = bestellbarerStand({ now: JETZT }) + stand.fragebogen.einbau = { ...EINBAU } + setzeZustand(zustand, { stand }) + return stand +} + +// ---------------------------------------------------------------- Vorlage mit Branche: Entwurf anlegen + +test('Vorlage mit Branche salon ohne Dokument: 201, Funktionsdokument off mit dauerhaftem siteKey, termineConfig mit Fragebogen-Branche', async () => { + assert.equal(aw.docs('portalFeatures').size, 0) + const res = await vorlage({ branche: 'salon' }) + assert.equal(res.status, 201, JSON.stringify(res.body)) + assert.deepEqual(Object.keys(res.body).sort(), ['einstellung', 'fehlt', 'fragebogen', 'version']) + assert.equal(res.body.version, 1) + assert.deepEqual(res.body.fehlt, []) + assert.equal(res.body.einstellung.vorlage, 'salon') + assert.ok(res.body.einstellung.leistungen.length >= 2) + assert.ok(res.body.einstellung.ressourcen.every((r) => !('feedToken' in r)), 'der Browser bekommt nie ein Token') + assert.deepEqual(res.body.fragebogen, { ...leerFragebogen(), branche: 'salon' }) + + const feature = aw.docs('portalFeatures').get(FEATURE) + assert.equal(feature.state, 'off') + assert.equal(feature.enabled, false) + assert.equal(feature.unlockedByPurchase, false) + assert.match(feature.siteKey, /^[a-z2-7]{16}$/) + assert.equal(termineConfigDoc().version, 1) + assert.equal(termineConfigDoc().updatedBy, 'kunde:c1') + assert.equal(termineConfigDoc().customerId, 'c1') + assert.equal(termineConfigDoc().projectId, 'p1') + assert.equal(gespeicherterFragebogen().branche, 'salon') + assert.deepEqual(gespeicherterFragebogen(), res.body.fragebogen) + assert.ok(gespeicherteEinstellung().ressourcen.every((r) => r.feedToken.length === 43), 'gespeichert hat jede Person ein Token') + assert.equal(gespeicherteEinstellung().betrieb.name, 'Salon Müller', 'Betrieb aus dem Kundenkonto') + assert.equal(logEintraege().length, 0, 'kein Eintrag für den Kunden selbst') + + // GET zeigt denselben Stand, der Entwurf ist öffentlich unsichtbar + const gelesen = (await send('GET', einstellungenUrl(), { cookie })).body + assert.equal(gelesen.state, 'off') + assert.equal(gelesen.fragebogen.branche, 'salon') + assert.equal(gelesen.version, 1) + assert.deepEqual((await oeffentlichConfig(feature.siteKey)).body, { aktiv: false }) + const siteKey = feature.siteKey + assert.equal((await vorlage({ branche: 'salon' })).status, 409) + assert.equal(aw.docs('portalFeatures').get(FEATURE).siteKey, siteKey, 'der siteKey bleibt') +}) + +test('Vorlage mit Branche: jede Branche außer dem Gesundheitsbereich legt ihre Vorlage an, der Fragebogen nennt sie', async () => { + const mitVorlage = BRANCHEN.filter((b) => !b.gesundheit) + assert.ok(mitVorlage.length >= 9) + for (const branche of mitVorlage) { + await deleteDocument('portalFeatures', FEATURE).catch(() => {}) + await deleteDocument('termineConfig', FEATURE).catch(() => {}) + const res = await vorlage({ branche: branche.key }) + assert.equal(res.status, 201, `${branche.key}: ${JSON.stringify(res.body)}`) + assert.equal(res.body.einstellung.vorlage, branche.vorlage, branche.key) + assert.equal(res.body.fragebogen.branche, branche.key) + assert.equal(gespeicherterFragebogen().branche, branche.key) + } +}) + +test('Vorlage: Betriebsfelder aus dem Rumpf überschreiben das Konto, leere Felder nicht, unbekannte und fremde Schlüssel werden ignoriert', async () => { + const res = await vorlage({ + branche: 'salon', + betrieb: { name: ' Haarstudio Neu ', email: '', telefon: ' ', adresse: '', websiteUrl: 'https://haarstudio.example.invalid', impressumUrl: 'https://boese.example.invalid/impressum' }, + }) + assert.equal(res.status, 201, JSON.stringify(res.body)) + const betrieb = res.body.einstellung.betrieb + assert.equal(betrieb.name, 'Haarstudio Neu', 'ein nichtleerer Wert überschreibt das Konto') + assert.equal(betrieb.websiteUrl, 'https://haarstudio.example.invalid/', 'die Adresse wird wie bei jeder Einstellung normalisiert') + assert.equal(betrieb.email, 'k@example.invalid', 'ein leeres Feld lässt den Wert aus dem Konto') + assert.equal(betrieb.telefon, '06301 1234') + assert.equal(betrieb.adresse, 'Hauptstr. 1, 67722 Winnweiler') + assert.notEqual(betrieb.impressumUrl, 'https://boese.example.invalid/impressum', 'nur die fünf Betriebsfelder gelten') + assert.deepEqual(res.body.fehlt, []) +}) + +test('Vorlage: Betriebsfelder ohne Konto-Angaben werden aus dem Rumpf gefüllt und "fehlt" schrumpft', async () => { + await deleteDocument('customers', 'c1') + aw.seed('customers', customers.c1) // Konto ohne Firma, Anschrift und Telefon + const leer = await vorlage({ branche: 'salon' }) + assert.equal(leer.status, 201, JSON.stringify(leer.body)) + assert.deepEqual(leer.body.fehlt, FEHLT_BEIDES) + await deleteDocument('termineConfig', FEATURE) + const voll = await vorlage({ branche: 'salon', betrieb: { telefon: '0151 2345678', adresse: 'Weg 1, 12345 Ort' } }) + assert.equal(voll.status, 201, JSON.stringify(voll.body)) + assert.deepEqual(voll.body.fehlt, []) + assert.equal(voll.body.einstellung.betrieb.telefon, '0151 2345678') +}) + +test('Vorlage: Betriebsangaben in falscher Form => 400, nichts wird angelegt (auch kein Funktionsdokument)', async () => { + for (const betrieb of ['Salon', 5, ['a'], { name: 5 }, { telefon: {} }, { email: ['x'] }, { websiteUrl: true }]) { + const res = await vorlage({ branche: 'salon', betrieb }) + assert.equal(res.status, 400, JSON.stringify(betrieb)) + assert.ok(res.body.fehler?.betrieb || Object.keys(res.body.fehler ?? {}).some((k) => k.startsWith('betrieb.')), JSON.stringify(betrieb)) + } + // eine Angabe, die die Einstellung ablehnt (ungültige Adresse): 400, ohne Dokument entsteht nichts + const ungueltig = await vorlage({ branche: 'salon', betrieb: { websiteUrl: 'javascript:alert(1)' } }) + assert.equal(ungueltig.status, 400, JSON.stringify(ungueltig.body)) + assert.equal(aw.docs('portalFeatures').size, 0) + assert.equal(aw.docs('termineConfig').size, 0) +}) + +test('Vorlage ohne Branche (nur vorlage) bleibt wie in TP1: Einstellung aus der Vorlage, der gespeicherte Fragebogen bleibt unberührt', async () => { + setzeZustand('active', { stand: null }) + const res = await vorlage({ vorlage: 'kosmetik' }) + assert.equal(res.status, 201, JSON.stringify(res.body)) + assert.equal(res.body.einstellung.vorlage, 'kosmetik') + assert.deepEqual(res.body.fragebogen, leerFragebogen()) + assert.equal(termineConfigDoc().fragebogen, undefined, 'ohne Branche wird kein Fragebogen geschrieben') + // und die Antwort auf eine Branche gewinnt gegenüber einer mitgeschickten vorlage + await deleteDocument('termineConfig', FEATURE) + const beides = await vorlage({ branche: 'tattoo', vorlage: 'salon' }) + assert.equal(beides.status, 201) + assert.equal(beides.body.einstellung.vorlage, 'tattoo') + assert.equal(beides.body.fragebogen.branche, 'tattoo') +}) + +test('Vorlage: unbekannte Branche (auch Prototyp-Schlüssel, falsche Typen) => 400 mit Feld branche, nichts angelegt', async () => { + for (const branche of ['gibtsnicht', '__proto__', 'constructor', 'toString', 'hasOwnProperty', '', 5, true, {}, ['salon']]) { + const res = await vorlage({ branche }) + assert.equal(res.status, 400, JSON.stringify(branche)) + assert.equal(res.body.fehler.branche, 'Bitte wähle eine Branche aus der Liste.') + assert.equal(res.body.code, undefined) + } + assert.equal(aw.docs('portalFeatures').size, 0) + assert.equal(aw.docs('termineConfig').size, 0) +}) + +// ---------------------------------------------------------------- Gesundheitsbereich: harter Ausschluss + +test('Vorlage mit Branche gesundheit => 400 gesundheitsbereich; ohne vorhandenes Dokument wird weder portalFeatures noch termineConfig angelegt', async () => { + const vorher = dokumente() + for (const auth of [{ cookie }, kv()]) { + const res = await vorlage({ branche: 'gesundheit' }, auth) + assert.equal(res.status, 400, JSON.stringify(res.body)) + assert.equal(res.body.code, 'gesundheitsbereich') + assert.equal(res.body.error, GESUNDHEIT_TEXT) + assert.equal(res.body.fehler.branche, GESUNDHEIT_TEXT) + } + assert.equal(dokumente(), vorher) + assert.equal(aw.docs('portalFeatures').size, 0) + assert.equal(aw.docs('termineConfig').size, 0) + assert.equal(aw.docs('customerViewLog').size, 0, 'abgelehnt heißt nichts protokolliert') +}) + +test('Vorlage mit Branche gesundheit bei vorhandenem Entwurf (auch mit ersetzen): 400, der Entwurf und sein Fragebogen bleiben unverändert', async () => { + standMitEinbau('off') + const vorher = dokumente() + for (const extra of [{}, { ersetzen: true, version: 1 }]) { + const res = await vorlage({ branche: 'gesundheit', ...extra }) + assert.equal(res.status, 400, JSON.stringify(extra)) + assert.equal(res.body.code, 'gesundheitsbereich') + } + assert.equal(dokumente(), vorher) + assert.equal(gespeicherterFragebogen().branche, 'salon') +}) + +// ---------------------------------------------------------------- Zweimal / Zustandsmatrix + +test('Vorlage zweimal => 409 einstellung_vorhanden, nichts überschrieben (Einstellung und Fragebogen); zwei gleichzeitige Anfragen: genau eine gewinnt', async () => { + assert.equal((await vorlage({ branche: 'salon' })).status, 201) + const einstellungVorher = termineConfigDoc().einstellung + const zweite = await vorlage({ branche: 'tattoo' }) + assert.equal(zweite.status, 409) + assert.equal(zweite.body.code, 'einstellung_vorhanden') + assert.equal(zweite.body.error, 'Es gibt schon Einstellungen. Eine Vorlage kannst du nur beim ersten Öffnen wählen.') + assert.equal(termineConfigDoc().einstellung, einstellungVorher) + assert.equal(gespeicherterFragebogen().branche, 'salon') + assert.equal(termineConfigDoc().version, 1) + + await deleteDocument('termineConfig', FEATURE) + const [a, b] = await Promise.all(['salon', 'kosmetik'].map((branche) => vorlage({ branche }))) + assert.deepEqual([a.status, b.status].sort(), [201, 409]) + const gewinner = a.status === 201 ? a : b + assert.equal(gespeicherterFragebogen().branche, gewinner.body.fragebogen.branche, 'Einstellung und Fragebogen kommen vom selben Gewinner') + assert.equal(gespeicherteEinstellung().vorlage, gewinner.body.einstellung.vorlage) +}) + +for (const zustand of ZUSTAENDE) { + for (const [zeile, auth] of ZEILEN) { + const kundenansicht = zeile === 'Kundenansicht' + const gesperrt = (zustand === 'ordered' || zustand === 'setup') && !kundenansicht + const abgeschaltet = zustand === 'disabled' + test(`Vorlage mit Branche: Zustand ${zustand}, ${zeile} => ${abgeschaltet ? '409 abgeschaltet' : gesperrt ? '409 gesperrt' : '201'}`, async () => { + setzeZustand(zustand, { stand: null }) + const vorher = dokumente() + const res = await vorlage({ branche: 'salon' }, auth()) + if (abgeschaltet || gesperrt) { + assert.equal(res.status, 409, JSON.stringify(res.body)) + assert.equal(res.body.code, abgeschaltet ? 'abgeschaltet' : 'gesperrt') + if (gesperrt) assert.equal(res.body.error, GESPERRT) + assert.equal(dokumente(), vorher, 'nichts angelegt, nichts protokolliert') + return + } + assert.equal(res.status, 201, JSON.stringify(res.body)) + assert.equal(gespeicherterFragebogen().branche, 'salon') + assert.equal(termineConfigDoc().updatedBy, kundenansicht ? 'team:u_staff2' : 'kunde:c1') + const feature = aw.docs('portalFeatures').get(FEATURE) + assert.equal(feature.state, zustand === 'keins' ? 'off' : zustand, 'der Zustand des Funktionsdokuments ändert sich nie') + assert.deepEqual(logEintraege().map((e) => [e.action, e.detail]), kundenansicht ? [['termin_einstellungen', 'Vorlage salon']] : []) + }) + } +} + +test('Vorlage mit Branche: ohne laufendes Wartungs-Abo 409 kein_abo und nichts angelegt; Abo nicht lesbar => 503, kein Rohtext', async () => { + await deleteDocument('subscriptions', 'sub_h') + const vorher = dokumente() + const ohne = await vorlage({ branche: 'salon' }) + assert.equal(ohne.status, 409) + assert.equal(ohne.body.code, 'kein_abo') + assert.equal(ohne.body.error, aboMeldung(T, 'kein_abo')) + assert.equal(dokumente(), vorher) + + wartung() + aw.failNext({ collection: 'subscriptions', status: 500, message: 'geheimer Appwrite-Text' }) + const fehler = await vorlage({ branche: 'salon' }) + assert.equal(fehler.status, 503) + assert.equal(fehler.body.code, 'abo_pruefung') + assert.ok(!JSON.stringify(fehler.body).includes('geheimer Appwrite-Text')) + assert.equal(dokumente(), vorher) + assert.equal((await vorlage({ branche: 'salon' })).status, 201, 'der nächste Versuch geht') +}) + +test('Vorlage: fremdes Projekt (Kunde c2) => 403, ohne Sitzung 401, ohne projectId 400, Pilot-Schalter 409; nichts angelegt', async () => { + const vorher = dokumente() + const fremd = await vorlage({ branche: 'salon' }, { cookie: customerCookie('c2') }) + assert.equal(fremd.status, 403) + assert.equal(fremd.body.error, 'Kein Zugriff auf dieses Projekt.') + assert.equal((await send('POST', VORLAGE_URL, { body: { projectId: 'p1', branche: 'salon' } })).status, 401) + assert.equal((await send('POST', VORLAGE_URL, { cookie, body: { branche: 'salon' } })).status, 400) + config.termine.pilotEmails = ['andere@example.invalid'] + const gesperrt = await vorlage({ branche: 'salon' }) + assert.equal(gesperrt.status, 409) + assert.equal(gesperrt.body.code, 'nicht_verfuegbar') + assert.equal(dokumente(), vorher) +}) + +// ---------------------------------------------------------------- Vorlage ersetzen (nur Entwurf in off) + +test('Vorlage ersetzen in off: neue Leistungen, neue IDs und Feed-Token, Fragebogen neu mit der Branche, bestaetigt leer, einbau bleibt', async () => { + const stand = standMitEinbau('off') + const alt = gespeicherteEinstellung() + assert.deepEqual(gespeicherterFragebogen().bestaetigt, [...SCHRITTE]) + assert.equal(gespeicherterFragebogen().preiseGeprueft, true) + + const res = await vorlage({ branche: 'tattoo', ersetzen: true, version: 1 }) + assert.equal(res.status, 201, JSON.stringify(res.body)) + assert.equal(res.body.version, 2) + assert.equal(res.body.einstellung.vorlage, 'tattoo') + assert.deepEqual(res.body.einstellung.leistungen.map((l) => l.name), ['Beratungsgespräch', 'Kleines Tattoo', 'Tattoo-Session']) + assert.ok(res.body.einstellung.ressourcen.every((r) => !('feedToken' in r))) + assert.deepEqual(res.body.fragebogen, { ...leerFragebogen(), branche: 'tattoo', einbau: stand.fragebogen.einbau }) + assert.deepEqual(res.body.fragebogen.bestaetigt, []) + assert.equal(res.body.fragebogen.preiseGeprueft, false) + assert.equal(res.body.fragebogen.bundeslandGewaehlt, false) + assert.deepEqual(res.body.fragebogen.einbau, EINBAU, 'die Einbauwünsche bleiben') + + const neu = gespeicherteEinstellung() + assert.equal(termineConfigDoc().version, 2) + assert.deepEqual(gespeicherterFragebogen(), res.body.fragebogen, 'gespeichert, was die Antwort nennt') + assert.ok(!ids(neu).some((id) => ids(alt).includes(id)), 'neue IDs für Personen und Leistungen') + assert.ok(!feedTokens(neu).some((t) => feedTokens(alt).includes(t)), 'neue Kalender-Token') + assert.ok(feedTokens(neu).every((t) => t.length === 43)) + assert.equal(termineConfigDoc().updatedBy, 'kunde:c1') + assert.equal(aw.docs('portalFeatures').get(FEATURE).state, 'off') + assert.deepEqual(res.body.fehlt, []) +}) + +test('Vorlage ersetzen: Einstellung und Fragebogen werden in einem Schreibvorgang gespeichert (genau ein Schreibzugriff auf termineConfig)', async () => { + standMitEinbau('off') + // der zweite passende Schreibzugriff scheitert: gäbe es einen zweiten, wäre die Antwort kein 201 + aw.failNext({ collection: 'termineConfig', method: 'PATCH', status: 500, after: 1 }) + const res = await vorlage({ branche: 'kosmetik', ersetzen: true, version: 1 }) + assert.equal(res.status, 201, JSON.stringify(res.body)) + assert.equal(gespeicherterFragebogen().branche, 'kosmetik') + assert.equal(gespeicherteEinstellung().vorlage, 'kosmetik') + // den eingeplanten Fehler verbrauchen: der nächste Schreibzugriff scheitert (500 ohne Rohtext), der Stand bleibt + const stand = termineConfigDoc() + const verbraucht = await putFragebogen({ ...res.body.fragebogen, bestaetigt: ['branche'] }) + assert.equal(verbraucht.status, 500) + assert.deepEqual(termineConfigDoc(), stand) +}) + +test('Vorlage ersetzen mit vorlage statt branche: der Fragebogen nennt den Schlüssel der Vorlage als Branche; betrieb überschreibt', async () => { + standMitEinbau('off') + const res = await vorlage({ vorlage: 'fotograf', ersetzen: true, version: 1, betrieb: { name: 'Foto Neu' } }) + assert.equal(res.status, 201, JSON.stringify(res.body)) + assert.equal(res.body.fragebogen.branche, 'fotograf') + assert.equal(res.body.einstellung.betrieb.name, 'Foto Neu') + assert.deepEqual(res.body.fragebogen.einbau, EINBAU) +}) + +test('Vorlage ersetzen ohne gültige version => 400, mit falscher version => 409 EINSTELLUNG_VERALTET; nichts geändert', async () => { + standMitEinbau('off') + const vorher = dokumente() + for (const version of [undefined, null, '1', 1.5, -1, true, {}]) { + const res = await vorlage({ branche: 'tattoo', ersetzen: true, ...(version === undefined ? {} : { version }) }) + assert.equal(res.status, 400, String(version)) + assert.ok(res.body.fehler.version, String(version)) + } + for (const version of [0, 2, 9]) { + const res = await vorlage({ branche: 'tattoo', ersetzen: true, version }) + assert.equal(res.status, 409, String(version)) + assert.equal(res.body.code, 'EINSTELLUNG_VERALTET') + } + assert.equal(dokumente(), vorher) +}) + +test('Vorlage mit Einstellung ohne ersetzen (auch mit version) => 409 einstellung_vorhanden; ersetzen nur mit true', async () => { + standMitEinbau('off') + const vorher = dokumente() + for (const extra of [{}, { version: 1 }, { ersetzen: false, version: 1 }, { ersetzen: 'true', version: 1 }, { ersetzen: 1, version: 1 }]) { + const res = await vorlage({ branche: 'tattoo', ...extra }) + assert.equal(res.status, 409, JSON.stringify(extra)) + assert.equal(res.body.code, 'einstellung_vorhanden', JSON.stringify(extra)) + } + assert.equal(dokumente(), vorher) +}) + +for (const zustand of ['active', 'ordered', 'setup']) { + test(`Vorlage ersetzen in ${zustand} => 409 nicht_ersetzbar (auch mit richtiger version), Dokumente unverändert`, async () => { + standMitEinbau(zustand) + const vorher = dokumente() + // ordered und setup schreibt nur die Kundenansicht; dort gilt dieselbe Regel + for (const auth of zustand === 'active' ? [{ cookie }, kv()] : [kv()]) { + const res = await vorlage({ branche: 'tattoo', ersetzen: true, version: 1 }, auth) + assert.equal(res.status, 409, JSON.stringify(res.body)) + assert.equal(res.body.code, 'nicht_ersetzbar') + assert.equal(res.body.error, 'Die Vorlage kannst du nur wechseln, solange du den Terminplaner noch nicht bestellt hast.') + } + assert.equal(dokumente(), vorher) + }) +} + +test('Vorlage ersetzen in disabled => 409 abgeschaltet, Dokumente unverändert', async () => { + standMitEinbau('disabled') + const vorher = dokumente() + const res = await vorlage({ branche: 'tattoo', ersetzen: true, version: 1 }) + assert.equal(res.status, 409) + assert.equal(res.body.code, 'abgeschaltet') + assert.equal(dokumente(), vorher) +}) + +test('Vorlage ersetzen in der Kundenansicht (off): erlaubt, protokolliert ohne Betriebsdaten, updatedBy ist der Mitarbeiter', async () => { + standMitEinbau('off') + const res = await vorlage({ branche: 'tattoo', ersetzen: true, version: 1, betrieb: { name: 'Geheimer Betrieb' } }, kv()) + assert.equal(res.status, 201, JSON.stringify(res.body)) + assert.equal(termineConfigDoc().updatedBy, 'team:u_staff2') + assert.deepEqual(logEintraege().map((e) => [e.action, e.detail]), [['termin_einstellungen', 'Vorlage tattoo ersetzt']]) + assert.ok(!JSON.stringify(logEintraege()).includes('Geheimer Betrieb')) +}) + +test('Vorlage ersetzen bei unlesbarer Einstellung (off): mit der Version des Dokuments geht es, ohne ersetzen bleibt es 409', async () => { + aw.seed('portalFeatures', featureDoc('off')) + aw.seed('termineConfig', { $id: FEATURE, customerId: 'c1', projectId: 'p1', einstellung: '{kaputt', version: 3 }) + const ohne = await vorlage({ branche: 'salon' }) + assert.equal(ohne.status, 409) + assert.equal(ohne.body.code, 'einstellung_vorhanden') + assert.equal(termineConfigDoc().einstellung, '{kaputt') + const mit = await vorlage({ branche: 'salon', ersetzen: true, version: 3 }) + assert.equal(mit.status, 201, JSON.stringify(mit.body)) + assert.equal(mit.body.version, 4) + assert.equal(gespeicherteEinstellung().vorlage, 'salon') + assert.equal(gespeicherterFragebogen().branche, 'salon') +}) + +// ---------------------------------------------------------------- PUT /fragebogen + +const beispielFragebogen = (over = {}) => ({ + ...leerFragebogen(), + branche: 'salon', + bestaetigt: ['branche', 'wer'], + bundeslandGewaehlt: true, + einbau: { modus: 'inline', knopfText: '', stelle: 'Auf der Startseite', gestaltung: '', bemerkung: '' }, + ...over, +}) + +test('PUT fragebogen speichert nur den Fragebogen: version und einstellung der Einstellung bleiben, updatedAt/updatedBy ändern sich, Antwort { fragebogen, offen }', async () => { + setzeZustand('off') + const vorher = termineConfigDoc() + const res = await putFragebogen(beispielFragebogen()) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.deepEqual(Object.keys(res.body).sort(), ['fragebogen', 'offen']) + assert.deepEqual(res.body.fragebogen, beispielFragebogen()) + assert.deepEqual(gespeicherterFragebogen(), beispielFragebogen()) + const danach = termineConfigDoc() + assert.equal(danach.version, vorher.version, 'die Version der Einstellung bleibt') + assert.equal(danach.einstellung, vorher.einstellung, 'die Einstellung bleibt byte-gleich') + assert.equal(danach.updatedBy, 'kunde:c1') + assert.equal(new Date(danach.updatedAt).toISOString(), JETZT.toISOString(), 'updatedAt ist die Uhrzeit der Anfrage') + assert.equal(aw.docs('portalFeatures').get(FEATURE).state, 'off') + assert.equal(logEintraege().length, 0, 'kein Eintrag im Protokoll der Kundenansicht') + // GET liest den neuen Stand + assert.deepEqual((await send('GET', einstellungenUrl(), { cookie })).body.fragebogen, beispielFragebogen()) +}) + +test('PUT fragebogen: bereinigt die Eingabe wie normalisiereFragebogen (Reihenfolge, Doppelte, Fremdes, Steuerzeichen) und ersetzt den ganzen Fragebogen', async () => { + setzeZustand('off') + const res = await putFragebogen({ + branche: 'salon', + bestaetigt: ['website', 'branche', 'branche', 'unbekannt'], + einbau: { modus: 'knopf', knopfText: 'Jetzt\u0000 buchen', stelle: 'x', ignoriert: 'y' }, + boese: true, + }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.deepEqual(res.body.fragebogen.bestaetigt, ['branche', 'website']) + assert.equal(res.body.fragebogen.einbau.knopfText, 'Jetzt buchen') + assert.ok(!('boese' in res.body.fragebogen) && !('ignoriert' in res.body.fragebogen.einbau)) + assert.equal(res.body.fragebogen.preiseGeprueft, false, 'was fehlt, ist leer (PUT ersetzt, ergänzt nicht)') + assert.deepEqual(gespeicherterFragebogen(), res.body.fragebogen) +}) + +test('PUT fragebogen: ein offener Editor mit der alten version kann danach weiter per PATCH speichern (kein 409), auch zwei Tabs', async () => { + const stand = setzeZustand('off') + const geladen = (await send('GET', einstellungenUrl(), { cookie })).body // Tab A und Tab B haben version 1 + assert.equal(geladen.version, 1) + for (let i = 0; i < 3; i += 1) assert.equal((await putFragebogen(beispielFragebogen({ bestaetigt: SCHRITTE.slice(0, i + 1) }))).status, 200) + assert.equal(termineConfigDoc().version, 1) + const tabA = await patch({ version: geladen.version, einstellung: neueEinstellung(stand) }) + assert.equal(tabA.status, 200, JSON.stringify(tabA.body)) + assert.equal(tabA.body.version, 2) + assert.equal((await putFragebogen(beispielFragebogen({ bestaetigt: [...SCHRITTE] }))).status, 200, 'Tab B bestätigt weiter') + const tabB = await patch({ version: 2, einstellung: neueEinstellung(stand, (e) => ({ ...e, betrieb: { ...e.betrieb, name: 'Salon B' } })) }) + assert.equal(tabB.status, 200, JSON.stringify(tabB.body)) + assert.equal(gespeicherteEinstellung().betrieb.name, 'Salon B') + assert.deepEqual(gespeicherterFragebogen().bestaetigt, [...SCHRITTE], 'der PATCH hat den Fragebogen nicht angefasst') +}) + +test('PUT fragebogen mit ungültigem Modus => 400 mit fehler[einbau.modus]; ungültige Formen => 400; gespeichert wird nichts', async () => { + setzeZustand('off') + const vorher = dokumente() + const modus = await putFragebogen(beispielFragebogen({ einbau: { modus: 'popup' } })) + assert.equal(modus.status, 400, JSON.stringify(modus.body)) + assert.equal(modus.body.fehler['einbau.modus'], 'Bitte wähle, ob der Terminplaner im Text oder als Knopf erscheint.') + assert.equal(modus.body.error, modus.body.fehler['einbau.modus']) + for (const eingabe of [undefined, null, 'x', 5, [], { branche: 'gibtsnicht' }, { bestaetigt: 'branche' }, { preiseGeprueft: 'ja' }]) { + const res = await send('PUT', FRAGEBOGEN_URL, { cookie, body: { projectId: 'p1', ...(eingabe === undefined ? {} : { fragebogen: eingabe }) } }) + assert.equal(res.status, 400, JSON.stringify(eingabe)) + assert.ok(res.body.fehler && Object.keys(res.body.fehler).length > 0, JSON.stringify(eingabe)) + } + assert.equal(dokumente(), vorher) +}) + +test('PUT fragebogen ohne Einstellung => 409 keine_einstellung, nichts angelegt (kein Funktionsdokument, kein termineConfig)', async () => { + // kein Funktionsdokument + const vorher = dokumente() + const ohneDokument = await putFragebogen(beispielFragebogen()) + assert.equal(ohneDokument.status, 409) + assert.equal(ohneDokument.body.code, 'keine_einstellung') + assert.equal(dokumente(), vorher) + assert.equal(aw.docs('portalFeatures').size, 0) + // Funktionsdokument ohne termineConfig + setzeZustand('off', { stand: null }) + const ohneKonfig = await putFragebogen(beispielFragebogen()) + assert.equal(ohneKonfig.status, 409) + assert.equal(ohneKonfig.body.code, 'keine_einstellung') + assert.equal(aw.docs('termineConfig').size, 0) +}) + +for (const zustand of ZUSTAENDE) { + const erwartet = { keins: '409 keine_einstellung', off: '200', ordered: '409 gesperrt', setup: '409 gesperrt', active: '200', disabled: '409 abgeschaltet' }[zustand] + test(`PUT fragebogen: Zustand ${zustand} => ${erwartet}`, async () => { + setzeZustand(zustand) + const vorher = dokumente() + const res = await putFragebogen(beispielFragebogen()) + if (erwartet === '200') { + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.deepEqual(gespeicherterFragebogen(), beispielFragebogen()) + assert.equal(aw.docs('portalFeatures').get(FEATURE).state, zustand) + return + } + assert.equal(res.status, 409, JSON.stringify(res.body)) + assert.equal(res.body.code, erwartet.split(' ')[1]) + if (zustand === 'ordered' || zustand === 'setup') assert.equal(res.body.error, GESPERRT) + if (zustand === 'disabled') assert.match(res.body.error, /Wartungs-Abo beendet/) + assert.equal(dokumente(), vorher, 'nichts geändert') + }) +} + +test('PUT fragebogen: in off nur mit laufendem Wartungs-Abo (409 kein_abo, 503 bei Lesefehler); in active ohne Abo-Abfrage', async () => { + setzeZustand('off') + await deleteDocument('subscriptions', 'sub_h') + const vorher = dokumente() + const ohne = await putFragebogen(beispielFragebogen()) + assert.equal(ohne.status, 409) + assert.equal(ohne.body.code, 'kein_abo') + assert.equal(ohne.body.error, aboMeldung(T, 'kein_abo')) + assert.equal(dokumente(), vorher) + + wartung() + aw.failNext({ collection: 'subscriptions', status: 500, message: 'geheimer Appwrite-Text' }) + const fehler = await putFragebogen(beispielFragebogen()) + assert.equal(fehler.status, 503) + assert.equal(fehler.body.code, 'abo_pruefung') + assert.ok(!JSON.stringify(fehler.body).includes('geheimer Appwrite-Text')) + assert.equal(dokumente(), vorher) + + await deleteDocument('subscriptions', 'sub_h') + await deleteDocument('portalFeatures', FEATURE) + await deleteDocument('termineConfig', FEATURE) + setzeZustand('active') + aw.resetListCalls() + assert.equal((await putFragebogen(beispielFragebogen())).status, 200, 'active läuft auch ohne Abo weiter (Abgleich: Block E)') + assert.deepEqual(aw.listCalls('subscriptions'), []) +}) + +test('PUT fragebogen: offen ist die Bestellprüfung des gespeicherten Stands (nur in off), null in active', async () => { + const stand = setzeZustand('off') + const unvollstaendig = await putFragebogen(beispielFragebogen()) + assert.equal(unvollstaendig.status, 200) + const erwartet = bestellpruefung({ einstellung: stand.einstellung, fragebogen: unvollstaendig.body.fragebogen, feature: T, abo: { ok: true } }) + assert.ok(erwartet.length > 0) + assert.deepEqual(unvollstaendig.body.offen, erwartet, 'dieselbe Liste wie im GET') + assert.ok(unvollstaendig.body.offen.some((p) => p.schritt === 'leistungen'), 'noch nicht bestätigte Schritte stehen drin') + + const vollstaendig = await putFragebogen({ ...stand.fragebogen }) + assert.equal(vollstaendig.status, 200) + assert.deepEqual(vollstaendig.body.offen, [], 'alles bestätigt und bestellbar') + assert.deepEqual((await send('GET', einstellungenUrl(), { cookie })).body.offen, []) + + await deleteDocument('portalFeatures', FEATURE) + await deleteDocument('termineConfig', FEATURE) + setzeZustand('active') + assert.equal((await putFragebogen(beispielFragebogen())).body.offen, null) +}) + +test('PUT fragebogen: Branche gesundheit lässt sich speichern, das Gate (offen) blockt sie aber', async () => { + setzeZustand('off') + const res = await putFragebogen({ ...bestellbarerStand({ now: JETZT }).fragebogen, branche: 'gesundheit' }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.ok(res.body.offen.some((p) => p.schritt === 'branche')) +}) + +// ---------------------------------------------------------------- PUT: Kundenansicht + +for (const zustand of ZUSTAENDE) { + test(`PUT fragebogen in der Kundenansicht (${zustand}) => 403 view_blocked über den Wächter; nichts geändert, nichts protokolliert`, async () => { + setzeZustand(zustand) + const vorher = dokumente() + const res = await putFragebogen(beispielFragebogen(), kv()) + assert.equal(res.status, 403, JSON.stringify(res.body)) + assert.equal(res.body.code, 'view_blocked') + assert.equal(dokumente(), vorher) + }) + + test(`PUT fragebogen in der Kundenansicht (${zustand}) => 403 mit Text direkt am Router (ohne Wächter); vor jeder Zustandsprüfung, nichts geändert`, async () => { + setzeZustand(zustand) + const vorher = dokumente() + const res = await sendDirekt('PUT', FRAGEBOGEN_URL, { cookie: kundenansichtCookie(), body: { projectId: 'p1', fragebogen: beispielFragebogen() } }) + assert.equal(res.status, 403, JSON.stringify(res.body)) + assert.equal(res.body.error, KUNDENANSICHT_TEXT) + assert.equal(dokumente(), vorher) + }) +} + +test('PUT fragebogen am Router: dieselbe Anfrage mit der Sitzung des Kunden selbst geht durch (der Router sperrt nur die Kundenansicht)', async () => { + setzeZustand('off') + const res = await sendDirekt('PUT', FRAGEBOGEN_URL, { cookie, body: { projectId: 'p1', fragebogen: beispielFragebogen() } }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.deepEqual(gespeicherterFragebogen(), beispielFragebogen()) +}) + +test('PUT fragebogen am Router: auch eine alte Ansicht (viewAs ohne viewId) und die Ansicht eines anderen Kunden schreiben nichts', async () => { + setzeZustand('off') + const vorher = dokumente() + for (const c of [viewAsCookie('c1'), kundenansichtCookie({ customerId: 'c2' })]) { + const res = await sendDirekt('PUT', FRAGEBOGEN_URL, { cookie: c, body: { projectId: 'p1', fragebogen: beispielFragebogen() } }) + assert.ok([401, 403].includes(res.status), String(res.status)) + } + assert.equal(dokumente(), vorher) +}) + +// ---------------------------------------------------------------- PUT: Mandanten und Zugang + +test('PUT fragebogen: Kunde c2 mit dem Projekt von c1 => 403 in jedem Zustand; nichts angelegt, nichts geändert', async () => { + for (const zustand of ZUSTAENDE) { + await deleteDocument('portalFeatures', FEATURE).catch(() => {}) + await deleteDocument('termineConfig', FEATURE).catch(() => {}) + setzeZustand(zustand) + const vorher = dokumente() + const res = await putFragebogen(beispielFragebogen(), { cookie: customerCookie('c2') }) + assert.equal(res.status, 403, zustand) + assert.equal(res.body.error, 'Kein Zugriff auf dieses Projekt.') + assert.equal(dokumente(), vorher, zustand) + } +}) + +test('PUT fragebogen: ohne Sitzung 401, ohne projectId 400, unbrauchbare projectId 403, Pilot-Schalter 409', async () => { + setzeZustand('off') + const vorher = dokumente() + assert.equal((await send('PUT', FRAGEBOGEN_URL, { body: { projectId: 'p1', fragebogen: beispielFragebogen() } })).status, 401) + assert.equal((await send('PUT', FRAGEBOGEN_URL, { cookie, body: { fragebogen: beispielFragebogen() } })).status, 400) + assert.equal((await putFragebogen(beispielFragebogen(), { cookie }, { projectId: 'gibt es nicht' })).status, 403) + config.termine.pilotEmails = ['andere@example.invalid'] + const gesperrt = await putFragebogen(beispielFragebogen()) + assert.equal(gesperrt.status, 409) + assert.equal(gesperrt.body.code, 'nicht_verfuegbar') + config.termine.pilotEmails = ['*'] + config.termine.mode = 'off' + assert.equal((await putFragebogen(beispielFragebogen())).status, 409) + assert.equal(dokumente(), vorher) +}) + +test('PUT fragebogen und Vorlage: die Antworten sind nie zwischenspeicherbar', async () => { + setzeZustand('off') + const res = await putFragebogen(beispielFragebogen()) + assert.equal(res.headers.get('cache-control'), 'no-store') + assert.equal((await vorlage({ branche: 'gesundheit' })).headers.get('cache-control'), 'no-store') +}) + +// ---------------------------------------------------------------- Schreibbudget + +test('Schreibbudget: ein kompletter Durchlauf (1 Vorlage, 8 PATCH, 8 PUT, 5 Editor-Speicherungen) bleibt unter 120 je Stunde, der 121. Aufruf ist 429', async () => { + const vorlageErgebnis = await vorlage({ branche: 'salon' }) + assert.equal(vorlageErgebnis.status, 201, JSON.stringify(vorlageErgebnis.body)) + let version = vorlageErgebnis.body.version + let einstellung = vorlageErgebnis.body.einstellung + let aufrufe = 1 + + const speichern = async (name) => { + einstellung = { ...einstellung, betrieb: { ...einstellung.betrieb, name } } + const res = await patch({ version, einstellung }) + aufrufe += 1 + assert.equal(res.status, 200, `Aufruf ${aufrufe}: ${JSON.stringify(res.body)}`) + version = res.body.version + einstellung = res.body.einstellung + } + for (let i = 0; i < 8; i += 1) await speichern(`Salon Schritt ${i + 1}`) // je Schritt ein PATCH ... + for (let i = 0; i < 8; i += 1) { // ... und ein PUT mit dem Haken + const res = await putFragebogen(beispielFragebogen({ bestaetigt: SCHRITTE.slice(0, i + 1) })) + aufrufe += 1 + assert.equal(res.status, 200, `Aufruf ${aufrufe}: ${JSON.stringify(res.body)}`) + } + for (let i = 0; i < 5; i += 1) await speichern(`Salon Editor ${i + 1}`) // fünf Speicherungen im Editor + assert.equal(aufrufe, 22) + + // bis 120 Aufrufe geht alles durch ... + while (aufrufe < 120) { + const res = await putFragebogen(beispielFragebogen()) + aufrufe += 1 + assert.equal(res.status, 200, `Aufruf ${aufrufe}`) + } + // ... der 121. ist 429, gleich welche Schreib-Route (PUT, PATCH, Vorlage teilen sich das Budget des Kunden) + const vorher = dokumente() + for (const res of [ + await putFragebogen(beispielFragebogen()), + await patch({ version, einstellung }), + await vorlage({ branche: 'tattoo', ersetzen: true, version }), + ]) { + assert.equal(res.status, 429) + assert.equal(res.body.error, 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.') + } + assert.equal(dokumente(), vorher, 'abgewiesen heißt nichts geschrieben') + assert.equal((await send('GET', einstellungenUrl(), { cookie })).status, 200, 'Lesen bleibt frei') + // eine Stunde später ist das Budget wieder da + setzeUhrFuerTests(() => new Date(JETZT.getTime() + 61 * 60 * 1000)) + assert.equal((await putFragebogen(beispielFragebogen())).status, 200) +}) diff --git a/test/termineRoutes.test.js b/test/termineRoutes.test.js index 0fa500b..928df09 100644 --- a/test/termineRoutes.test.js +++ b/test/termineRoutes.test.js @@ -246,7 +246,7 @@ test('ohne Pilot (Kunde nicht in der Liste, Modus off) 409 "noch nicht verfuegba assert.equal((await send('GET', einstellungenUrl(), { cookie })).status, 409) }) -test('Funktion aus, noch nicht bestellt oder ohne Dokument: Einstellungen lesen geht (Entwurf), Vorlage, Feed-Link, Buchungen und Zeiten bleiben 409 "nicht aktiv"', async () => { +test('Funktion aus, noch nicht bestellt oder ohne Dokument: Einstellungen lesen geht (Entwurf), Feed-Link, Buchungen und Zeiten bleiben 409 "nicht aktiv"', async () => { // Lesen der Einstellungen: in jedem Zustand und auch ohne Dokument (Zustandsmatrix: test/termineFragebogenRoutes.test.js) for (const projectId of ['p2', 'p5']) { const lesen = await send('GET', einstellungenUrl(projectId), { cookie }) @@ -254,12 +254,11 @@ test('Funktion aus, noch nicht bestellt oder ohne Dokument: Einstellungen lesen assert.equal(lesen.body.state, 'off', projectId) assert.equal(lesen.body.einstellung, null, projectId) assert.equal(lesen.body.version, 0, projectId) - // alles andere (PATCH der Einstellungen im Entwurf: Schreibregeln in test/termineFragebogenRoutes.test.js, Teil 1b) + // alles andere (PATCH der Einstellungen und POST der Vorlage im Entwurf: Schreibregeln in test/termineFragebogenRoutes.test.js, Teile 1b und 2) for (const [method, pfad, body] of [ ['GET', `/api/termine/buchungen?projectId=${projectId}&von=2026-10-01&bis=2026-10-31`], ['GET', `/api/termine/zeiten?projectId=${projectId}`], ['GET', `/api/termine/export.csv?projectId=${projectId}`], - ['POST', '/api/termine/einstellungen/vorlage', { projectId, vorlage: 'salon' }], ['POST', `/api/termine/ressourcen/${R1}/feed`, { projectId }], ['POST', '/api/termine/buchungen', { projectId, art: 'sperre' }], ]) {