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:
1 parent
ab47fabcb2
commit
008ad4fb1e
7 files changed
+869
-47
No files matched your search
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 () => []
|
||||
})
|
||||
|
||||
|
||||
@@ -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 () => []
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
Reference in new issue
Block a user