Files
Webklar-Kundenbereich/test/termineFragebogen.test.js
T
ClaudeandClaude Sonnet 5.5 008ad4fb1e Terminplaner TP2: Bestell-Gate und Gesundheits-Stichwortpruefung
fragebogen.js ersetzt den Stub aus Block B: bestellpruefung (Kriterien (a) bis (h),
je Punkt ein Eintrag, nach Schrittfolge sortiert, Abo-Punkt nur bei mitAbo !== false),
SCHRITT_TITEL, gesundheitsWarnungen (Stichworte nur am Wortanfang, hoechstens 10
Meldungen) und bestellungAnsicht fuer die Bestellseite. einstellungen.js exportiert
sichtbareLeistungen und MELDUNG_PFLICHT. Testhelfer bestellbarerStand und entwurfDoc;
der Stub-Test entfaellt, die zwei B-Tests mit dem Stub laufen gegen das echte Gate.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 19:00:12 +00:00

809 lines
45 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { VORLAGEN, vorlagenListe, ausVorlage } from '../server/services/termine/vorlagen.js'
import {
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) => {
const ergebnis = norm(eingabe)
assert.equal(ergebnis.fehler, undefined, `unerwarteter Fehler: ${JSON.stringify(ergebnis.fehler)}`)
return ergebnis.fragebogen
}
// ---------------------------------------------------------------- SCHRITTE und BRANCHEN
test('SCHRITTE: genau die acht Schritte in fester Reihenfolge, unveraenderlich', () => {
assert.deepEqual(SCHRITTE, ['branche', 'wer', 'leistungen', 'zeiten', 'regeln', 'gastfelder', 'benachrichtigung', 'website'])
assert.ok(Object.isFrozen(SCHRITTE))
})
test('BRANCHEN: Reihenfolge von vorlagenListe(), danach genau der Eintrag Gesundheitsbereich', () => {
const liste = vorlagenListe()
assert.equal(BRANCHEN.length, liste.length + 1)
BRANCHEN.slice(0, liste.length).forEach((branche, i) => {
assert.deepEqual(branche, { key: liste[i].key, name: liste[i].name, vorlage: liste[i].key, gesundheit: false })
})
assert.deepEqual(BRANCHEN.at(-1), { key: 'gesundheit', name: 'Gesundheitsbereich', vorlage: null, gesundheit: true })
})
test('BRANCHEN: jede Vorlage steht in VORLAGEN, alle neun Vorlagen kommen genau einmal vor', () => {
const mitVorlage = BRANCHEN.filter((b) => b.vorlage !== null)
assert.ok(mitVorlage.every((b) => Object.hasOwn(VORLAGEN, b.vorlage)))
assert.equal(Object.keys(VORLAGEN).length, 9)
assert.deepEqual(mitVorlage.map((b) => b.vorlage).sort(), Object.keys(VORLAGEN).sort())
assert.equal(new Set(mitVorlage.map((b) => b.vorlage)).size, 9)
assert.equal(new Set(BRANCHEN.map((b) => b.key)).size, BRANCHEN.length, 'Schluessel eindeutig')
})
test('BRANCHEN: genau ein Eintrag mit gesundheit true und ohne Vorlage; sonstiges bleibt waehlbar (D6)', () => {
const gesundheit = BRANCHEN.filter((b) => b.gesundheit)
assert.equal(gesundheit.length, 1)
assert.equal(gesundheit[0].vorlage, null)
assert.equal(gesundheit[0].key, 'gesundheit')
assert.equal(gesundheit[0].name, 'Gesundheitsbereich')
assert.ok(BRANCHEN.filter((b) => b.vorlage === null).every((b) => b.gesundheit), 'ohne Vorlage nur der Gesundheitsbereich')
const sonstiges = BRANCHEN.find((b) => b.key === 'sonstiges')
assert.deepEqual(sonstiges, { key: 'sonstiges', name: VORLAGEN.sonstiges.name, vorlage: 'sonstiges', gesundheit: false })
assert.equal(ok({ branche: 'sonstiges' }).branche, 'sonstiges')
})
test('BRANCHEN: jeder Eintrag hat genau die vier Felder, die Liste ist unveraenderlich', () => {
for (const branche of BRANCHEN) {
assert.deepEqual(Object.keys(branche), ['key', 'name', 'vorlage', 'gesundheit'])
assert.ok(typeof branche.name === 'string' && branche.name.length > 2)
assert.equal(typeof branche.gesundheit, 'boolean')
}
assert.ok(Object.isFrozen(BRANCHEN))
assert.ok(BRANCHEN.every((b) => Object.isFrozen(b)))
})
test('FRAGEBOGEN_MAX ist 16000', () => {
assert.equal(FRAGEBOGEN_MAX, 16000)
})
// ---------------------------------------------------------------- leerFragebogen
test('leerFragebogen: Grundform, bei jedem Aufruf ein neues Objekt', () => {
assert.deepEqual(leerFragebogen(), {
schema: 1,
branche: null,
bestaetigt: [],
bundeslandGewaehlt: false,
preiseGeprueft: false,
einbau: { modus: null, knopfText: '', stelle: '', gestaltung: '', bemerkung: '' },
})
const a = leerFragebogen()
a.bestaetigt.push('wer')
a.einbau.stelle = 'x'
assert.deepEqual(leerFragebogen().bestaetigt, [])
assert.equal(leerFragebogen().einbau.stelle, '')
})
// ---------------------------------------------------------------- normalisiereFragebogen
test('normalisiere: {} ergibt leerFragebogen()', () => {
assert.deepEqual(norm({}), { fragebogen: leerFragebogen() })
})
test('normalisiere: Nicht-Objekt ergibt fehler._ und kein fragebogen', () => {
for (const eingabe of [null, undefined, 'text', 42, true, [], [{}]]) {
const ergebnis = norm(eingabe)
assert.equal(typeof ergebnis.fehler._, 'string', `Eingabe ${JSON.stringify(eingabe)}`)
assert.deepEqual(Object.keys(ergebnis.fehler), ['_'])
assert.equal('fragebogen' in ergebnis, false)
}
})
test('normalisiere: unbekannte Schluessel und __proto__ fliegen raus, ohne den Prototyp anzufassen', () => {
const eingabe = JSON.parse(
'{"__proto__":{"vergiftet":1},"fremd":"x","constructor":{"prototype":{"vergiftet":2}},'
+ '"einbau":{"__proto__":{"vergiftet":3},"fremd":"y","stelle":"Startseite"}}',
)
const fragebogen = ok(eingabe)
assert.deepEqual(fragebogen, { ...leerFragebogen(), einbau: { ...leerFragebogen().einbau, stelle: 'Startseite' } })
assert.deepEqual(Object.keys(fragebogen), ['schema', 'branche', 'bestaetigt', 'bundeslandGewaehlt', 'preiseGeprueft', 'einbau'])
assert.deepEqual(Object.keys(fragebogen.einbau), ['modus', 'knopfText', 'stelle', 'gestaltung', 'bemerkung'])
assert.equal(Object.hasOwn(fragebogen, '__proto__'), false)
assert.equal(Object.getPrototypeOf(fragebogen), Object.prototype)
assert.equal({}.vergiftet, undefined)
assert.equal(fragebogen.vergiftet, undefined)
})
test('normalisiere: schema ist immer 1, egal was kommt', () => {
assert.equal(ok({ schema: 7 }).schema, 1)
assert.equal(ok({ schema: 'x' }).schema, 1)
})
test('normalisiere: bestaetigt in Reihenfolge der Schritte, ohne Doppelte und Fremde', () => {
assert.deepEqual(ok({ bestaetigt: ['website', 'branche', 'branche', 'quatsch'] }).bestaetigt, ['branche', 'website'])
assert.deepEqual(ok({ bestaetigt: [...SCHRITTE].reverse() }).bestaetigt, SCHRITTE)
assert.deepEqual(ok({ bestaetigt: [1, null, {}, ['wer'], 'wer'] }).bestaetigt, ['wer'])
assert.deepEqual(ok({ bestaetigt: [] }).bestaetigt, [])
assert.deepEqual(ok({ bestaetigt: null }).bestaetigt, [])
assert.deepEqual(ok({ bestaetigt: ['__proto__', 'constructor', 'toString'] }).bestaetigt, [])
})
test('normalisiere: bestaetigt, das keine Liste ist, ergibt fehler.bestaetigt', () => {
for (const wert of ['branche', 5, true, { 0: 'branche' }]) {
const ergebnis = norm({ bestaetigt: wert })
assert.deepEqual(Object.keys(ergebnis.fehler), ['bestaetigt'], JSON.stringify(wert))
}
})
test('normalisiere: branche nur aus BRANCHEN oder null', () => {
for (const branche of BRANCHEN) assert.equal(ok({ branche: branche.key }).branche, branche.key)
assert.equal(ok({ branche: 'gesundheit' }).branche, 'gesundheit')
assert.equal(ok({ branche: null }).branche, null)
assert.equal(ok({}).branche, null)
for (const wert of ['zahnarzt', '', 'Salon', 'SALON', 'constructor', '__proto__', 7, ['salon'], {}]) {
assert.deepEqual(Object.keys(norm({ branche: wert }).fehler ?? {}), ['branche'], JSON.stringify(wert))
}
})
test('normalisiere: knopfText mit 41 Zeichen wird auf 40 gekuerzt, einzeilig', () => {
assert.equal(ok({ einbau: { knopfText: 'x'.repeat(41) } }).einbau.knopfText, 'x'.repeat(40))
assert.equal(ok({ einbau: { knopfText: 'x'.repeat(40) } }).einbau.knopfText, 'x'.repeat(40))
assert.equal(ok({ einbau: { knopfText: '😀'.repeat(41) } }).einbau.knopfText, '😀'.repeat(40), 'Codepoints, nicht UTF-16-Einheiten')
assert.equal(ok({ einbau: { knopfText: ' Jetzt\nTermin\tbuchen ' } }).einbau.knopfText, 'Jetzt Termin buchen')
})
test('normalisiere: Textlaengen stelle 300, gestaltung 1000, bemerkung 1000', () => {
const einbau = ok({ einbau: { stelle: 'a'.repeat(301), gestaltung: 'b'.repeat(1001), bemerkung: 'c'.repeat(1001) } }).einbau
assert.equal(einbau.stelle, 'a'.repeat(300))
assert.equal(einbau.gestaltung, 'b'.repeat(1000))
assert.equal(einbau.bemerkung, 'c'.repeat(1000))
})
test('normalisiere: Steuerzeichen verschwinden, Absaetze bleiben (stelle, gestaltung, bemerkung)', () => {
const roh = 'Farbe\u0000 dunkel‮, rund\r\n\r\nZweiter Absatz​\n\n\n\nDritter'
const erwartet = 'Farbe dunkel, rund\n\nZweiter Absatz\n\nDritter'
const einbau = ok({ einbau: { stelle: roh, gestaltung: roh, bemerkung: roh } }).einbau
assert.equal(einbau.gestaltung, erwartet)
assert.equal(einbau.stelle, erwartet)
assert.equal(einbau.bemerkung, erwartet)
assert.ok(!/[\u0000‮​\r]/.test(einbau.gestaltung))
})
test('normalisiere: Texte werden getrimmt, ein Text nur aus Steuerzeichen wird leer', () => {
const einbau = ok({ einbau: { knopfText: '\u0000‮', stelle: ' ', gestaltung: '\n\n', bemerkung: ' x ' } }).einbau
assert.deepEqual(einbau, { modus: null, knopfText: '', stelle: '', gestaltung: '', bemerkung: 'x' })
})
test('normalisiere: Textfelder, die keine Zeichenkette sind, ergeben einen Fehler am Pfad', () => {
for (const feld of ['knopfText', 'stelle', 'gestaltung', 'bemerkung']) {
for (const wert of [5, true, ['a'], {}]) {
assert.deepEqual(Object.keys(norm({ einbau: { [feld]: wert } }).fehler ?? {}), [`einbau.${feld}`], `${feld}=${JSON.stringify(wert)}`)
}
assert.equal(ok({ einbau: { [feld]: null } }).einbau[feld], '')
}
})
test('normalisiere: einbau.modus nur inline, knopf oder null; popup ergibt fehler[einbau.modus]', () => {
assert.equal(ok({ einbau: { modus: 'inline' } }).einbau.modus, 'inline')
assert.equal(ok({ einbau: { modus: 'knopf' } }).einbau.modus, 'knopf')
assert.equal(ok({ einbau: { modus: null } }).einbau.modus, null)
assert.equal(ok({ einbau: {} }).einbau.modus, null)
const ergebnis = norm({ einbau: { modus: 'popup' } })
assert.deepEqual(Object.keys(ergebnis.fehler), ['einbau.modus'])
assert.equal(typeof ergebnis.fehler['einbau.modus'], 'string')
assert.equal('fragebogen' in ergebnis, false)
for (const wert of ['', 'INLINE', 1, ['inline'], {}]) {
assert.deepEqual(Object.keys(norm({ einbau: { modus: wert } }).fehler ?? {}), ['einbau.modus'], JSON.stringify(wert))
}
})
test('normalisiere: einbau, das kein Objekt ist, ergibt fehler.einbau', () => {
for (const wert of ['inline', 5, [], true]) {
assert.deepEqual(Object.keys(norm({ einbau: wert }).fehler ?? {}), ['einbau'], JSON.stringify(wert))
}
assert.deepEqual(ok({ einbau: null }).einbau, leerFragebogen().einbau)
})
test('normalisiere: bundeslandGewaehlt und preiseGeprueft nur als Wahrheitswert; ja ergibt Fehler', () => {
assert.equal(ok({ bundeslandGewaehlt: true }).bundeslandGewaehlt, true)
assert.equal(ok({ preiseGeprueft: true }).preiseGeprueft, true)
assert.equal(ok({ bundeslandGewaehlt: false, preiseGeprueft: false }).bundeslandGewaehlt, false)
assert.equal(ok({ bundeslandGewaehlt: null }).bundeslandGewaehlt, false)
assert.deepEqual(Object.keys(norm({ bundeslandGewaehlt: 'ja' }).fehler), ['bundeslandGewaehlt'])
assert.deepEqual(Object.keys(norm({ preiseGeprueft: 1 }).fehler), ['preiseGeprueft'])
for (const wert of ['true', 'ja', 0, 1, [], {}]) {
assert.deepEqual(Object.keys(norm({ bundeslandGewaehlt: wert }).fehler ?? {}), ['bundeslandGewaehlt'], JSON.stringify(wert))
}
})
test('normalisiere: mehrere Fehler zugleich, jeder am eigenen Pfad', () => {
const ergebnis = norm({ branche: 'x', bestaetigt: 3, bundeslandGewaehlt: 'ja', preiseGeprueft: 'nein', einbau: { modus: 'popup', stelle: 4 } })
assert.deepEqual(Object.keys(ergebnis.fehler).sort(), ['bestaetigt', 'branche', 'bundeslandGewaehlt', 'einbau.modus', 'einbau.stelle', 'preiseGeprueft'])
assert.ok(Object.values(ergebnis.fehler).every((text) => typeof text === 'string' && text.length > 5))
})
test('normalisiere: das Ergebnis teilt nichts mit der Eingabe', () => {
const eingabe = { bestaetigt: ['wer'], einbau: { stelle: 'oben' } }
const fragebogen = ok(eingabe)
eingabe.bestaetigt.push('zeiten')
eingabe.einbau.stelle = 'unten'
assert.deepEqual(fragebogen.bestaetigt, ['wer'])
assert.equal(fragebogen.einbau.stelle, 'oben')
assert.notEqual(fragebogen.einbau, eingabe.einbau)
})
test('normalisiere: voll ausgefuellter Fragebogen bleibt unveraendert', () => {
const voll = {
schema: 1,
branche: 'salon',
bestaetigt: ['branche', 'wer', 'leistungen', 'zeiten', 'regeln', 'gastfelder', 'benachrichtigung', 'website'],
bundeslandGewaehlt: true,
preiseGeprueft: true,
einbau: { modus: 'knopf', knopfText: 'Termin buchen', stelle: 'Unterseite Kontakt', gestaltung: 'Lila Knopf\n\nRund', bemerkung: 'Bitte vor 10 Uhr' },
}
assert.deepEqual(norm(voll), { fragebogen: voll })
})
test('normalisiere: idempotent', () => {
const eingaben = [
{},
{ branche: 'gesundheit', bestaetigt: ['website', 'wer', 'wer', 'x'], einbau: { modus: 'inline', knopfText: 'y'.repeat(60), stelle: ' a\u0000\n\n\n\nb ' } },
{ schema: 3, fremd: 1, bundeslandGewaehlt: true, einbau: { gestaltung: '😀'.repeat(1200), bemerkung: '\r\nzwei\r\n\r\nAbsätze‮' } },
JSON.parse('{"__proto__":{"a":1},"branche":"werkstatt"}'),
]
for (const eingabe of eingaben) {
const einmal = ok(eingabe)
assert.deepEqual(norm(einmal), { fragebogen: einmal })
}
})
test('normalisiere: groesste gueltige Eingabe bleibt unter FRAGEBOGEN_MAX (auch mit Zeichen, die JSON verdoppelt)', () => {
// Anfuehrungszeichen werden als \" gespeichert, Absatzumbrueche als \n\n: ungünstigster Fall fuer die Zeichenzahl
const gross = (zeichen, max) => zeichen.repeat(max)
for (const zeichen of ['"', '\\', '😀', 'ä', 'a\n\n']) {
const fragebogen = ok({
branche: 'werkstatt',
bestaetigt: SCHRITTE,
bundeslandGewaehlt: true,
preiseGeprueft: true,
einbau: {
modus: 'knopf',
knopfText: gross(zeichen, 40),
stelle: gross(zeichen, 300),
gestaltung: gross(zeichen, 1000),
bemerkung: gross(zeichen, 1000),
},
})
const json = JSON.stringify(fragebogen)
assert.ok(json.length < FRAGEBOGEN_MAX, `${JSON.stringify(zeichen)}: ${json.length} Zeichen`)
assert.ok(Buffer.byteLength(json, 'utf8') < FRAGEBOGEN_MAX, `${JSON.stringify(zeichen)}: ${Buffer.byteLength(json, 'utf8')} Bytes`)
}
})
// ---------------------------------------------------------------- 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: '<b>Praxis</b> Therapie' }, leistungen: [{ name: langesWort, beschreibung: 'Rezept <script>' }], gastFelder: { zusatz: [{ label: 'Diagnose?', optionen: ['Beschwerden', 'Nein'] }] } })
assert.ok(treffer.length >= 4)
for (const t of treffer) {
assert.equal(typeof t, 'string')
assert.ok(t.length <= 200, `${t.length}: ${t}`)
assert.doesNotMatch(t, /[<>]/, 'Markup bleibt aussen vor')
}
assert.ok(treffer.some((t) => /Betriebsname/.test(t)))
assert.ok(treffer.some((t) => /Leistung/.test(t)))
assert.ok(treffer.some((t) => /Zusatzfeld/.test(t)))
})
test('Stichwortpruefung: reine Funktion – Eingabe unveraendert, bei jedem Aufruf ein neues Array', () => {
const e = mitName('Physiotherapie')
const vorher = structuredClone(e)
const a = gesundheitsWarnungen(e)
assert.deepEqual(e, vorher)
assert.notEqual(a, gesundheitsWarnungen(e))
assert.deepEqual(a, gesundheitsWarnungen(e))
})
// ---------------------------------------------------------------- bestellungAnsicht
test('bestellungAnsicht: Abbildung von oeffentlicheAnsicht – Preis, Hinweis, Einrichtung, Zustimmungen unveraendert', async () => {
const ansicht = await bestellungAnsicht(T)
const reg = oeffentlicheAnsicht('termine')
assert.deepEqual(Object.keys(ansicht).sort(), ['belehrung', 'consent', 'dokumente', 'einrichtung', 'preis', 'preisHinweis'])
assert.equal(ansicht.preis, reg.priceLabel)
assert.equal(ansicht.preis, '149 €')
assert.equal(ansicht.preisHinweis, config.ustHinweis)
assert.equal(ansicht.einrichtung, reg.setupEstimate)
assert.deepEqual(ansicht.consent, reg.consent)
assert.equal(ansicht.consent.knopf, T.consent.knopf, 'der Knopf wörtlich aus dem Register')
assert.deepEqual(ansicht.consent.items, T.consent.items.map((i) => ({ id: i.id, text: i.text, haken: i.haken === true })))
assert.ok(ansicht.consent.items.every((i) => typeof i.haken === 'boolean'), 'haken-Flag je Eintrag')
assert.equal(ansicht.consent.version, T.consent.version)
})
test('bestellungAnsicht: belehrung ist das Ergebnis des Hakens (derselbe Text, den der Nachweis speichert)', async () => {
const ansicht = await bestellungAnsicht(T)
assert.equal(ansicht.belehrung, await featureHaken('termine').belehrung())
assert.equal(ansicht.belehrung, BELEHRUNG_PLATZHALTER)
// ein Test-Haken im Register ueberschreibt den Standard
T.belehrung = async () => 'Belehrung Test'
try {
assert.equal((await bestellungAnsicht(T)).belehrung, 'Belehrung Test')
} finally {
delete T.belehrung
}
})
test('bestellungAnsicht: dokumente nur mit gesetzter url, mit id, titel, version und url', async () => {
const merke = { lb: T.leistungsbeschreibung, avv: T.avv, widerruf: T.widerruf }
try {
T.leistungsbeschreibung = { version: '2026-10-05', url: '/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.html' }
T.avv = { version: '2026-10-05', url: '' }
T.widerruf = null
let ansicht = await bestellungAnsicht(T)
assert.deepEqual(ansicht.dokumente, [{ id: 'leistungsbeschreibung', titel: 'Leistungsbeschreibung', version: '2026-10-05', url: '/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.html' }])
T.avv = { version: '2026-10-05', url: '/rechtliches/avv-terminplaner-2026-10-05.html' }
T.widerruf = { version: '2026-10-05', url: '/rechtliches/widerruf-terminplaner-2026-10-05.html' }
ansicht = await bestellungAnsicht(T)
assert.deepEqual(ansicht.dokumente.map((d) => d.id), ['leistungsbeschreibung', 'avv', 'widerruf'])
for (const d of ansicht.dokumente) {
assert.deepEqual(Object.keys(d), ['id', 'titel', 'version', 'url'])
assert.ok(d.titel.length > 3 && d.url.startsWith('/rechtliches/') && d.version === '2026-10-05')
}
assert.ok(!('pflichtinfo' in ansicht) && !('widerruf' in ansicht), 'keine Felder pflichtinfo/widerruf')
} finally {
T.leistungsbeschreibung = merke.lb
T.avv = merke.avv
T.widerruf = merke.widerruf
}
})
test('bestellungAnsicht: Register ohne Rechtsunterlagen (Entwurf, url leer) ergibt eine leere Liste', async () => {
const merke = { lb: T.leistungsbeschreibung, avv: T.avv, widerruf: T.widerruf }
try {
T.leistungsbeschreibung = { version: 'ENTWURF', url: '' }
T.avv = { version: 'ENTWURF' }
T.widerruf = null
assert.deepEqual((await bestellungAnsicht(T)).dokumente, [])
} finally {
T.leistungsbeschreibung = merke.lb
T.avv = merke.avv
T.widerruf = merke.widerruf
}
})
test('bestellungAnsicht: unbekannte Funktion ergibt null, ebenso fehlender Eintrag', async () => {
assert.equal(await bestellungAnsicht({ key: 'analyse' }), null)
assert.equal(await bestellungAnsicht({ key: '__proto__' }), null)
assert.equal(await bestellungAnsicht({ key: 'nope', label: { name: 'x' } }), null)
assert.equal(await bestellungAnsicht(null), null)
assert.equal(await bestellungAnsicht(undefined), null)
assert.equal(await bestellungAnsicht({}), null)
})
test('bestellungAnsicht: enthaelt nie interne Felder (ticket, mails, Funktionen) und ist JSON-sicher', async () => {
const ansicht = await bestellungAnsicht(T)
const json = JSON.stringify(ansicht)
for (const verboten of ['ticket', 'mails', 'topic', 'details', 'formen', 'returnHash', 'product', 'requiresWartungsAbo', 'disabledMessage']) {
assert.ok(!json.includes(`"${verboten}"`), `kein Feld ${verboten}`)
}
assert.deepEqual(JSON.parse(json), ansicht)
// eine Kopie: Aenderungen an der Ansicht aendern das Register nicht
ansicht.consent.items[0].text = 'geaendert'
ansicht.consent.knopf = 'geaendert'
assert.notEqual(T.consent.items[0].text, 'geaendert')
assert.notEqual(T.consent.knopf, 'geaendert')
})
test('bestellungAnsicht: News (ohne belehrung-Haken) ergibt belehrung null und den News-Preis', async () => {
const ansicht = await bestellungAnsicht(config.features.news)
assert.equal(ansicht.belehrung, null)
assert.equal(ansicht.preis, config.features.news.priceLabel)
})
test('Der Stub ist weg: keine Marke STUB im Kopf von fragebogen.js', async () => {
const { readFile } = await import('node:fs/promises')
const quelle = await readFile(new URL('../server/services/termine/fragebogen.js', import.meta.url), 'utf8')
assert.doesNotMatch(quelle, /STUB/)
})