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>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 19:00:12 +00:00
1 parent ab47fabcb2
commit 008ad4fb1e
7 files changed
+869 -47

No files matched your search

+2 -2
View File
@@ -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)))
}
+202 -8
View File
@@ -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,
}
}
+1 -1
View File
@@ -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 () => []
})
+73 -8
View File
@@ -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 () => []
}
+59
View File
@@ -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
}
+532 -2
View File
@@ -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: '<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/)
})
-26
View File
@@ -1,26 +0,0 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
// Platzhalter der Bestellpruefung (server/services/termine/fragebogen.js, Block B): bestellbar ist alles, bis
// Block C die echte Pruefung liefert. Block C loescht diese Datei zusammen mit dem Stub (Task C2).
const { bestellpruefung } = await import('../server/services/termine/fragebogen.js')
test('Stub: bestellpruefung liefert [] fuer jede Eingabe, mit und ohne Abo-Punkt', () => {
assert.deepEqual(bestellpruefung({ einstellung: null, fragebogen: null, feature: { key: 'termine' } }), [])
assert.deepEqual(bestellpruefung({ einstellung: {}, fragebogen: {}, feature: {}, abo: { ok: false, grund: 'kein_abo' } }), [])
assert.deepEqual(bestellpruefung({ einstellung: {}, fragebogen: {}, feature: {}, mitAbo: false }), [])
assert.deepEqual(bestellpruefung({}), [])
assert.ok(Array.isArray(bestellpruefung({})), 'ein Array, kein Promise')
})
test('Stub: jeder Aufruf liefert ein neues Array (Aufrufer duerfen es veraendern)', () => {
const eins = bestellpruefung({})
eins.push({ schritt: 'x', text: 'y' })
assert.deepEqual(bestellpruefung({}), [])
})
test('Stub traegt die Marke im Kopfkommentar (Block C ersetzt die Datei, Abschlusspruefung Block B zaehlt sie)', async () => {
const quelle = await readFile(new URL('../server/services/termine/fragebogen.js', import.meta.url), 'utf8')
assert.match(quelle, /^\/\/ STUB \(Block B\) – ersetzt Task C/)
})