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