diff --git a/server/services/termine/einstellungen.js b/server/services/termine/einstellungen.js
index b0e6163..5478dae 100644
--- a/server/services/termine/einstellungen.js
+++ b/server/services/termine/einstellungen.js
@@ -559,13 +559,13 @@ function farbe(fehl, wert) {
// ---------------------------------------------------------------- Pflichtangaben und Widerruf
-const MELDUNG_PFLICHT = {
+export const MELDUNG_PFLICHT = {
'betrieb.telefon': 'Bitte gib die Telefonnummer deines Betriebs an. Gäste sehen sie als Ausweichweg und in ihren Mails.',
'betrieb.adresse': 'Bitte gib die Anschrift deines Betriebs an. Sie steht in der Widerrufsbelehrung, die Gäste vor der Buchung sehen.',
}
/** Leistungen, die Gaeste sehen: aktiv und mit mindestens einer aktiven Ressource (wie in oeffentlicheEinstellung). */
-function sichtbareLeistungen(einstellung) {
+export function sichtbareLeistungen(einstellung) {
const aktiveIds = new Set((einstellung?.ressourcen || []).filter((r) => r?.aktiv).map((r) => r.id))
return (einstellung?.leistungen || []).filter((l) => l?.aktiv && (l.ressourcen || []).some((id) => aktiveIds.has(id)))
}
diff --git a/server/services/termine/fragebogen.js b/server/services/termine/fragebogen.js
index f223563..160ed5e 100644
--- a/server/services/termine/fragebogen.js
+++ b/server/services/termine/fragebogen.js
@@ -1,12 +1,16 @@
-// STUB (Block B) – ersetzt Task C2 (bestellpruefung); Task C1 legt in dieser Datei den Rest des Fragebogen-Dienstes an
-import { text } from './einstellungen.js'
+import { config } from '../../config.js'
+import { aboMeldung } from '../featureOrder.js'
+import { featureHaken, oeffentlicheAnsicht } from '../featureRegistry.js'
+import { MELDUNG_PFLICHT, fehlendePflichtangaben, hatPreis, sichtbareLeistungen, text } from './einstellungen.js'
import { vorlagenListe } from './vorlagen.js'
/**
* Bestell-Fragebogen des Terminplaners (TP2, Spezifikation 2026-10-03-terminplaner-tp2-verkauf-design.md): Der Kunde
* richtet den Terminplaner vor der Bestellung ein; der Entwurf steht in termineConfig.einstellung, der Fragebogen
* (Branche, bestaetigte Schritte, Wuensche zum Einbau) im Attribut termineConfig.fragebogen als JSON-Text.
- * Dieser Teil: Schritte, Branchen, Grundform und Normalisierung. Reine Funktionen ohne Appwrite und ohne Uhr.
+ * Teil 1: Schritte, Branchen, Grundform und Normalisierung. Teil 2: Bestell-Gate (bestellpruefung), weiche
+ * Gesundheits-Stichwortpruefung und die Sicht der Bestellseite (bestellungAnsicht). Ohne Appwrite und ohne Uhr
+ * (bestellungAnsicht liest nur das Register und den Haken belehrung).
* Fehler sind { [pfad]: text } wie bei normalisiereEinstellung (Pfad '_' = das Ganze); Texte in Du-Form.
*/
@@ -109,12 +113,202 @@ export function normalisiereFragebogen(eingabe) {
return Object.keys(fehler).length ? { fehler } : { fragebogen }
}
+// ---------------------------------------------------------------- Teil 2: Bestell-Gate
+
/**
- * Bestellpruefung des Terminplaners: Liste der offenen Punkte `[{ schritt, text }]`, leer = bestellbar.
- * Die Signatur ist endgueltig (Block C ersetzt nur den Rumpf): `mitAbo !== false` schaltet den Abo-Punkt (h)
- * dazu, den nur die Vorschau (GET /api/termine/einstellungen) mit `abo` anfordert; der Checkout ruft mit
- * `mitAbo: false` und ohne `abo`. Der Platzhalter meldet nichts offen.
+ * Titel der Schritte (und der Bestellseite) fuer Meldungen und Oberflaeche; Schluessel wie SCHRITTE plus `bestellen`
+ * ("Prüfen und bestellen"). Der Titel steht in "Bitte bestätige den Schritt „…“." und wird dort woertlich gezeigt.
+ */
+export const SCHRITT_TITEL = Object.freeze({
+ branche: 'Branche und Betrieb',
+ wer: 'Wer wird gebucht',
+ leistungen: 'Leistungen',
+ zeiten: 'Zeiten',
+ regeln: 'Regeln',
+ gastfelder: 'Gastfelder',
+ benachrichtigung: 'Benachrichtigung',
+ website: 'Website',
+ bestellen: 'Prüfen und bestellen',
+})
+
+// Schritt, in dem der Kunde eine fehlende Pflichtangabe nachtraegt (Name und Anschrift stehen in Schritt 1, Telefon in Schritt 8)
+const PFLICHT_SCHRITT = { 'betrieb.telefon': 'website', 'betrieb.adresse': 'branche' }
+
+const SCHRITT_FOLGE = [...SCHRITTE, 'bestellen']
+
+const istText = (wert) => typeof wert === 'string' && wert.trim() !== ''
+
+/**
+ * Bestellpruefung des Terminplaners: Liste der offenen Punkte `[{ schritt, text }]`, leer = bestellbar. Jeder Punkt ist
+ * ein eigener Eintrag; sortiert nach Schrittfolge (SCHRITTE, dann `bestellen`), innerhalb eines Schritts in der
+ * Reihenfolge der Kriterien (a) bis (h):
+ * (a) fehlende Pflichtangaben (Telefon ⇒ website, Anschrift ⇒ branche; Text aus MELDUNG_PFLICHT),
+ * (b) keine fuer Gaeste sichtbare Leistung ⇒ leistungen, (c) je unbestaetigter Schritt ein Punkt, dazu leistungen,
+ * wenn eine sichtbare Leistung einen Preis hat und die Preise nicht geprueft sind, (d) Bundesland nicht ausdruecklich
+ * gewaehlt ⇒ zeiten (der Serverstandard RP zaehlt nicht), (e) Einbau-Modus fehlt oder Knopf ohne Knopftext ⇒ website,
+ * (f) Branche fehlt oder Gesundheitsbereich ⇒ branche, (g) Impressum- oder Datenschutz-Adresse fehlt ⇒ website (je
+ * Adresse ein Punkt), (h) nur bei `mitAbo !== false`: das Abo laeuft nicht sauber ⇒ bestellen.
+ * `feature` = Register-Eintrag (nur label.name und aboMeldung); `abo` = Ergebnis von bestellbar(), gelesen nur bei
+ * mitAbo !== false: fehlt es (Lesefehler), gilt der Grund 'unbekannt' (im Zweifel nicht bestellbar). Der Checkout (B6)
+ * ruft mit `mitAbo: false` und ohne `abo`, dort liefert die Abo-Stufe (7) die Codes; nur GET /api/termine/einstellungen
+ * (C4a) uebergibt `abo`. Ohne Einstellung gibt es genau einen Punkt (branche). Rein: veraendert nichts, neues Array.
*/
export function bestellpruefung({ einstellung, fragebogen, feature, abo, mitAbo = true } = {}) {
- return []
+ if (!istObjekt(einstellung)) return [{ schritt: 'branche', text: 'Bitte starte den Fragebogen mit der Wahl deiner Branche.' }]
+ const stand = istObjekt(fragebogen) ? fragebogen : {}
+ const betrieb = istObjekt(einstellung.betrieb) ? einstellung.betrieb : {}
+ const einbau = istObjekt(stand.einbau) ? stand.einbau : {}
+ const offen = []
+ const punkt = (schritt, meldung) => offen.push({ schritt, text: meldung })
+
+ // (a) Pflichtangaben des Betriebs
+ for (const pfad of fehlendePflichtangaben(einstellung)) punkt(PFLICHT_SCHRITT[pfad], MELDUNG_PFLICHT[pfad])
+
+ // (b) mindestens eine Leistung, die Gaeste sehen
+ const sichtbar = sichtbareLeistungen(einstellung)
+ if (sichtbar.length === 0) {
+ punkt('leistungen', 'Bitte lege mindestens eine aktive Leistung an, die einer aktiven Person oder einem aktiven Platz zugeordnet ist. Nur solche Leistungen sehen Gäste.')
+ }
+
+ // (c) jeder Schritt bestaetigt; Preise geprueft, wenn eine sichtbare Leistung einen hat
+ const bestaetigt = Array.isArray(stand.bestaetigt) ? stand.bestaetigt : []
+ for (const schritt of SCHRITTE) {
+ if (!bestaetigt.includes(schritt)) punkt(schritt, `Bitte bestätige den Schritt „${SCHRITT_TITEL[schritt]}“.`)
+ }
+ if (sichtbar.some(hatPreis) && stand.preiseGeprueft !== true) {
+ punkt('leistungen', 'Bitte prüfe die Preise deiner Leistungen und bestätige sie. Die Preise der Vorlage sind nur Platzhalter.')
+ }
+
+ // (d) Bundesland ausdruecklich gewaehlt (der Serverstandard RP ist keine Wahl)
+ if (stand.bundeslandGewaehlt !== true) {
+ punkt('zeiten', 'Bitte wähle das Bundesland deines Betriebs. Danach richten sich die Feiertage.')
+ }
+
+ // (e) Einbau: Modus, bei einem Knopf auch dessen Text
+ if (!EINBAU_MODI.includes(einbau.modus)) {
+ punkt('website', 'Bitte wähle, ob der Terminplaner direkt auf deiner Seite oder hinter einem Knopf erscheint.')
+ } else if (einbau.modus === 'knopf' && !istText(einbau.knopfText)) {
+ punkt('website', 'Bitte gib an, was auf dem Knopf stehen soll, hinter dem sich der Terminplaner öffnet.')
+ }
+
+ // (f) Branche gewaehlt und nicht der Gesundheitsbereich
+ const branche = BRANCHEN.find((b) => b.key === stand.branche)
+ if (!branche) {
+ punkt('branche', 'Bitte wähle deine Branche.')
+ } else if (branche.gesundheit) {
+ const name = feature?.label?.name || 'Terminplaner'
+ punkt('branche', `Für Betriebe im Gesundheitsbereich ist „${name}“ nicht verfügbar. Wähle eine andere Branche, wenn dein Angebot nicht dazugehört.`)
+ }
+
+ // (g) Impressum und Datenschutzerklaerung (der Gaestehinweis verweist darauf)
+ if (!istText(betrieb.impressumUrl)) punkt('website', 'Bitte trage die Adresse deines Impressums ein. Gäste finden dort die Angaben zu deinem Betrieb.')
+ if (!istText(betrieb.datenschutzUrl)) punkt('website', 'Bitte trage die Adresse deiner Datenschutzerklärung ein. Der Hinweis für Gäste verweist darauf.')
+
+ // (h) Wartungs-Abo sauber laufend (nur wenn gefragt); fehlendes abo = Lesefehler = nicht bestellbar
+ if (mitAbo !== false && !(istObjekt(abo) && abo.ok === true)) {
+ punkt('bestellen', aboMeldung(feature, istObjekt(abo) ? abo.grund : 'unbekannt'))
+ }
+
+ // stabil nach Schrittfolge (Array.prototype.sort ist ab Node 12 stabil)
+ return offen.sort((a, b) => SCHRITT_FOLGE.indexOf(a.schritt) - SCHRITT_FOLGE.indexOf(b.schritt))
+}
+
+// ---------------------------------------------------------------- Gesundheits-Stichwortpruefung
+
+// Regel: Treffer nur am Wortanfang (vor dem Treffer steht kein Buchstabe), Woerter = Folgen von Buchstaben.
+// Gruppe 1: Stamm mit beliebiger Fortsetzung bis zum Wortende (Zusammensetzungen zaehlen: Physiotherapie, Arztpraxis).
+const STAEMME_FREI = ['physio', 'therap', 'arzt', 'ärzt', 'zahnarzt', 'zahnärzt', 'heilpraktik', 'krankengymnast', 'osteopath', 'diagnos']
+// Gruppe 2: Stamm plus hoechstens eine Beugungsendung, dann Wortende (Rezepte, Beschwerden, Behandlungen; nicht Rezeption, Rezeptur).
+const STAEMME_BEUGUNG = ['rezept', 'beschwerde', 'behandlung']
+const ENDUNGEN = new Set(['', 'e', 'en', 'n', 's', 'es'])
+
+const MAX_WARNUNGEN = 10
+const MAX_WORT = 60
+const MAX_NAME = 40
+
+const istGesundheitswort = (wort) =>
+ STAEMME_FREI.some((stamm) => wort.startsWith(stamm))
+ || STAEMME_BEUGUNG.some((stamm) => wort.startsWith(stamm) && ENDUNGEN.has(wort.slice(stamm.length)))
+
+/** Woerter eines Textes (Folgen von Buchstaben, NFC), die ein Gesundheitsstichwort sind, in Textreihenfolge. */
+function gesundheitsWoerter(wert) {
+ if (typeof wert !== 'string') return []
+ return [...wert.normalize('NFC').matchAll(/[\p{L}\p{M}]+/gu)]
+ .map((m) => m[0])
+ .filter((wort) => istGesundheitswort(wort.toLowerCase()))
+}
+
+const kurz = (wert, max) => (wert.length > max ? `${wert.slice(0, max - 1)}…` : wert)
+
+/**
+ * Weiche Pruefung auf Gesundheitsangaben (Spec 3.5, D6): Stichwortliste ueber Betriebsname, Leistungsnamen,
+ * Beschreibungen sowie Beschriftungen und Auswahlen der Zusatzfelder. Ergebnis: hoechstens 10 Meldungen (Klartext, ohne
+ * Doppelte), jede nennt den Fundort und das gefundene Wort. Nur Warnblock fuers Team (T16, Ticket): dem Kunden zeigt
+ * Block C nichts, eine Meldung ist kein Fehler. Liest nie Gaestedaten (nur die Einstellung des Betriebs).
+ * @param {object|null} einstellung
+ * @returns {string[]}
+ */
+export function gesundheitsWarnungen(einstellung) {
+ if (!istObjekt(einstellung)) return []
+ const meldungen = new Set()
+ const pruefe = (wert, fundort) => {
+ for (const wort of gesundheitsWoerter(wert)) {
+ if (meldungen.size >= MAX_WARNUNGEN) return
+ meldungen.add(`${fundort} enthält „${kurz(wort, MAX_WORT)}“`)
+ }
+ }
+ pruefe(einstellung.betrieb?.name, 'Betriebsname')
+ for (const leistung of Array.isArray(einstellung.leistungen) ? einstellung.leistungen : []) {
+ if (!istObjekt(leistung)) continue
+ pruefe(leistung.name, 'Leistungsname')
+ const name = typeof leistung.name === 'string' ? kurz(leistung.name.trim(), MAX_NAME) : ''
+ pruefe(leistung.beschreibung, name ? `Beschreibung der Leistung „${name}“` : 'Beschreibung einer Leistung')
+ }
+ const zusatz = einstellung.gastFelder?.zusatz
+ for (const feld of Array.isArray(zusatz) ? zusatz : []) {
+ if (!istObjekt(feld)) continue
+ pruefe(feld.label, 'Zusatzfeld-Beschriftung')
+ const label = typeof feld.label === 'string' ? kurz(feld.label.trim(), MAX_NAME) : ''
+ for (const option of Array.isArray(feld.optionen) ? feld.optionen : []) {
+ pruefe(option, label ? `Auswahl im Zusatzfeld „${label}“` : 'Auswahl in einem Zusatzfeld')
+ }
+ }
+ return [...meldungen]
+}
+
+// ---------------------------------------------------------------- Sicht der Bestellseite
+
+// Titel der Rechtsunterlagen (Registerfelder) auf der Bestellseite
+const DOKUMENT_TITEL = [
+ ['leistungsbeschreibung', 'Leistungsbeschreibung'],
+ ['avv', 'Auftragsverarbeitungsvertrag (AVV)'],
+ ['widerruf', 'Widerrufsbelehrung'],
+]
+
+/**
+ * Sicht der Bestellseite ("Prüfen und bestellen"): reine Abbildung von oeffentlicheAnsicht(feature.key) (B1) plus die
+ * Belehrung. `consent` bleibt unveraendert, inklusive `knopf` und des `haken`-Flags je Eintrag (der Knopf zeigt genau diesen
+ * Text, B speichert genau diesen Text). `dokumente` stammen aus den Registerfeldern leistungsbeschreibung/avv/widerruf und
+ * nur mit gesetzter `url`. `belehrung` ist das Ergebnis des Hakens belehrung (derselbe Text, den B7 im Nachweis speichert;
+ * ohne Haken null). Keine internen Felder (ticket, mails). Unbekannte Funktion ⇒ null.
+ * @param {{ key: string }} feature Register-Eintrag
+ * @returns {Promise<{ preis: string, preisHinweis: string, einrichtung: string, consent: object, dokumente: object[], belehrung: string|null }|null>}
+ */
+export async function bestellungAnsicht(feature) {
+ const ansicht = typeof feature?.key === 'string' ? oeffentlicheAnsicht(feature.key) : null
+ if (!ansicht) return null
+ const dokumente = []
+ for (const [id, titel] of DOKUMENT_TITEL) {
+ const fassung = ansicht[id]
+ if (fassung && typeof fassung.url === 'string' && fassung.url !== '') dokumente.push({ id, titel, version: fassung.version, url: fassung.url })
+ }
+ const belehrung = (await featureHaken(feature.key).belehrung?.()) ?? null
+ return {
+ preis: ansicht.priceLabel,
+ preisHinweis: config.ustHinweis,
+ einrichtung: ansicht.setupEstimate,
+ consent: ansicht.consent,
+ dokumente,
+ belehrung,
+ }
}
diff --git a/test/featurePaid.test.js b/test/featurePaid.test.js
index 1e79d32..a4f790e 100644
--- a/test/featurePaid.test.js
+++ b/test/featurePaid.test.js
@@ -70,7 +70,7 @@ beforeEach(async () => {
mails.versandFehler = null
config.termine.mode = 'pilot'
config.termine.pilotEmails = ['*']
- // Die Fragebogen-Pruefung gehoert zu Block C: hier ist jedes Projekt bestellbar
+ // Die Bestellpruefung (Fragebogen-Gate, termine/fragebogen.js) ist hier nicht Gegenstand: jedes Projekt ist bestellbar
T.bestellpruefung = async () => []
})
diff --git a/test/funktionenCheckout.test.js b/test/funktionenCheckout.test.js
index 3bf1125..52a1a73 100644
--- a/test/funktionenCheckout.test.js
+++ b/test/funktionenCheckout.test.js
@@ -27,6 +27,7 @@ const { config } = await import('../server/config.js')
const { startTestApp, post } = await import('./support/testApp.js')
const { customerCookie, kundenansichtCookie, seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js')
const { default: funktionenRoutes } = await import('../server/routes/funktionen.js')
+const { bestellbarerStand, entwurfDoc } = await import('./support/fragebogenStand.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const reg = await import('../server/services/featureRegistry.js')
const order = await import('../server/services/featureOrder.js')
@@ -349,9 +350,10 @@ test('Bestellpruefung: kaputter Haken (keine Liste) lehnt ab, 500 ohne Rohtext (
test('Standard-Haken liest den Entwurf nur, wenn es ein Dokument gibt, und ruft die Bestellpruefung ohne Abo', async () => {
const haken = reg.featureHaken('termine')
const arg = { customerId: 'c1', projectId: 'p1', feature: T }
- // ohne Dokument: keine Abfrage von termineConfig, der Platzhalter meldet nichts offen
+ // ohne Dokument: keine Abfrage von termineConfig, das echte Gate meldet nur die Branche offen (kein Abo-Punkt)
aw.failNext({ collection: 'termineConfig', status: 500 })
- assert.deepEqual(await haken.bestellpruefung(arg), [])
+ const ohneDoc = await haken.bestellpruefung(arg)
+ assert.deepEqual(ohneDoc.map((p) => p.schritt), ['branche'])
// mit Dokument: der Entwurf wird geladen (der eingeplante Lesefehler trifft)
aw.seed('portalFeatures', { $id: 'f_p1_termine', customerId: 'c1', projectId: 'p1', featureKey: 'termine', state: 'off', enabled: false, siteKey: 'abcdefghijklmnop' })
const log = stumm()
@@ -360,10 +362,21 @@ test('Standard-Haken liest den Entwurf nur, wenn es ein Dokument gibt, und ruft
} finally {
log.mock.restore()
}
- assert.deepEqual(await haken.bestellpruefung(arg), [], 'danach wieder normal')
+ // Dokument ohne gespeicherten Entwurf: wie ohne Dokument
+ assert.deepEqual((await haken.bestellpruefung(arg)).map((p) => p.schritt), ['branche'], 'danach wieder normal')
+ // bestellbarer Entwurf: leer; offener Fragebogen: Punkte der Schritte, nie der Abo-Punkt (mitAbo: false)
+ aw.seed('termineConfig', entwurfDoc('f_p1_termine', { customerId: 'c1', projectId: 'p1' }))
+ assert.deepEqual(await haken.bestellpruefung(arg), [])
+ const offen = bestellbarerStand()
+ offen.fragebogen.bestaetigt = []
+ aw.seed('termineConfig', entwurfDoc('f_p1_termine', { customerId: 'c1', projectId: 'p1', stand: offen }))
+ const punkte = await haken.bestellpruefung(arg)
+ assert.equal(punkte.length, 8)
+ assert.ok(punkte.every((p) => p.schritt !== 'bestellen'))
})
// ------------------------------------------------------------------------------------------ 7 Abo ---
+const SCHRITTE_OHNE_BRANCHE = ['wer', 'leistungen', 'zeiten', 'regeln', 'gastfelder', 'benachrichtigung', 'website']
const ABO_TEXT = {
kein_abo: 'Für den Terminplaner brauchst du ein laufendes Wartungs-Abo.',
gekuendigt: 'Dein Wartungs-Abo ist gekündigt. Für den Terminplaner brauchst du ein laufendes Abo.',
@@ -443,15 +456,67 @@ test('Abo-Lesefehler: 500 ohne Rohtext, fail-closed ohne Spur', async () => {
}
})
-test('Abo-Stufe mit leerer Pruefung (Stub und Standard-Gate): ohne Abo 409 kein_abo, nie bestellung_offen', async () => {
+test('Abo-Stufe mit dem echten Gate: ohne Abo und bestellbarem Entwurf 409 kein_abo, mit offenem Fragebogen 409 bestellung_offen (ohne bestellen)', async () => {
const c3 = customerCookie('c3')
- // Standard-Haken (Platzhalter von Block B meldet nichts offen)
- aboAbgelehnt(await checkout('termine', { projectId: 'p3', zustimmung: zustimmung() }, c3), 'kein_abo')
- // ueberschriebener Haken mit leerer Liste
+ const entwurf = (stand) => {
+ aw.seed('portalFeatures', { $id: 'f_p3_termine', customerId: 'c3', projectId: 'p3', featureKey: 'termine', state: 'off', enabled: false, siteKey: 'abcdefghijklmnop' })
+ aw.seed('termineConfig', entwurfDoc('f_p3_termine', { customerId: 'c3', projectId: 'p3', stand }))
+ }
+ // Standard-Haken, Kunde ohne Abo, bestellbarer Entwurf: das Gate ist leer, die Abo-Stufe lehnt ab
+ entwurf(bestellbarerStand())
+ const featuresVorher = aw.docs('portalFeatures').size
+ const abgelehnt = await checkout('termine', { projectId: 'p3', zustimmung: zustimmung() }, c3)
+ assert.equal(abgelehnt.status, 409)
+ assert.equal(abgelehnt.body.code, 'kein_abo')
+ assert.notEqual(abgelehnt.body.code, 'bestellung_offen')
+ assert.equal(abgelehnt.body.error, ABO_TEXT.kein_abo)
+ assert.deepEqual(spuren(), { sessions: 0, consents: 0, features: featuresVorher }, 'keine Sitzung, kein Nachweis, kein neues Dokument')
+ // Kunde ohne Abo, Fragebogen offen: bestellung_offen, und die Liste enthaelt keinen Abo-Punkt (mitAbo: false)
+ const offen = bestellbarerStand()
+ offen.fragebogen.bestaetigt = ['branche']
+ entwurf(offen)
+ const res = await checkout('termine', { projectId: 'p3', zustimmung: zustimmung() }, c3)
+ assert.equal(res.status, 409)
+ assert.equal(res.body.code, 'bestellung_offen')
+ assert.equal(res.body.offen.length, 7)
+ assert.ok(res.body.offen.every((p) => p.schritt !== 'bestellen'), 'kein Eintrag bestellen')
+ assert.deepEqual(res.body.offen.map((p) => p.schritt), SCHRITTE_OHNE_BRANCHE)
+ assert.deepEqual(spuren(), { sessions: 0, consents: 0, features: featuresVorher })
+ // ohne Entwurf (gar kein Dokument): ebenfalls bestellung_offen mit der Branche, nie ein Abo-Punkt
+ for (const col of ['portalFeatures', 'termineConfig']) for (const id of aw.docs(col).keys()) await deleteDocument(col, id)
+ const leer = await checkout('termine', { projectId: 'p3', zustimmung: zustimmung() }, c3)
+ assert.equal(leer.status, 409)
+ assert.equal(leer.body.code, 'bestellung_offen')
+ assert.deepEqual(leer.body.offen.map((p) => p.schritt), ['branche'])
+ // ueberschriebener Haken mit leerer Liste: weiter die Abo-Stufe
T.bestellpruefung = async () => []
aboAbgelehnt(await checkout('termine', { projectId: 'p3', zustimmung: zustimmung() }, c3), 'kein_abo')
})
+test('Echtes Gate bis zur Stripe-Sitzung: Kunde mit Wartungs-Abo und bestellbarem Entwurf kommt durch, mit offenem Punkt nicht', async () => {
+ const entwurf = (stand) => {
+ aw.seed('portalFeatures', { $id: 'f_p1_termine', customerId: 'c1', projectId: 'p1', featureKey: 'termine', state: 'off', enabled: false, siteKey: 'abcdefghijklmnop' })
+ aw.seed('termineConfig', entwurfDoc('f_p1_termine', { customerId: 'c1', projectId: 'p1', stand }))
+ }
+ // ein offener Punkt (Impressum fehlt): 409 bestellung_offen, keine Sitzung, kein Nachweis
+ const luecke = bestellbarerStand()
+ luecke.einstellung.betrieb.impressumUrl = null
+ entwurf(luecke)
+ const offen = await checkout('termine', bodyT())
+ assert.equal(offen.status, 409)
+ assert.equal(offen.body.code, 'bestellung_offen')
+ assert.deepEqual(offen.body.offen.map((p) => p.schritt), ['website'])
+ assert.deepEqual({ sessions: sitzungen().length, consents: nachweise().length }, { sessions: 0, consents: 0 })
+ // bestellbarer Entwurf: Gate leer, Abo laeuft, die Stripe-Sitzung entsteht
+ entwurf(bestellbarerStand())
+ const ok = await checkout('termine', bodyT())
+ assert.equal(ok.status, 200, JSON.stringify(ok.body))
+ assert.match(ok.body.url, /^https?:\/\//)
+ assert.equal(sitzungen().length, 1)
+ assert.equal(nachweise().length, 1)
+ assert.equal(termineDoc().state, 'ordered')
+})
+
test('aboMeldung: Wortlaute aus dem Register, Akkusativ aus mails.formen.akk, unbekannter Grund wie unbekannt', () => {
for (const grund of ['kein_abo', 'gekuendigt', 'nicht_aktiv']) assert.equal(order.aboMeldung(T, grund), ABO_TEXT[grund], grund)
assert.equal(order.aboMeldung(T, 'unbekannt'), 'Das Abo konnte gerade nicht geprüft werden.')
@@ -544,7 +609,7 @@ test('Abo-Memo: je Anfrage ein aboMemo(), News fragt das Abo nie', async () => {
})
// ------------------------------------------------------------------------------------------ Sperre, Zustand, Nachweis (B7) ---
-// Ab hier der Rest der Route: die Vorpruefungen sind bestanden (Fragebogen-Pruefung per Haken uebersprungen, Block C fuellt sie).
+// Ab hier der Rest der Route: die Vorpruefungen sind bestanden (Fragebogen-Pruefung per Haken uebersprungen; das echte Gate pruefen die Tests der Abschnitte 6 und 7).
const bereit = () => {
T.bestellpruefung = async () => []
}
diff --git a/test/support/fragebogenStand.js b/test/support/fragebogenStand.js
new file mode 100644
index 0000000..e4e01b9
--- /dev/null
+++ b/test/support/fragebogenStand.js
@@ -0,0 +1,59 @@
+import { ausVorlage } from '../../server/services/termine/vorlagen.js'
+import { SCHRITTE, leerFragebogen } from '../../server/services/termine/fragebogen.js'
+
+/**
+ * Testhelfer fuer die Bestellpruefung des Terminplaners (TP2, Task C2): ein Entwurf, der alle Punkte des Gates erfuellt,
+ * und das termineConfig-Dokument dazu. Erst nach useTestEnv() dynamisch importieren, wenn der Test config.js mit
+ * Testwerten braucht (die Vorlagen ziehen config.js mit).
+ */
+
+const BETRIEB = {
+ name: 'Salon Muster',
+ email: 'salon@example.invalid',
+ telefon: '06301 12345',
+ adresse: 'Hauptstraße 1, 67655 Kaiserslautern',
+ impressumUrl: 'https://salon.example.invalid/impressum',
+ datenschutzUrl: 'https://salon.example.invalid/datenschutz',
+ bundesland: 'RP',
+}
+
+/**
+ * Ein bestellbarer Entwurf: Vorlage `salon` mit Telefon, Anschrift, Impressum und Datenschutz, alle Schritte bestaetigt,
+ * Bundesland gewaehlt, Preise geprueft, Einbau `inline`, Branche `salon`. Bei jedem Aufruf neue Objekte (neue IDs).
+ * Mit `abo: { ok: true }` ergibt bestellpruefung() []; Tests aendern einzelne Felder, um genau einen Punkt zu oeffnen.
+ * @param {{ now?: Date }} [optionen]
+ * @returns {{ einstellung: object, fragebogen: object }}
+ */
+export function bestellbarerStand({ now = new Date() } = {}) {
+ const einstellung = ausVorlage('salon', BETRIEB, now)
+ const leer = leerFragebogen()
+ const fragebogen = {
+ ...leer,
+ branche: 'salon',
+ bestaetigt: [...SCHRITTE],
+ bundeslandGewaehlt: true,
+ preiseGeprueft: true,
+ einbau: { ...leer.einbau, modus: 'inline' },
+ }
+ return { einstellung, fragebogen }
+}
+
+/**
+ * termineConfig-Dokument (Dokument-ID = ID des portalFeatures-Dokuments) fuer aw.seed('termineConfig', …): Einstellung und
+ * Fragebogen als JSON-Text wie in Produktion. Ohne `stand` der bestellbare Stand; ein Feld, das null/undefined ist, fehlt im Dokument.
+ * @param {string} featureId
+ * @param {{ customerId: string, projectId: string, stand?: { einstellung?: object|null, fragebogen?: object|null } }} optionen
+ */
+export function entwurfDoc(featureId, { customerId, projectId, stand = bestellbarerStand() }) {
+ const doc = {
+ $id: featureId,
+ customerId,
+ projectId,
+ einstellung: JSON.stringify(stand.einstellung ?? {}),
+ version: 1,
+ updatedAt: '2026-10-04T10:00:00.000Z',
+ updatedBy: 'test',
+ }
+ if (stand.fragebogen !== null && stand.fragebogen !== undefined) doc.fragebogen = JSON.stringify(stand.fragebogen)
+ return doc
+}
diff --git a/test/termineFragebogen.test.js b/test/termineFragebogen.test.js
index 0d9c49b..5f2f82e 100644
--- a/test/termineFragebogen.test.js
+++ b/test/termineFragebogen.test.js
@@ -1,9 +1,15 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
-import { VORLAGEN, vorlagenListe } from '../server/services/termine/vorlagen.js'
+import { VORLAGEN, vorlagenListe, ausVorlage } from '../server/services/termine/vorlagen.js'
import {
- SCHRITTE, BRANCHEN, FRAGEBOGEN_MAX, leerFragebogen, normalisiereFragebogen,
+ SCHRITTE, SCHRITT_TITEL, BRANCHEN, FRAGEBOGEN_MAX, leerFragebogen, normalisiereFragebogen,
+ bestellpruefung, gesundheitsWarnungen, bestellungAnsicht,
} from '../server/services/termine/fragebogen.js'
+import { MELDUNG_PFLICHT, sichtbareLeistungen } from '../server/services/termine/einstellungen.js'
+import { aboMeldung } from '../server/services/featureOrder.js'
+import { oeffentlicheAnsicht, featureHaken, BELEHRUNG_PLATZHALTER } from '../server/services/featureRegistry.js'
+import { config } from '../server/config.js'
+import { bestellbarerStand, entwurfDoc } from './support/fragebogenStand.js'
const norm = normalisiereFragebogen
const ok = (eingabe) => {
@@ -276,3 +282,527 @@ test('normalisiere: groesste gueltige Eingabe bleibt unter FRAGEBOGEN_MAX (auch
assert.ok(Buffer.byteLength(json, 'utf8') < FRAGEBOGEN_MAX, `${JSON.stringify(zeichen)}: ${Buffer.byteLength(json, 'utf8')} Bytes`)
}
})
+
+// ---------------------------------------------------------------- Bestell-Gate (bestellpruefung)
+
+const T = config.features.termine
+const ABO_OK = { ok: true }
+const pruefe = (stand, extra = {}) => bestellpruefung({ ...stand, feature: T, abo: ABO_OK, ...extra })
+const stand = (aendere) => {
+ const s = bestellbarerStand({ now: new Date('2026-10-04T10:00:00.000Z') })
+ aendere?.(s)
+ return s
+}
+const SIE_FORM = /\b(Sie|Ihnen|Ihr|Ihre|Ihren|Ihrem|Ihrer|Ihres)\b/
+const ohnePflichttexte = (texte) => texte.filter((t) => !Object.values(MELDUNG_PFLICHT).includes(t))
+
+test('SCHRITT_TITEL: ein Titel je Schritt und fuer bestellen, unveraenderlich', () => {
+ assert.deepEqual(Object.keys(SCHRITT_TITEL), [...SCHRITTE, 'bestellen'])
+ assert.ok(Object.values(SCHRITT_TITEL).every((t) => typeof t === 'string' && t.length > 2))
+ assert.ok(Object.isFrozen(SCHRITT_TITEL))
+ assert.equal(new Set(Object.values(SCHRITT_TITEL)).size, SCHRITTE.length + 1, 'Titel eindeutig')
+})
+
+test('bestellbarerStand: Vorlage salon, alle Angaben da, bei jedem Aufruf neue Objekte', () => {
+ const a = bestellbarerStand({ now: new Date('2026-10-04T10:00:00.000Z') })
+ const b = bestellbarerStand()
+ assert.equal(a.einstellung.vorlage, 'salon')
+ assert.equal(a.einstellung.betrieb.telefon, '06301 12345')
+ assert.ok(a.einstellung.betrieb.adresse && a.einstellung.betrieb.impressumUrl && a.einstellung.betrieb.datenschutzUrl)
+ assert.deepEqual(a.fragebogen.bestaetigt, SCHRITTE)
+ assert.equal(a.fragebogen.bundeslandGewaehlt, true)
+ assert.equal(a.fragebogen.preiseGeprueft, true)
+ assert.equal(a.fragebogen.einbau.modus, 'inline')
+ assert.equal(a.fragebogen.branche, 'salon')
+ assert.notEqual(a.einstellung.ressourcen[0].id, b.einstellung.ressourcen[0].id)
+ assert.notEqual(a.fragebogen.bestaetigt, b.fragebogen.bestaetigt)
+ assert.deepEqual(norm(a.fragebogen), { fragebogen: a.fragebogen }, 'der Fragebogen ist normalisiert')
+})
+
+test('entwurfDoc: termineConfig-Dokument mit Einstellung und Fragebogen als JSON-Text', () => {
+ const s = stand()
+ const doc = entwurfDoc('f_p1_termine', { customerId: 'c1', projectId: 'p1', stand: s })
+ assert.equal(doc.$id, 'f_p1_termine')
+ assert.equal(doc.customerId, 'c1')
+ assert.equal(doc.projectId, 'p1')
+ assert.deepEqual(JSON.parse(doc.einstellung), JSON.parse(JSON.stringify(s.einstellung)))
+ assert.deepEqual(JSON.parse(doc.fragebogen), s.fragebogen)
+ assert.ok(Number.isInteger(doc.version))
+ assert.equal(Object.hasOwn(entwurfDoc('f', { customerId: 'c', projectId: 'p', stand: { einstellung: s.einstellung, fragebogen: null } }), 'fragebogen'), false)
+ assert.ok(JSON.parse(entwurfDoc('f', { customerId: 'c', projectId: 'p' }).fragebogen).bestaetigt.length === 8, 'ohne stand: der bestellbare')
+})
+
+test('bestellpruefung: bestellbarer Stand mit abo { ok: true } ergibt []', () => {
+ const ergebnis = pruefe(stand())
+ assert.deepEqual(ergebnis, [])
+ assert.ok(Array.isArray(ergebnis), 'ein Array, kein Promise')
+ assert.notEqual(pruefe(stand()), pruefe(stand()), 'bei jedem Aufruf ein neues Array')
+})
+
+// Tabelle: Kriterium, Aenderung am bestellbaren Stand, erwarteter Schritt, (optional) erwarteter Text
+const GATE_FAELLE = [
+ ['(a) Telefon leer', (s) => { s.einstellung.betrieb.telefon = '' }, 'website', MELDUNG_PFLICHT['betrieb.telefon']],
+ ['(a) Telefon fehlt (null)', (s) => { s.einstellung.betrieb.telefon = null }, 'website', MELDUNG_PFLICHT['betrieb.telefon']],
+ ['(a) Anschrift leer', (s) => { s.einstellung.betrieb.adresse = '' }, 'branche', MELDUNG_PFLICHT['betrieb.adresse']],
+ ['(a) Anschrift nur Leerzeichen', (s) => { s.einstellung.betrieb.adresse = ' ' }, 'branche', MELDUNG_PFLICHT['betrieb.adresse']],
+ ['(b) alle Leistungen inaktiv', (s) => { s.einstellung.leistungen.forEach((l) => { l.aktiv = false }) }, 'leistungen'],
+ ['(b) keine aktive Person', (s) => { s.einstellung.ressourcen.forEach((r) => { r.aktiv = false }) }, 'leistungen'],
+ ['(b) keine Leistung', (s) => { s.einstellung.leistungen = [] }, 'leistungen'],
+ ['(c) Schritt branche nicht bestaetigt', (s) => { s.fragebogen.bestaetigt = s.fragebogen.bestaetigt.filter((x) => x !== 'branche') }, 'branche', 'Bitte bestätige den Schritt „Branche und Betrieb“.'],
+ ['(c) Schritt wer nicht bestaetigt', (s) => { s.fragebogen.bestaetigt = s.fragebogen.bestaetigt.filter((x) => x !== 'wer') }, 'wer', 'Bitte bestätige den Schritt „Wer wird gebucht“.'],
+ ['(c) Schritt website nicht bestaetigt', (s) => { s.fragebogen.bestaetigt = s.fragebogen.bestaetigt.filter((x) => x !== 'website') }, 'website', 'Bitte bestätige den Schritt „Website“.'],
+ ['(c) Preise nicht geprueft', (s) => { s.fragebogen.preiseGeprueft = false }, 'leistungen'],
+ ['(d) Bundesland nicht gewaehlt', (s) => { s.fragebogen.bundeslandGewaehlt = false }, 'zeiten'],
+ ['(d) bundeslandGewaehlt fehlt', (s) => { delete s.fragebogen.bundeslandGewaehlt }, 'zeiten'],
+ ['(d) Wahrheitswert als Text zaehlt nicht', (s) => { s.fragebogen.bundeslandGewaehlt = 'true' }, 'zeiten'],
+ ['(e) einbau.modus fehlt', (s) => { s.fragebogen.einbau.modus = null }, 'website'],
+ ['(e) einbau fehlt ganz', (s) => { delete s.fragebogen.einbau }, 'website'],
+ ['(e) Modus knopf ohne Knopftext', (s) => { s.fragebogen.einbau.modus = 'knopf'; s.fragebogen.einbau.knopfText = '' }, 'website'],
+ ['(e) Modus knopf mit Leerzeichen als Knopftext', (s) => { s.fragebogen.einbau.modus = 'knopf'; s.fragebogen.einbau.knopfText = ' ' }, 'website'],
+ ['(f) Branche fehlt', (s) => { s.fragebogen.branche = null }, 'branche'],
+ ['(f) Branche unbekannt', (s) => { s.fragebogen.branche = 'raumschiff' }, 'branche'],
+ ['(f) Branche Gesundheit', (s) => { s.fragebogen.branche = 'gesundheit' }, 'branche'],
+ ['(g) Impressum fehlt', (s) => { s.einstellung.betrieb.impressumUrl = null }, 'website'],
+ ['(g) Datenschutz fehlt', (s) => { s.einstellung.betrieb.datenschutzUrl = null }, 'website'],
+ ['(g) Datenschutz leer', (s) => { s.einstellung.betrieb.datenschutzUrl = '' }, 'website'],
+]
+
+for (const [name, aendere, schritt, erwartetText] of GATE_FAELLE) {
+ test(`Gate ${name}: genau ein Punkt im Schritt ${schritt}`, () => {
+ const ergebnis = pruefe(stand(aendere))
+ assert.equal(ergebnis.length, 1, JSON.stringify(ergebnis))
+ assert.equal(ergebnis[0].schritt, schritt)
+ assert.ok(typeof ergebnis[0].text === 'string' && ergebnis[0].text.length > 10)
+ assert.deepEqual(Object.keys(ergebnis[0]), ['schritt', 'text'])
+ if (erwartetText) assert.equal(ergebnis[0].text, erwartetText)
+ })
+}
+
+test('Gate: Telefon leer ergibt { schritt: website }, Anschrift leer { schritt: branche } (Text aus MELDUNG_PFLICHT)', () => {
+ assert.deepEqual(pruefe(stand((s) => { s.einstellung.betrieb.telefon = '' })), [{ schritt: 'website', text: MELDUNG_PFLICHT['betrieb.telefon'] }])
+ assert.deepEqual(pruefe(stand((s) => { s.einstellung.betrieb.adresse = '' })), [{ schritt: 'branche', text: MELDUNG_PFLICHT['betrieb.adresse'] }])
+})
+
+test('Gate (a): ohne sichtbare Widerrufsbelehrung braucht es die Anschrift nicht (Unternehmer-Gaeste)', () => {
+ const ergebnis = pruefe(stand((s) => { s.einstellung.recht.gaesteSind = 'unternehmer'; s.einstellung.betrieb.adresse = '' }))
+ assert.deepEqual(ergebnis, [])
+})
+
+test('Gate (b): sichtbareLeistungen ist dieselbe Funktion wie in der oeffentlichen Sicht (aktiv und mit aktiver Ressource)', () => {
+ const s = stand()
+ assert.ok(sichtbareLeistungen(s.einstellung).length >= 1)
+ s.einstellung.leistungen.forEach((l) => { l.ressourcen = ['r_gibtesnicht'] })
+ assert.equal(sichtbareLeistungen(s.einstellung).length, 0)
+ assert.deepEqual(pruefe(s).map((p) => p.schritt), ['leistungen'])
+})
+
+test('Gate (c): bestaetigt leer ergibt acht Punkte in Schrittfolge, je Schritt einer', () => {
+ const ergebnis = pruefe(stand((s) => { s.fragebogen.bestaetigt = [] }))
+ assert.equal(ergebnis.length, 8)
+ assert.deepEqual(ergebnis.map((p) => p.schritt), SCHRITTE)
+ ergebnis.forEach((p, i) => assert.equal(p.text, `Bitte bestätige den Schritt „${SCHRITT_TITEL[SCHRITTE[i]]}“.`))
+})
+
+test('Gate (c): fehlendes bestaetigt (kein Array) zaehlt wie leer', () => {
+ const ergebnis = pruefe(stand((s) => { s.fragebogen.bestaetigt = 'alle' }))
+ assert.deepEqual(ergebnis.map((p) => p.schritt), SCHRITTE)
+})
+
+test('Gate (c): Preis-Punkt nur bei sichtbarer Leistung mit Preis; alle Preise keiner ergibt keinen Preis-Punkt', () => {
+ const s = stand((x) => { x.fragebogen.preiseGeprueft = false })
+ assert.ok(s.einstellung.leistungen.some((l) => l.preisArt !== 'keiner' && l.preisCent > 0))
+ assert.deepEqual(pruefe(s).map((p) => p.schritt), ['leistungen'])
+ const keiner = stand((x) => {
+ x.fragebogen.preiseGeprueft = false
+ x.einstellung.leistungen.forEach((l) => { l.preisArt = 'keiner'; l.preisCent = 0 })
+ })
+ assert.deepEqual(pruefe(keiner), [])
+ // Preis nur an einer unsichtbaren (inaktiven) Leistung ⇒ ebenfalls kein Preis-Punkt
+ const verdeckt = stand((x) => {
+ x.fragebogen.preiseGeprueft = false
+ x.einstellung.leistungen.forEach((l, i) => { if (i > 0) { l.preisArt = 'keiner'; l.preisCent = 0 } })
+ x.einstellung.leistungen[0].aktiv = false
+ })
+ assert.deepEqual(pruefe(verdeckt), [])
+})
+
+test('Gate (c): unbestaetigte Leistungen und ungepruefte Preise sind zwei eigene Punkte, beide im Schritt leistungen', () => {
+ const ergebnis = pruefe(stand((s) => {
+ s.fragebogen.bestaetigt = s.fragebogen.bestaetigt.filter((x) => x !== 'leistungen')
+ s.fragebogen.preiseGeprueft = false
+ }))
+ assert.equal(ergebnis.length, 2)
+ assert.deepEqual(ergebnis.map((p) => p.schritt), ['leistungen', 'leistungen'])
+ assert.notEqual(ergebnis[0].text, ergebnis[1].text)
+ assert.equal(ergebnis[0].text, 'Bitte bestätige den Schritt „Leistungen“.')
+})
+
+test('Gate (d): bundeslandGewaehlt false bei einstellung.bundesland RP ist trotzdem ein Punkt (der Serverstandard zaehlt nicht)', () => {
+ const s = stand((x) => { x.fragebogen.bundeslandGewaehlt = false })
+ assert.equal(s.einstellung.bundesland, 'RP')
+ assert.deepEqual(pruefe(s).map((p) => p.schritt), ['zeiten'])
+ s.fragebogen.bundeslandGewaehlt = true
+ assert.deepEqual(pruefe(s), [])
+})
+
+test('Gate (e): beide Modi sind in Ordnung, knopf mit Knopftext auch; ein unbekannter Modus nicht', () => {
+ assert.deepEqual(pruefe(stand((s) => { s.fragebogen.einbau.modus = 'knopf'; s.fragebogen.einbau.knopfText = 'Termin buchen' })), [])
+ assert.deepEqual(pruefe(stand((s) => { s.fragebogen.einbau.modus = 'popup' })).map((p) => p.schritt), ['website'])
+})
+
+test('Gate (e): zwei unterschiedliche Texte fuer fehlenden Modus und fehlenden Knopftext', () => {
+ const ohneModus = pruefe(stand((s) => { s.fragebogen.einbau.modus = null }))[0].text
+ const ohneText = pruefe(stand((s) => { s.fragebogen.einbau.modus = 'knopf'; s.fragebogen.einbau.knopfText = '' }))[0].text
+ assert.notEqual(ohneModus, ohneText)
+})
+
+test('Gate (f): Gesundheitstext nennt die Funktion aus dem Register (label.name), nicht die Branche "gesundheit" als Schluessel', () => {
+ const [punkt] = pruefe(stand((s) => { s.fragebogen.branche = 'gesundheit' }))
+ assert.equal(punkt.schritt, 'branche')
+ assert.ok(punkt.text.includes(T.label.name))
+ assert.ok(punkt.text.includes('Gesundheitsbereich'))
+ const [anders] = pruefe(stand((s) => { s.fragebogen.branche = 'gesundheit' }), { feature: { key: 'x', label: { name: 'Zeitplaner' } } })
+ assert.ok(anders.text.includes('Zeitplaner'))
+ assert.ok(!anders.text.includes(T.label.name))
+ // ohne Register-Eintrag faellt der Name auf "Terminplaner" zurueck
+ assert.ok(pruefe(stand((s) => { s.fragebogen.branche = 'gesundheit' }), { feature: undefined })[0].text.includes('Terminplaner'))
+})
+
+test('Gate (g): beide URLs fehlen ⇒ zwei Punkte (Impressum, Datenschutz) im Schritt website, Impressum zuerst', () => {
+ const ergebnis = pruefe(stand((s) => { s.einstellung.betrieb.impressumUrl = null; s.einstellung.betrieb.datenschutzUrl = null }))
+ assert.equal(ergebnis.length, 2)
+ assert.deepEqual(ergebnis.map((p) => p.schritt), ['website', 'website'])
+ assert.match(ergebnis[0].text, /Impressum/)
+ assert.match(ergebnis[1].text, /Datenschutz/)
+})
+
+test('Gate (h): abo { ok: false, grund } ergibt einen Punkt in bestellen mit dem Text aus aboMeldung', () => {
+ for (const grund of ['kein_abo', 'gekuendigt', 'nicht_aktiv']) {
+ const ergebnis = pruefe(stand(), { abo: { ok: false, grund } })
+ assert.deepEqual(ergebnis, [{ schritt: 'bestellen', text: aboMeldung(T, grund) }], grund)
+ }
+})
+
+test('Gate (h): abo fehlt (Lesefehler) ergibt aboMeldung(feature, "unbekannt"); abo ohne gueltigen Grund ebenso', () => {
+ const erwartet = [{ schritt: 'bestellen', text: aboMeldung(T, 'unbekannt') }]
+ assert.deepEqual(pruefe(stand(), { abo: undefined }), erwartet)
+ assert.deepEqual(pruefe(stand(), { abo: null }), erwartet)
+ assert.deepEqual(pruefe(stand(), { abo: { ok: false } }), erwartet)
+ assert.deepEqual(pruefe(stand(), { abo: { ok: false, grund: 'was_anderes' } }), erwartet)
+ assert.deepEqual(bestellpruefung({ ...stand(), feature: T }), erwartet, 'Standard mitAbo true, abo nicht uebergeben')
+})
+
+test('Gate (h): nur abo.ok === true gibt frei (truthy genuegt nicht)', () => {
+ assert.deepEqual(pruefe(stand(), { abo: { ok: 'ja' } }).map((p) => p.schritt), ['bestellen'])
+ assert.deepEqual(pruefe(stand(), { abo: { ok: 1 } }).map((p) => p.schritt), ['bestellen'])
+})
+
+test('Gate (h): mitAbo false ergibt keinen Abo-Punkt, auch ohne abo und mit abo { ok: false, grund: kein_abo }', () => {
+ assert.deepEqual(pruefe(stand(), { mitAbo: false, abo: undefined }), [])
+ assert.deepEqual(pruefe(stand(), { mitAbo: false, abo: { ok: false, grund: 'kein_abo' } }), [])
+ assert.deepEqual(bestellpruefung({ ...stand(), feature: T, mitAbo: false }), [])
+ // mitAbo true/undefined fragt weiter; nur ein ausdrueckliches false schaltet ab
+ assert.equal(pruefe(stand(), { mitAbo: true, abo: { ok: false, grund: 'kein_abo' } }).length, 1)
+ assert.equal(pruefe(stand(), { mitAbo: undefined, abo: { ok: false, grund: 'kein_abo' } }).length, 1)
+})
+
+test('Gate (h): der Abo-Punkt steht immer zuletzt, nach den Punkten der Schritte', () => {
+ const ergebnis = pruefe(stand((s) => { s.fragebogen.bestaetigt = [] }), { abo: { ok: false, grund: 'gekuendigt' } })
+ assert.equal(ergebnis.length, 9)
+ assert.deepEqual(ergebnis.map((p) => p.schritt), [...SCHRITTE, 'bestellen'])
+})
+
+test('Gate: einstellung null ergibt genau einen Punkt branche (auch mit abo, fragebogen null)', () => {
+ const ergebnis = bestellpruefung({ einstellung: null, fragebogen: null, feature: T, abo: { ok: false, grund: 'kein_abo' } })
+ assert.equal(ergebnis.length, 1)
+ assert.equal(ergebnis[0].schritt, 'branche')
+ assert.equal(typeof ergebnis[0].text, 'string')
+ assert.deepEqual(bestellpruefung({ einstellung: null, fragebogen: null, feature: T, mitAbo: false }).map((p) => p.schritt), ['branche'])
+ assert.deepEqual(bestellpruefung({}).map((p) => p.schritt), ['branche'], 'ohne Argumente')
+ assert.deepEqual(bestellpruefung().map((p) => p.schritt), ['branche'], 'ganz ohne Argument')
+ assert.deepEqual(bestellpruefung({ einstellung: 'kaputt' }).map((p) => p.schritt), ['branche'], 'kein Objekt')
+})
+
+test('Gate: fragebogen null bei vorhandener Einstellung ist ein leerer Fragebogen', () => {
+ const s = stand()
+ const ergebnis = pruefe({ einstellung: s.einstellung, fragebogen: null })
+ const schritte = ergebnis.map((p) => p.schritt)
+ assert.ok(schritte.filter((x) => SCHRITTE.includes(x)).length >= 8)
+ assert.deepEqual(ergebnis, pruefe({ einstellung: s.einstellung, fragebogen: leerFragebogen() }))
+ assert.deepEqual(ergebnis, pruefe({ einstellung: s.einstellung, fragebogen: undefined }))
+})
+
+test('Gate: Reihenfolge stabil – nach Schrittfolge, innerhalb eines Schritts in der Reihenfolge (a), (e), (g)', () => {
+ const kaputt = stand((s) => {
+ s.einstellung.betrieb.impressumUrl = null
+ s.einstellung.betrieb.telefon = ''
+ s.fragebogen.einbau.modus = null
+ s.einstellung.betrieb.adresse = ''
+ s.fragebogen.branche = null
+ s.fragebogen.bundeslandGewaehlt = false
+ s.fragebogen.preiseGeprueft = false
+ })
+ const ergebnis = pruefe(kaputt, { abo: { ok: false, grund: 'nicht_aktiv' } })
+ const reihenfolge = ergebnis.map((p) => p.schritt)
+ assert.deepEqual(reihenfolge, ['branche', 'branche', 'leistungen', 'zeiten', 'website', 'website', 'website', 'bestellen'])
+ const website = ergebnis.filter((p) => p.schritt === 'website').map((p) => p.text)
+ assert.equal(website[0], MELDUNG_PFLICHT['betrieb.telefon'], '(a) vor (e) vor (g)')
+ assert.match(website[2], /Impressum/)
+ assert.equal(ergebnis[0].text, MELDUNG_PFLICHT['betrieb.adresse'], '(a) vor (f) im Schritt branche')
+ // zweiter Aufruf mit anders aufgebauten Eingaben: dasselbe Ergebnis
+ assert.deepEqual(pruefe(structuredClone(kaputt), { abo: { ok: false, grund: 'nicht_aktiv' } }), ergebnis)
+ assert.deepEqual(pruefe(kaputt, { abo: { ok: false, grund: 'nicht_aktiv' } }), ergebnis)
+})
+
+test('Gate: reine Funktion – veraendert Eingaben nicht, ohne Gaestedaten und ohne Nebenwirkung', () => {
+ const s = stand((x) => { x.fragebogen.bestaetigt = ['wer']; x.einstellung.betrieb.telefon = '' })
+ const vorher = structuredClone(s)
+ const abo = { ok: false, grund: 'kein_abo' }
+ pruefe(s, { abo })
+ assert.deepEqual(s, vorher)
+ assert.deepEqual(abo, { ok: false, grund: 'kein_abo' })
+})
+
+test('Gate: alle Texte in Du-Form (kein Sie/Ihr), ausser den unveraenderten Pflichtangaben-Meldungen aus einstellungen.js', () => {
+ const alles = new Set()
+ for (const [, aendere] of GATE_FAELLE) pruefe(stand(aendere)).forEach((p) => alles.add(p.text))
+ pruefe(stand((s) => { s.fragebogen.bestaetigt = [] })).forEach((p) => alles.add(p.text))
+ for (const grund of ['kein_abo', 'gekuendigt', 'nicht_aktiv', undefined]) pruefe(stand(), { abo: { ok: false, grund } }).forEach((p) => alles.add(p.text))
+ pruefe({ einstellung: null, fragebogen: null }).forEach((p) => alles.add(p.text))
+ pruefe(stand((s) => { s.fragebogen.branche = 'gesundheit'; s.fragebogen.einbau.modus = 'knopf'; s.fragebogen.einbau.knopfText = '' })).forEach((p) => alles.add(p.text))
+ assert.ok(alles.size > 15, `nur ${alles.size} verschiedene Texte`)
+ for (const text of ohnePflichttexte([...alles])) {
+ assert.doesNotMatch(text, SIE_FORM, text)
+ assert.ok(text.length <= 300, `zu lang: ${text}`)
+ assert.doesNotMatch(text, /[<>]/, 'kein Markup')
+ }
+ // Die zwei Pflichtangaben-Meldungen bleiben wie in einstellungen.js (Gäste sehen "sie" = die Nummer/Anschrift, keine Anrede)
+ assert.ok(Object.values(MELDUNG_PFLICHT).every((m) => /\bdeines? Betriebs\b/.test(m)))
+})
+
+// ---------------------------------------------------------------- Gesundheits-Stichwortpruefung
+
+const mitName = (name) => ({ betrieb: { name: 'Studio' }, leistungen: [{ name, beschreibung: '', aktiv: true, ressourcen: [] }], gastFelder: { zusatz: [] } })
+const mitBeschreibung = (beschreibung) => ({ betrieb: { name: 'Studio' }, leistungen: [{ name: 'Termin', beschreibung }], gastFelder: { zusatz: [] } })
+const mitLabel = (label) => ({ betrieb: { name: 'Studio' }, leistungen: [], gastFelder: { zusatz: [{ key: 'x', label, typ: 'text', optionen: [] }] } })
+const mitOption = (option) => ({ betrieb: { name: 'Studio' }, leistungen: [], gastFelder: { zusatz: [{ key: 'x', label: 'Anliegen', typ: 'auswahl', optionen: ['Sonstiges', option] }] } })
+const mitBetrieb = (name) => ({ betrieb: { name }, leistungen: [], gastFelder: { zusatz: [] } })
+
+// Stichwort ⇒ ein Beispielwort, das den Stamm enthaelt (je Stamm der Liste ein Fall)
+const STICHWORTE = [
+ ['physio', 'Physiotherapie'], ['therap', 'Therapeutin'], ['arzt', 'Arztbesuch'], ['ärzt', 'Ärztin'],
+ ['zahnarzt', 'Zahnarzt'], ['zahnärzt', 'Zahnärztin'], ['heilpraktik', 'Heilpraktiker'],
+ ['krankengymnast', 'Krankengymnastik'], ['osteopath', 'Osteopathie'], ['diagnos', 'Diagnose'],
+ ['rezept', 'Rezept'], ['beschwerde', 'Beschwerden'], ['behandlung', 'Behandlung'],
+]
+
+test('Stichwortpruefung: jedes der 13 Stichworte trifft in Leistungsname, Beschreibung, Zusatzfeld-Beschriftung, Auswahl und Betriebsname', () => {
+ assert.equal(STICHWORTE.length, 13)
+ for (const [stamm, wort] of STICHWORTE) {
+ for (const [ort, einstellung] of [['Name', mitName(wort)], ['Beschreibung', mitBeschreibung(`Termin wegen ${wort} möglich`)], ['Beschriftung', mitLabel(wort)], ['Auswahl', mitOption(wort)], ['Betrieb', mitBetrieb(`${wort} Müller`)]]) {
+ const treffer = gesundheitsWarnungen(einstellung)
+ assert.equal(treffer.length, 1, `${stamm} (${wort}) in ${ort}: ${JSON.stringify(treffer)}`)
+ assert.ok(treffer[0].includes(wort), `${wort} steht in der Meldung: ${treffer[0]}`)
+ }
+ }
+})
+
+test('Stichwortpruefung: Gross-/Kleinschreibung und Umlaute (auch zerlegtes ä)', () => {
+ for (const wort of ['PHYSIO', 'physio', 'PhysioTherapie', 'ÄRZTIN', 'ärztin', 'Ärztin', 'ZAHNÄRZTIN', 'zahnärztin', 'HEILPRAKTIKERIN', 'REZEPT', 'BEHANDLUNGEN']) {
+ assert.equal(gesundheitsWarnungen(mitName(wort)).length, 1, wort)
+ }
+ assert.equal(gesundheitsWarnungen(mitName('Arztpraxis')).length, 1)
+})
+
+test('Regeltest: Treffer nur am Wortanfang; Gruppe 2 nur mit hoechstens einer Beugungsendung', () => {
+ const treffer = ['Rezept', 'Rezepte', 'Behandlungen', 'Beschwerden', 'Physiotherapie', 'Therapiestunde', 'Zahnärztin', 'Krankengymnastik', 'Osteopathie']
+ const keine = ['Rezeption', 'Rezeptur', 'Gesichtsbehandlung', 'Beratung', 'Besichtigung']
+ for (const wort of treffer) assert.equal(gesundheitsWarnungen(mitName(wort)).length, 1, `Treffer erwartet: ${wort}`)
+ for (const wort of keine) assert.deepEqual(gesundheitsWarnungen(mitName(wort)), [], `kein Treffer erwartet: ${wort}`)
+ // weitere Randfaelle der Regel
+ for (const wort of ['Rezepts', 'Rezeptes', 'Behandlung', 'Behandlungs', 'Beschwerde', 'Beschwerdes', 'Beschwerdenn']) {
+ assert.equal(gesundheitsWarnungen(mitName(wort)).length, wort === 'Beschwerdenn' ? 0 : 1, wort)
+ }
+ for (const wort of ['Behandlungsraum', 'Rezepturen', 'Beschwerdemanagement', 'Hausarzt', 'Kinderphysio', 'Lagerdiagnos', 'Wasserrezeption']) {
+ // "Hausarzt", "Kinderphysio", "Lagerdiagnos": kein Buchstabe vor dem Treffer erlaubt – Treffer nur am Wortanfang
+ assert.deepEqual(gesundheitsWarnungen(mitName(wort)), [], wort)
+ }
+})
+
+test('Regeltest: Wortanfang auch nach Bindestrich, Leerzeichen, Ziffer und Satzzeichen (kein Buchstabe davor)', () => {
+ for (const text of ['Kinder-Physiotherapie', 'Termin (Rezept abholen)', '3x Therapie', 'Beratung/Behandlung', 'a, Rezepte', 'Ärztliche Beratung']) {
+ assert.equal(gesundheitsWarnungen(mitName(text)).length, 1, text)
+ }
+ assert.deepEqual(gesundheitsWarnungen(mitName('Gesichtsbehandlung und Beratung, Besichtigung')), [])
+})
+
+test('Stichwortpruefung: alle neun Vorlagen ergeben [] (schuetzt Gesichtsbehandlung, Beratung, Besichtigung)', () => {
+ const keys = Object.keys(VORLAGEN)
+ assert.equal(keys.length, 9)
+ for (const key of keys) {
+ const einstellung = ausVorlage(key, { name: 'Muster GmbH', email: 'a@example.invalid', telefon: '1', adresse: 'Hauptstr. 1' }, new Date('2026-10-04T10:00:00.000Z'))
+ assert.deepEqual(gesundheitsWarnungen(einstellung), [], key)
+ }
+})
+
+test('Stichwortpruefung: null und leere Einstellung ergeben []', () => {
+ assert.deepEqual(gesundheitsWarnungen(null), [])
+ assert.deepEqual(gesundheitsWarnungen(undefined), [])
+ assert.deepEqual(gesundheitsWarnungen({}), [])
+ assert.deepEqual(gesundheitsWarnungen('Physiotherapie'), [])
+ assert.deepEqual(gesundheitsWarnungen({ betrieb: null, leistungen: 'x', gastFelder: { zusatz: [null, 5, { label: 7, optionen: 'a' }] } }), [])
+})
+
+test('Stichwortpruefung: nur Betriebsname, Leistungen und Zusatzfelder werden gelesen – nie E-Mail, Telefon, Anschrift, Storno, Gaeste', () => {
+ const e = {
+ betrieb: { name: 'Studio Nord', email: 'physio@example.invalid', telefon: 'Arzt-Notruf', adresse: 'Therapiestraße 1', impressumUrl: 'https://rezept.example.invalid' },
+ storno: { text: 'Behandlung nur nach Diagnose' },
+ leistungen: [{ name: 'Haarschnitt', beschreibung: 'Kurz und gut' }],
+ gastFelder: { telefon: 'pflicht', nachricht: 'optional', zusatz: [] },
+ }
+ assert.deepEqual(gesundheitsWarnungen(e), [])
+})
+
+test('Stichwortpruefung: ohne Doppelte (gleiche Meldung nur einmal) und hoechstens 10 Meldungen', () => {
+ const doppelt = {
+ betrieb: { name: 'Studio' },
+ leistungen: [{ name: 'Physiotherapie' }, { name: 'Physiotherapie' }, { name: 'physiotherapie' }],
+ gastFelder: { zusatz: [] },
+ }
+ const treffer = gesundheitsWarnungen(doppelt)
+ assert.equal(new Set(treffer).size, treffer.length, 'keine Doppelten')
+ assert.ok(treffer.length >= 1 && treffer.length <= 2, JSON.stringify(treffer))
+ const viele = { betrieb: { name: 'Studio' }, leistungen: Array.from({ length: 25 }, (_, i) => ({ name: `Therapie${String.fromCharCode(97 + i)}` })), gastFelder: { zusatz: [] } }
+ const alle = gesundheitsWarnungen(viele)
+ assert.equal(alle.length, 10)
+ assert.equal(new Set(alle).size, 10)
+ // zusammen mit Zusatzfeldern ebenfalls gedeckelt
+ const gemischt = { ...viele, gastFelder: { zusatz: Array.from({ length: 5 }, (_, i) => ({ label: `Rezept${i}`, optionen: ['Diagnose', 'Arzt'] })) } }
+ assert.ok(gesundheitsWarnungen(gemischt).length <= 10)
+})
+
+test('Stichwortpruefung: Meldungen sind Klartext (Strings), nennen den Fundort und das Wort, kein Markup, begrenzte Laenge', () => {
+ const langesWort = `Physio${'x'.repeat(200)}`
+ const treffer = gesundheitsWarnungen({ betrieb: { name: 'Praxis Therapie' }, leistungen: [{ name: langesWort, beschreibung: 'Rezept