Files
Webklar-Kundenbereich/test/termineFragebogenView.test.js
T
ClaudeandClaude Sonnet 5.5 20583424a3 Funktionen: Bestellseite und Statuskarten
Terminplaner TP2 C8: public/funktion-bestellen.js mit sicherLink, bestellKoerper,
fortsetzenKoerper, renderBestellen (Zusammenfassung, offene Punkte, Preis,
vollstaendige Widerrufsbelehrung als scrollbarer Text, Unterlagen, Zustimmungs-Haken,
Knopf mit bestellung.consent.knopf) und statusKarte (ordered/setup, Danke).
request() in termine-dom.js behaelt den ganzen Body der Fehlerantwort (err.body),
damit 409 bestellung_offen und fassung_veraltet offen/consent zeigen koennen.

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

1525 lines
94 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync, readdirSync } from 'node:fs'
import * as ansicht from '../public/termine-view.js'
import {
FRAGEBOGEN_SCHRITTE, LIMITS, MAX, buchungsartFuerAlle, buchungsartLage, ersterOffenerSchritt, ressourcenAusNamen, schrittStatus, zusammenfassung,
} from '../public/termine-view.js'
import { BRANCHEN, SCHRITTE, SCHRITT_TITEL, bestellungAnsicht, leerFragebogen } from '../server/services/termine/fragebogen.js'
import { featureConfig } from '../server/services/featureRegistry.js'
import { bestellbarerStand } from './support/fragebogenStand.js'
import { normalisiereEinstellung } from '../server/services/termine/einstellungen.js'
import { ausVorlage } from '../server/services/termine/vorlagen.js'
import { installFakeDom } from './support/fakeDom.js'
// Namensraeume statt benannter Importe: fehlt ein Export, scheitert nur der Test, der ihn braucht (nicht die ganze Datei)
import * as einstellungenTab from '../public/termine-tab-einstellungen.js'
import * as teamTab from '../public/termine-tab-team.js'
import * as huelle from '../public/termine.js'
import * as bestellen from '../public/funktion-bestellen.js'
import { FENSTER_FEHLER } from '../public/termine-view.js'
import { h, request } from '../public/termine-dom.js'
import { neuerSpeicher } from '../public/termine-speichern.js'
// Reine Helfer des Fragebogens in public/termine-view.js (TP2, Task C7a): Schrittstand, Namen der Personen/Plaetze, sofort/Anfrage fuer alle
// und die Zusammenfassung auf der Bestellseite. Kein DOM; der Spiegel zum Server (SCHRITTE) steht in test/termineView.test.js.
const JETZT = new Date('2026-10-05T08:00:00.000Z')
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',
}
const salon = () => ausVorlage('salon', BETRIEB, JETZT)
/** Zaehlende neueId: r_test0001, r_test0002 … (bekommt die belegten IDs, braucht sie hier nicht). */
const zaehler = () => {
let n = 0
return () => `r_test${String(++n).padStart(4, '0')}`
}
/** Zwei Ressourcen mit verschiedenen Plaenen, wie sie ein Entwurf haben kann. */
const zweiRessourcen = () => [
{ id: 'r_anna0001', typ: 'person', name: 'Anna', aktiv: true, plan: { 1: [['09:00', '12:00']] }, feedToken: 'a'.repeat(24) },
{ id: 'r_ben00002', typ: 'person', name: 'Ben', aktiv: true, plan: { 2: [['13:00', '17:00']] }, feedToken: 'b'.repeat(24) },
]
const namenVon = (ressourcen) => ressourcen.map((r) => r.name)
const idsVon = (ressourcen) => ressourcen.map((r) => r.id)
// ---------------------------------------------------------------- Schritte
test('FRAGEBOGEN_SCHRITTE: acht Paare [Schlüssel, Titel], Schlüssel und Titel wie beim Server', () => {
assert.equal(FRAGEBOGEN_SCHRITTE.length, 8)
for (const eintrag of FRAGEBOGEN_SCHRITTE) {
assert.equal(eintrag.length, 2)
assert.equal(typeof eintrag[0], 'string')
assert.equal(typeof eintrag[1], 'string')
assert.ok(eintrag[1].length > 0)
}
assert.deepEqual(FRAGEBOGEN_SCHRITTE.map(([key]) => key), [...SCHRITTE])
assert.deepEqual(FRAGEBOGEN_SCHRITTE.map(([, titel]) => titel), SCHRITTE.map((key) => SCHRITT_TITEL[key]))
})
test('schrittStatus: leerer Fragebogen ⇒ nichts erledigt, Reihenfolge und Titel wie FRAGEBOGEN_SCHRITTE', () => {
const erwartet = FRAGEBOGEN_SCHRITTE.map(([key, titel]) => ({ key, titel, erledigt: false }))
assert.deepEqual(schrittStatus(leerFragebogen()), erwartet)
assert.deepEqual(schrittStatus(null), erwartet, 'noch kein Fragebogen gespeichert')
assert.deepEqual(schrittStatus(undefined), erwartet)
assert.deepEqual(schrittStatus({ bestaetigt: 'wer' }), erwartet, 'kein Array ⇒ nichts erledigt')
assert.deepEqual(schrittStatus({}), erwartet)
})
test('schrittStatus: teilweise bestätigt, Fremdes zählt nicht', () => {
const status = schrittStatus({ bestaetigt: ['zeiten', 'branche', 'wer', 'bestellen', 'unbekannt'] })
assert.deepEqual(status.map((s) => s.key), [...SCHRITTE])
assert.deepEqual(status.filter((s) => s.erledigt).map((s) => s.key), ['branche', 'wer', 'zeiten'])
assert.equal(status.length, 8, '„bestellen“ ist kein Schritt des Fragebogens')
})
test('schrittStatus: vollständig bestätigt ⇒ alles erledigt', () => {
assert.ok(schrittStatus({ bestaetigt: [...SCHRITTE] }).every((s) => s.erledigt === true))
})
test('ersterOffenerSchritt: leer ⇒ branche, teilweise ⇒ der erste Schritt ohne Haken, vollständig ⇒ bestellen', () => {
assert.equal(ersterOffenerSchritt(leerFragebogen()), 'branche')
assert.equal(ersterOffenerSchritt(null), 'branche')
assert.equal(ersterOffenerSchritt(undefined), 'branche')
assert.equal(ersterOffenerSchritt({ bestaetigt: ['branche', 'wer'] }), 'leistungen')
assert.equal(ersterOffenerSchritt({ bestaetigt: ['branche', 'leistungen'] }), 'wer', 'die erste Lücke zählt, nicht die Zahl der Haken')
assert.equal(ersterOffenerSchritt({ bestaetigt: SCHRITTE.slice(0, 7) }), 'website')
assert.equal(ersterOffenerSchritt({ bestaetigt: [...SCHRITTE] }), 'bestellen')
})
// ---------------------------------------------------------------- ressourcenAusNamen
test('ressourcenAusNamen: „Stylist/in“ → „Anna“ behält ID und Plan, der zweite Name bekommt neue ID und den Plan von Anna', () => {
const bisher = salon().ressourcen
assert.equal(bisher.length, 1)
assert.equal(bisher[0].name, 'Stylist/in')
const erg = ressourcenAusNamen({ bisher, typ: 'person', namen: ['Anna', 'Ben'], neueId: zaehler() })
assert.deepEqual(namenVon(erg.ressourcen), ['Anna', 'Ben'])
const [anna, ben] = erg.ressourcen
assert.equal(anna.id, bisher[0].id, 'Anna behält die ID')
assert.deepEqual(anna.plan, bisher[0].plan, 'und den Plan')
assert.equal(ben.id, 'r_test0001')
assert.deepEqual(ben.plan, anna.plan, 'Ben bekommt den Plan von Anna …')
assert.notEqual(ben.plan, anna.plan, '… als Kopie')
const laenge = anna.plan[2].length
ben.plan[2].push(['20:00', '21:00'])
assert.equal(anna.plan[2].length, laenge, 'Änderungen an Ben verändern Anna nicht')
assert.equal(bisher[0].plan[2].length, laenge, 'und nicht den alten Eintrag')
assert.deepEqual(erg.neu, ['r_test0001'])
assert.deepEqual(erg.entfernt, [])
assert.ok(erg.ressourcen.every((r) => r.aktiv === true && r.typ === 'person'))
})
test('ressourcenAusNamen: gleiche Namen behalten ID und Plan, auch in anderer Reihenfolge und Schreibweise', () => {
const bisher = zweiRessourcen()
const erg = ressourcenAusNamen({ bisher, typ: 'person', namen: ['ben', 'Anna'], neueId: zaehler() })
assert.deepEqual(idsVon(erg.ressourcen), ['r_ben00002', 'r_anna0001'], 'Reihenfolge der Namen, IDs bleiben')
assert.deepEqual(namenVon(erg.ressourcen), ['ben', 'Anna'], 'die eingegebene Schreibweise gilt')
assert.deepEqual(erg.ressourcen[0].plan, bisher[1].plan)
assert.deepEqual(erg.ressourcen[1].plan, bisher[0].plan)
assert.deepEqual(erg.neu, [])
assert.deepEqual(erg.entfernt, [])
})
test('ressourcenAusNamen: Entfernen liefert die IDs der weggefallenen Einträge', () => {
const bisher = [...zweiRessourcen(), { id: 'r_cem00003', typ: 'person', name: 'Cem', aktiv: true, plan: {} }]
const erg = ressourcenAusNamen({ bisher, typ: 'person', namen: ['Ben'], neueId: zaehler() })
assert.deepEqual(idsVon(erg.ressourcen), ['r_ben00002'])
assert.deepEqual(erg.entfernt, ['r_anna0001', 'r_cem00003'])
assert.deepEqual(erg.neu, [])
const alle = ressourcenAusNamen({ bisher, typ: 'person', namen: [], neueId: zaehler() })
assert.deepEqual(alle.ressourcen, [])
assert.deepEqual(alle.entfernt, ['r_anna0001', 'r_ben00002', 'r_cem00003'])
})
test('ressourcenAusNamen: übrige alte Einträge werden der Reihe nach umbenannt', () => {
const bisher = zweiRessourcen()
const umbenannt = ressourcenAusNamen({ bisher, typ: 'person', namen: ['Xaver', 'Yvonne'], neueId: zaehler() })
assert.deepEqual(idsVon(umbenannt.ressourcen), ['r_anna0001', 'r_ben00002'])
assert.deepEqual(umbenannt.ressourcen.map((r) => r.plan), [bisher[0].plan, bisher[1].plan])
assert.deepEqual([umbenannt.neu, umbenannt.entfernt], [[], []])
// Ben bleibt, Dora übernimmt Annas Platz in der Liste, Cem fällt weg
const mitCem = [...bisher, { id: 'r_cem00003', typ: 'person', name: 'Cem', aktiv: true, plan: { 3: [['08:00', '09:00']] } }]
const gemischt = ressourcenAusNamen({ bisher: mitCem, typ: 'person', namen: ['Ben', 'Dora'], neueId: zaehler() })
assert.deepEqual(namenVon(gemischt.ressourcen), ['Ben', 'Dora'])
assert.deepEqual(idsVon(gemischt.ressourcen), ['r_ben00002', 'r_anna0001'])
assert.deepEqual(gemischt.ressourcen[1].plan, bisher[0].plan)
assert.deepEqual(gemischt.entfernt, ['r_cem00003'])
assert.deepEqual(gemischt.neu, [])
})
test('ressourcenAusNamen: ein neuer Name vor einem behaltenen bekommt trotzdem dessen Plan (Vorlage unabhängig von der Reihenfolge)', () => {
const bisher = [{ id: 'r_anna0001', typ: 'person', name: 'Anna', aktiv: true, plan: { 1: [['09:00', '12:00']] } }]
const erg = ressourcenAusNamen({ bisher, typ: 'person', namen: ['Ben', 'Anna'], neueId: zaehler() })
assert.deepEqual(namenVon(erg.ressourcen), ['Ben', 'Anna'])
const [ben, anna] = erg.ressourcen
assert.equal(anna.id, 'r_anna0001', 'Anna behält ihre ID')
assert.deepEqual(anna.plan, { 1: [['09:00', '12:00']] }, 'und ihren Plan')
assert.equal(ben.id, 'r_test0001')
assert.deepEqual(ben.plan, { 1: [['09:00', '12:00']] }, 'Ben bekommt Annas Plan, nicht einen leeren')
assert.notEqual(ben.plan, anna.plan, 'als Kopie')
ben.plan[1].push(['14:00', '16:00'])
assert.equal(anna.plan[1].length, 1, 'Änderungen an Ben verändern Anna nicht')
assert.equal(bisher[0].plan[1].length, 1, 'und nicht den alten Eintrag')
assert.deepEqual(erg.neu, ['r_test0001'])
assert.deepEqual(erg.entfernt, [])
// mehrere neue Namen vor dem behaltenen, und ein umbenannter alter Eintrag liefert die Vorlage ebenso
const drei = ressourcenAusNamen({ bisher, typ: 'person', namen: ['Ben', 'Cem', 'Anna'], neueId: zaehler() })
assert.deepEqual(drei.ressourcen.map((r) => r.plan), [bisher[0].plan, bisher[0].plan, bisher[0].plan])
const umbenannt = ressourcenAusNamen({ bisher, typ: 'person', namen: ['Ben', 'Cem'], neueId: zaehler() })
assert.deepEqual(idsVon(umbenannt.ressourcen), ['r_anna0001', 'r_test0001'], 'Anna wird zu Ben, Cem ist neu')
assert.deepEqual(umbenannt.ressourcen.map((r) => r.plan), [bisher[0].plan, bisher[0].plan])
// ohne bisherigen Eintrag gibt es keine Vorlage: leerer Plan
assert.deepEqual(ressourcenAusNamen({ bisher: [], typ: 'person', namen: ['Ben', 'Cem'], neueId: zaehler() }).ressourcen.map((r) => r.plan), [{}, {}])
})
test('ressourcenAusNamen: trimmt, entfernt Leere und Doppelte ohne Rücksicht auf Groß-/Kleinschreibung', () => {
const erg = ressourcenAusNamen({ bisher: [], typ: 'person', namen: [' Anna ', '', ' ', 'anna', 'ANNA', 'Ben', ' Ben', null, undefined, 5], neueId: zaehler() })
assert.deepEqual(namenVon(erg.ressourcen), ['Anna', 'Ben'], 'die erste Schreibweise gilt')
assert.equal(idsVon(erg.ressourcen).length, 2)
assert.deepEqual(namenVon(ressourcenAusNamen({ bisher: [], typ: 'person', namen: ['Anna Müller', 'anna müller'] }).ressourcen), ['Anna Müller'], 'mehrfache Leerzeichen zählen als eines (wie beim Server)')
})
test('ressourcenAusNamen: höchstens LIMITS.ressourcen Einträge', () => {
const namen = Array.from({ length: LIMITS.ressourcen + 5 }, (_, i) => `Person ${i + 1}`)
const erg = ressourcenAusNamen({ bisher: [], typ: 'person', namen, neueId: zaehler() })
assert.equal(LIMITS.ressourcen, 20)
assert.equal(erg.ressourcen.length, 20)
assert.deepEqual(namenVon(erg.ressourcen), namen.slice(0, 20), 'die ersten 20 bleiben')
assert.equal(erg.neu.length, 20)
// Leere und Doppelte zählen nicht mit
const mitLuecken = ['', ...namen.slice(0, 20).flatMap((n) => [n, n.toUpperCase()]), 'Überzählig']
assert.deepEqual(namenVon(ressourcenAusNamen({ bisher: [], typ: 'person', namen: mitLuecken }).ressourcen), namen.slice(0, 20))
// genau LIMITS.ressourcen bleibt ganz
assert.equal(ressourcenAusNamen({ bisher: [], typ: 'person', namen: namen.slice(0, 20) }).ressourcen.length, 20)
})
test('ressourcenAusNamen: Namen werden auf MAX.ressourcenName Zeichen gekürzt', () => {
const erg = ressourcenAusNamen({ bisher: [], typ: 'person', namen: ['x'.repeat(MAX.ressourcenName + 20)] })
assert.equal([...erg.ressourcen[0].name].length, MAX.ressourcenName)
})
test('ressourcenAusNamen: typ gilt für alle, auch für behaltene Einträge', () => {
const bisher = zweiRessourcen()
const erg = ressourcenAusNamen({ bisher, typ: 'platz', namen: ['Anna', 'Platz 2', 'Platz 3'], neueId: zaehler() })
assert.ok(erg.ressourcen.every((r) => r.typ === 'platz'))
assert.equal(erg.ressourcen[0].id, 'r_anna0001')
assert.deepEqual(bisher.map((r) => r.typ), ['person', 'person'], 'bisher bleibt unverändert')
assert.ok(ressourcenAusNamen({ bisher, typ: 'person', namen: ['A'] }).ressourcen.every((r) => r.typ === 'person'))
assert.equal(ressourcenAusNamen({ bisher: [], typ: 'unsinn', namen: ['A'] }).ressourcen[0].typ, 'person', 'unbekannter Typ ⇒ Person')
})
test('ressourcenAusNamen: ändert die Eingaben nicht, liefert nur Felder, die der Server kennt (kein feedToken)', () => {
const bisher = zweiRessourcen()
const vorher = JSON.stringify(bisher)
const namen = ['Ben', ' Anna ', 'Cem']
const namenVorher = [...namen]
const erg = ressourcenAusNamen({ bisher, typ: 'platz', namen, neueId: zaehler() })
assert.equal(JSON.stringify(bisher), vorher)
assert.deepEqual(namen, namenVorher)
for (const r of erg.ressourcen) assert.deepEqual(Object.keys(r).sort(), ['aktiv', 'id', 'name', 'plan', 'typ'])
assert.ok(!JSON.stringify(erg).includes('feedToken'))
assert.deepEqual(ressourcenAusNamen({ bisher: null, typ: 'person', namen: ['A'], neueId: zaehler() }).entfernt, [], 'ohne bisher: nichts zu entfernen')
assert.deepEqual(ressourcenAusNamen({ bisher, typ: 'person', namen: 'Anna', neueId: zaehler() }).ressourcen, [], 'keine Liste ⇒ keine Namen')
})
test('ressourcenAusNamen: neue IDs sind gültig und kollidieren nicht (Vorgabe ohne neueId, Zähler ohne Treffer)', () => {
const bisher = zweiRessourcen()
const namen = Array.from({ length: 15 }, (_, i) => `Neu ${i}`)
const erg = ressourcenAusNamen({ bisher, typ: 'person', namen })
const ids = idsVon(erg.ressourcen)
assert.equal(new Set(ids).size, ids.length)
assert.ok(ids.every((id) => /^r_[a-z0-9]{8}$/.test(id)))
assert.equal(erg.neu.length, 13, 'zwei der Namen übernehmen die alten Einträge')
assert.ok(erg.neu.every((id) => ids.includes(id) && !bisher.some((r) => r.id === id)))
// neueId bekommt die belegten IDs (Set) und darf nur unbelegte liefern
const gesehen = []
ressourcenAusNamen({ bisher, typ: 'person', namen: ['Anna', 'Ben', 'Cem', 'Dora'], neueId: (belegt) => { gesehen.push([...belegt]); return `r_zzzz000${gesehen.length}` } })
assert.equal(gesehen.length, 2)
assert.ok(gesehen[0].includes('r_anna0001') && gesehen[0].includes('r_ben00002'), 'behaltene IDs sind belegt')
assert.ok(gesehen[1].includes('r_zzzz0001'), 'und die schon vergebenen neuen')
// liefert neueId eine belegte ID, ist das ein Fehler (der Server würde doppelte IDs ablehnen)
assert.throws(() => ressourcenAusNamen({ bisher, typ: 'person', namen: ['Anna', 'Ben', 'Cem'], neueId: () => 'r_anna0001' }), /ID/)
})
test('ressourcenAusNamen: das Ergebnis besteht normalisiereEinstellung (Leistungen allen zugeordnet), für Personen und Plätze', () => {
for (const [typ, namen] of [['person', ['Anna', 'Ben', 'Cem']], ['platz', ['Platz 1', 'Platz 2']], ['person', ['Nur Ich']]]) {
const e = salon()
const erg = ressourcenAusNamen({ bisher: e.ressourcen, typ, namen })
e.ressourcen = erg.ressourcen
e.leistungen = e.leistungen.map((l) => ({ ...l, ressourcen: idsVon(erg.ressourcen) }))
const geprueft = normalisiereEinstellung(e, { now: JETZT })
assert.equal(geprueft.fehler, undefined, `${typ}: ${JSON.stringify(geprueft.fehler)}`)
assert.deepEqual(namenVon(geprueft.einstellung.ressourcen), namen)
assert.ok(geprueft.einstellung.ressourcen.every((r) => r.typ === typ && r.aktiv === true))
assert.ok(geprueft.einstellung.ressourcen.every((r) => typeof r.feedToken === 'string' && r.feedToken.length > 0), 'der Server vergibt die Feed-Token')
}
})
// ---------------------------------------------------------------- Buchungsart
test('buchungsartFuerAlle: ändert nur buchungsart, an einer Kopie', () => {
const leistungen = salon().leistungen
const vorher = JSON.stringify(leistungen)
const anfrage = buchungsartFuerAlle(leistungen, 'anfrage')
assert.equal(JSON.stringify(leistungen), vorher, 'Eingabe unverändert')
assert.equal(anfrage.length, leistungen.length)
assert.ok(anfrage.every((l) => l.buchungsart === 'anfrage'))
assert.deepEqual(anfrage.map(({ buchungsart, ...rest }) => rest), leistungen.map(({ buchungsart, ...rest }) => rest), 'alles andere bleibt')
assert.notEqual(anfrage[0], leistungen[0])
const zurueck = buchungsartFuerAlle(anfrage, 'sofort')
assert.deepEqual(zurueck, leistungen)
})
test('buchungsartFuerAlle: inaktive Leistungen auch, unbekannte Art und Nicht-Liste ändern nichts', () => {
const leistungen = [{ id: 's_aaaaaaaa', name: 'A', aktiv: false, buchungsart: 'sofort' }, { id: 's_bbbbbbbb', name: 'B', aktiv: true, buchungsart: 'anfrage' }]
assert.deepEqual(buchungsartFuerAlle(leistungen, 'anfrage').map((l) => l.buchungsart), ['anfrage', 'anfrage'])
assert.deepEqual(buchungsartFuerAlle(leistungen, 'bar'), leistungen)
assert.deepEqual(buchungsartFuerAlle(leistungen, undefined), leistungen)
assert.deepEqual(buchungsartFuerAlle(null, 'sofort'), [])
assert.deepEqual(buchungsartFuerAlle(undefined, 'sofort'), [])
})
test('buchungsartLage: sofort, anfrage oder gemischt', () => {
const l = (...arten) => arten.map((buchungsart, i) => ({ id: `s_${i}`, buchungsart }))
assert.equal(buchungsartLage(l('sofort', 'sofort')), 'sofort')
assert.equal(buchungsartLage(l('anfrage', 'anfrage', 'anfrage')), 'anfrage')
assert.equal(buchungsartLage(l('sofort', 'anfrage')), 'gemischt')
assert.equal(buchungsartLage(l('anfrage')), 'anfrage')
assert.equal(buchungsartLage([]), 'sofort', 'ohne Leistungen gilt die Vorgabe des Servers')
assert.equal(buchungsartLage(null), 'sofort')
assert.equal(buchungsartLage([{ name: 'ohne Angabe' }, { buchungsart: 'anfrage' }]), 'gemischt', 'fehlende Angabe = sofort (Vorgabe des Servers)')
assert.equal(buchungsartLage(ausVorlage('handwerk', BETRIEB, JETZT).leistungen), 'anfrage', 'Vorlage mit Anfragen')
assert.equal(buchungsartLage(salon().leistungen), 'sofort')
})
test('buchungsartLage nach buchungsartFuerAlle: zeigt genau die gesetzte Art', () => {
const leistungen = [{ buchungsart: 'sofort' }, { buchungsart: 'anfrage' }]
assert.equal(buchungsartLage(leistungen), 'gemischt')
assert.equal(buchungsartLage(buchungsartFuerAlle(leistungen, 'anfrage')), 'anfrage')
assert.equal(buchungsartLage(buchungsartFuerAlle(leistungen, 'sofort')), 'sofort')
})
// ---------------------------------------------------------------- zusammenfassung
const stand = (aendere = () => {}) => {
const einstellung = salon()
const fragebogen = { ...leerFragebogen(), branche: 'salon', bestaetigt: [...SCHRITTE], bundeslandGewaehlt: true, preiseGeprueft: true, einbau: { ...leerFragebogen().einbau, modus: 'inline' } }
aendere(einstellung, fragebogen)
return { einstellung, fragebogen }
}
const gruppe = (ergebnis, schritt) => ergebnis.find((g) => g.schritt === schritt)
test('zusammenfassung: acht Gruppen in der Reihenfolge der Schritte, nur Text', () => {
const { einstellung, fragebogen } = stand()
const erg = zusammenfassung(einstellung, fragebogen, BRANCHEN)
assert.deepEqual(erg.map((g) => g.schritt), [...SCHRITTE])
assert.deepEqual(erg.map((g) => g.titel), FRAGEBOGEN_SCHRITTE.map(([, titel]) => titel))
for (const g of erg) {
assert.deepEqual(Object.keys(g).sort(), ['schritt', 'titel', 'zeilen'])
assert.ok(Array.isArray(g.zeilen) && g.zeilen.length > 0, `${g.schritt}: Zeilen`)
assert.ok(g.zeilen.every((z) => typeof z === 'string' && z.trim() !== ''), `${g.schritt}: nur nicht-leere Texte`)
}
})
test('zusammenfassung (Vorlage salon): Name, Branche und Anschrift', () => {
const { einstellung, fragebogen } = stand()
const zeilen = gruppe(zusammenfassung(einstellung, fragebogen, BRANCHEN), 'branche').zeilen
assert.ok(zeilen.includes('Branche: Friseur und Salon'))
assert.ok(zeilen.includes('Betrieb: Salon Muster'))
assert.ok(zeilen.includes('Anschrift: Hauptstraße 1, 67655 Kaiserslautern'))
})
test('zusammenfassung (Vorlage salon): Leistungen mit Preis', () => {
const { einstellung, fragebogen } = stand()
const zeilen = gruppe(zusammenfassung(einstellung, fragebogen, BRANCHEN), 'leistungen').zeilen
assert.ok(zeilen.includes('Herrenschnitt: 30 Min., 28,00 €'), zeilen.join(' | '))
assert.ok(zeilen.includes('Damenschnitt: 45 Min., ab 45,00 €'))
assert.ok(zeilen.includes('Kinderschnitt: 30 Min., 18,00 €'))
assert.ok(zeilen.includes('Färben: 120 Min., ab 75,00 €'))
assert.ok(zeilen.includes('Preise geprüft: ja'))
const offen = stand((e, f) => { f.preiseGeprueft = false })
assert.ok(gruppe(zusammenfassung(offen.einstellung, offen.fragebogen, BRANCHEN), 'leistungen').zeilen.includes('Preise geprüft: nein'))
const ohnePreis = stand((e) => { e.leistungen[0] = { ...e.leistungen[0], preisArt: 'keiner', preisCent: 0, aktiv: false } })
const z = gruppe(zusammenfassung(ohnePreis.einstellung, ohnePreis.fragebogen, BRANCHEN), 'leistungen').zeilen
assert.ok(z.includes('Herrenschnitt: 30 Min., ohne Preis (nicht aktiv)'), z.join(' | '))
})
test('zusammenfassung (Vorlage salon): Wochenplan, Tage mit gleichen Zeiten zusammengefasst, Ruhetage', () => {
const { einstellung, fragebogen } = stand()
const zeilen = gruppe(zusammenfassung(einstellung, fragebogen, BRANCHEN), 'zeiten').zeilen
assert.ok(zeilen.includes('Dienstag–Freitag: 09:00–12:30, 13:30–18:00'), zeilen.join(' | '))
assert.ok(zeilen.includes('Samstag: 09:00–13:00'))
assert.ok(zeilen.includes('Geschlossen: Montag, Sonntag'))
const mehrere = stand((e) => { e.ressourcen = [{ ...e.ressourcen[0], name: 'Anna' }, { ...e.ressourcen[0], id: 'r_zweite01', name: 'Ben', plan: { 1: [['10:00', '11:00']] } }] })
const z = gruppe(zusammenfassung(mehrere.einstellung, mehrere.fragebogen, BRANCHEN), 'zeiten').zeilen
assert.ok(z.includes('Anna: Dienstag–Freitag: 09:00–12:30, 13:30–18:00'), z.join(' | '))
assert.ok(z.includes('Ben: Montag: 10:00–11:00'))
assert.ok(z.includes('Ben: Geschlossen: Dienstag–Sonntag'))
})
test('zusammenfassung: wer wird gebucht, Zeitraum-Angaben (Feiertage, Urlaub, besondere Tage)', () => {
const { einstellung, fragebogen } = stand((e) => {
e.ressourcen = [{ id: 'r_aaaaaaaa', typ: 'person', name: 'Anna', aktiv: true, plan: {} }, { id: 'r_bbbbbbbb', typ: 'person', name: 'Ben', aktiv: false, plan: {} }]
e.abwesenheiten = [{ id: 'a_aaaaaaaa', ressourceId: null, von: '2027-01-01', bis: '2027-01-02', titel: '' }]
e.sonderoeffnungen = { '2026-12-24': [['09:00', '12:00']], '2026-12-31': [] }
e.feiertageGeschlossen = true
})
const erg = zusammenfassung(einstellung, fragebogen, BRANCHEN)
assert.deepEqual(gruppe(erg, 'wer').zeilen, ['Personen: Anna, Ben (nicht aktiv)'])
const zeiten = gruppe(erg, 'zeiten').zeilen
assert.ok(zeiten.includes('Feiertage: geschlossen'))
assert.ok(zeiten.includes('Abwesenheiten: 1 eingetragen'))
assert.ok(zeiten.includes('Besondere Öffnungstage: 2'))
const plaetze = stand((e) => { e.ressourcen = [{ id: 'r_aaaaaaaa', typ: 'platz', name: 'Platz 1', aktiv: true, plan: {} }, { id: 'r_cccccccc', typ: 'person', name: 'Cem', aktiv: true, plan: {} }] })
assert.deepEqual(gruppe(zusammenfassung(plaetze.einstellung, plaetze.fragebogen, BRANCHEN), 'wer').zeilen, ['Plätze: Platz 1', 'Personen: Cem'])
})
test('zusammenfassung: Bundesland nur, wenn der Kunde es ausdrücklich gewählt hat (der Serverstandard RP zählt nicht)', () => {
const nichtGewaehlt = stand((e, f) => { f.bundeslandGewaehlt = false })
assert.equal(nichtGewaehlt.einstellung.bundesland, 'RP', 'Serverstandard')
const ohne = JSON.stringify(zusammenfassung(nichtGewaehlt.einstellung, nichtGewaehlt.fragebogen, BRANCHEN))
assert.ok(!ohne.includes('Rheinland-Pfalz') && !ohne.includes('Bundesland'), ohne)
const gewaehlt = stand((e) => { e.bundesland = 'HE' })
const zeilen = gruppe(zusammenfassung(gewaehlt.einstellung, gewaehlt.fragebogen, BRANCHEN), 'zeiten').zeilen
assert.ok(zeilen.includes('Bundesland: Hessen'), zeilen.join(' | '))
const rp = stand()
assert.ok(gruppe(zusammenfassung(rp.einstellung, rp.fragebogen, BRANCHEN), 'zeiten').zeilen.includes('Bundesland: Rheinland-Pfalz'), 'RP ausdrücklich gewählt')
})
test('zusammenfassung: Regeln, Gastfelder, Benachrichtigung und Website', () => {
const { einstellung, fragebogen } = stand((e, f) => {
e.gastFelder = { telefon: 'pflicht', nachricht: 'optional', zusatz: [{ key: 'kennzeichen', label: 'Kennzeichen', typ: 'text', optionen: [], pflicht: true }, { key: 'wahl', label: 'Wunsch', typ: 'auswahl', optionen: ['a'], pflicht: false }] }
e.anrede = 'du'
e.erinnerung = { anbieten: true, stundenVorher: 12 }
e.betrieb.websiteUrl = 'https://salon.example.invalid'
f.einbau = { modus: 'knopf', knopfText: 'Termin buchen', stelle: 'Unter dem Text auf der Startseite', gestaltung: 'Dunkelblau', bemerkung: 'Bitte vor dem Wochenende' }
})
const erg = zusammenfassung(einstellung, fragebogen, BRANCHEN)
const regeln = gruppe(erg, 'regeln').zeilen
assert.ok(regeln.includes('Buchungsart: sofort buchbar'), regeln.join(' | '))
assert.ok(regeln.includes('Raster: 15 Minuten'))
assert.ok(regeln.includes('Vorlaufzeit: 2 Stunden'))
assert.ok(regeln.includes('Buchungszeitraum: 60 Tage im Voraus'))
assert.ok(regeln.includes('Absage durch Gäste: bis 24 Stunden vorher'))
assert.ok(regeln.includes('Deine Gäste sind: Privatpersonen'))
assert.ok(regeln.includes('Zahlung: vor Ort (bar, EC-Karte, Kreditkarte)'))
const gast = gruppe(erg, 'gastfelder').zeilen
assert.ok(gast.includes('Telefonnummer des Gastes: Pflicht') && gast.includes('Nachricht des Gastes: Optional'), gast.join(' | '))
assert.ok(gast.includes('Zusatzfeld „Kennzeichen“: Pflicht') && gast.includes('Zusatzfeld „Wunsch“: Optional (Auswahl)'))
const benachrichtigung = gruppe(erg, 'benachrichtigung').zeilen
assert.ok(benachrichtigung.includes('Benachrichtigungen an: salon@example.invalid'), benachrichtigung.join(' | '))
assert.ok(benachrichtigung.includes('Anrede der Gäste: Du (persönlich)'))
assert.ok(benachrichtigung.includes('E-Mail-Erinnerung für Gäste: angeboten, 12 Stunden vorher'))
const website = gruppe(erg, 'website').zeilen
assert.ok(website.includes('Telefon: 06301 12345'), website.join(' | '))
assert.ok(website.includes('Impressum: https://salon.example.invalid/impressum'))
assert.ok(website.includes('Datenschutzerklärung: https://salon.example.invalid/datenschutz'))
assert.ok(website.includes('Website: https://salon.example.invalid'))
assert.ok(website.includes('Darstellung: hinter einem Knopf „Termin buchen“'))
assert.ok(website.includes('Stelle: Unter dem Text auf der Startseite'))
assert.ok(website.includes('Gestaltungswunsch: Dunkelblau'))
assert.ok(website.includes('Bemerkung: Bitte vor dem Wochenende'))
const inline = stand()
assert.ok(gruppe(zusammenfassung(inline.einstellung, inline.fragebogen, BRANCHEN), 'website').zeilen.includes('Darstellung: direkt auf deiner Seite'))
})
test('zusammenfassung: Anfrage, gemischte Gäste, Freizeitangebote, keine Erinnerung', () => {
const { einstellung, fragebogen } = stand((e) => {
e.leistungen = e.leistungen.map((l, i) => ({ ...l, buchungsart: i === 0 ? 'anfrage' : 'sofort' }))
e.recht = { gaesteSind: 'gemischt', freizeit: true }
e.erinnerung = { anbieten: false, stundenVorher: 24 }
})
const erg = zusammenfassung(einstellung, fragebogen, BRANCHEN)
const regeln = gruppe(erg, 'regeln').zeilen
assert.ok(regeln.includes('Buchungsart: gemischt, je Leistung'), regeln.join(' | '))
assert.ok(regeln.includes('Deine Gäste sind: Privatpersonen und Unternehmen'))
assert.ok(regeln.includes('Leistungen sind Freizeitangebote mit festem Termin'))
assert.ok(gruppe(erg, 'benachrichtigung').zeilen.includes('E-Mail-Erinnerung für Gäste: aus'))
const anfrage = stand((e) => { e.leistungen = ausVorlage('handwerk', BETRIEB, JETZT).leistungen })
assert.ok(gruppe(zusammenfassung(anfrage.einstellung, anfrage.fragebogen, BRANCHEN), 'regeln').zeilen.includes('Buchungsart: auf Anfrage'))
})
test('zusammenfassung: nie feedToken, nie Zeichen aus der Einstellung außerhalb der genannten Felder', () => {
const { einstellung, fragebogen } = stand()
assert.ok(einstellung.ressourcen.every((r) => typeof r.feedToken === 'string' && r.feedToken.length > 0), 'die Einstellung trägt Token')
const text = JSON.stringify(zusammenfassung(einstellung, fragebogen, BRANCHEN))
assert.ok(!text.includes('feedToken'))
for (const r of einstellung.ressourcen) assert.ok(!text.includes(r.feedToken))
assert.ok(!text.includes(einstellung.ressourcen[0].id), 'auch keine IDs')
})
test('zusammenfassung: Kundentext bleibt Text (nichts entschärft, nichts verändert), lange Wünsche werden gekürzt', () => {
const frech = '<img src=x onerror=alert(1)> & "Anführung"'
const { einstellung, fragebogen } = stand((e, f) => {
e.betrieb.name = frech
f.einbau = { modus: 'inline', knopfText: '', stelle: '', gestaltung: 'g'.repeat(1000), bemerkung: '' }
})
const erg = zusammenfassung(einstellung, fragebogen, BRANCHEN)
assert.ok(gruppe(erg, 'branche').zeilen.includes(`Betrieb: ${frech}`))
const wunsch = gruppe(erg, 'website').zeilen.find((z) => z.startsWith('Gestaltungswunsch: '))
assert.ok(wunsch.length <= 'Gestaltungswunsch: '.length + 200, 'gekürzt')
assert.ok(wunsch.endsWith('…'))
})
test('zusammenfassung: ohne Einstellung und Fragebogen weiterhin acht Gruppen, ohne Fehler und ohne erfundene Angaben', () => {
for (const [e, f, b] of [[null, null, null], [undefined, undefined, undefined], [{}, {}, []], ['x', 5, 'y']]) {
const erg = zusammenfassung(e, f, b)
assert.deepEqual(erg.map((g) => g.schritt), [...SCHRITTE])
assert.ok(erg.every((g) => Array.isArray(g.zeilen) && g.zeilen.every((z) => typeof z === 'string')))
const text = JSON.stringify(erg)
assert.ok(!/undefined|null|NaN|Rheinland/.test(text), text)
}
const unbekannt = zusammenfassung(salon(), { ...leerFragebogen(), branche: 'unbekannt' }, BRANCHEN)
assert.ok(!gruppe(unbekannt, 'branche').zeilen.some((z) => z.startsWith('Branche:')), 'unbekannte Branche ⇒ keine Zeile')
})
test('Löschdatum und Abschalt-Texte gehören nicht in die Helfer des Fragebogens (E11: abschaltText)', () => {
assert.equal(ansicht.loeschDatum, undefined)
assert.equal(typeof ansicht.abschaltText, 'function')
})
test('termine-view.js bleibt rein: kein DOM, kein Netz, nur der Import der Anrede-Auswahl', () => {
const quelle = readFileSync(new URL('../public/termine-view.js', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '')
assert.doesNotMatch(quelle, /\b(document|window|navigator|fetch|XMLHttpRequest|localStorage|sessionStorage)\b/)
assert.deepEqual(quelle.match(/^import .*$/gm), ["import { ANREDE_WAHL } from './termine-gaestetexte.js'"])
})
// ------------------------------------------------------- Fake-DOM (Testhilfe, TP2 C7b)
// test/support/fakeDom.js tragen auch C8, C9a, C9b und C10b: die Tests hier halten fest, worauf sie sich verlassen.
/** Fake-DOM fuer die Dauer von fn aufbauen (und immer abbauen). */
const mitDom = (optionen, fn) => async () => {
const fx = installFakeDom(optionen)
try {
await fn(fx)
} finally {
fx.restore()
}
}
/** Namen (data-feld) aller Formularfelder im Baum, in Dokumentreihenfolge. */
const felderVon = (fx, baum) => fx.alle(baum, (n) => typeof n.dataset.feld === 'string').map((n) => n.dataset.feld)
test('Fake-DOM: globale Objekte nur zwischen installFakeDom und restore', () => {
const vorher = { document: globalThis.document, fetch: globalThis.fetch, CSS: globalThis.CSS, window: globalThis.window }
const fx = installFakeDom()
assert.equal(globalThis.document, fx.document)
assert.notEqual(globalThis.fetch, vorher.fetch)
assert.equal(globalThis.CSS.escape('leistungen.2.dauer'), 'leistungen\\.2\\.dauer')
fx.restore()
assert.deepEqual({ document: globalThis.document, fetch: globalThis.fetch, CSS: globalThis.CSS, window: globalThis.window }, vorher)
})
test('Fake-DOM: Baum, Attribute, Eigenschaften und isConnected', mitDom(undefined, (fx) => {
const knopf = h('button', { type: 'button', class: 'kb-btn tm-x', 'data-aktion': 'weiter', hidden: true }, 'Weiter ', h('b', null, 'jetzt'), 5)
assert.equal(knopf.tagName, 'BUTTON')
assert.equal(knopf.className, 'kb-btn tm-x')
assert.equal(knopf.dataset.aktion, 'weiter')
assert.equal(knopf.getAttribute('data-aktion'), 'weiter')
assert.equal(knopf.hidden, true)
assert.equal(fx.text(knopf), 'Weiter jetzt5')
assert.equal(knopf.children.length, 1, 'children: nur Elemente')
assert.equal(knopf.childNodes.length, 3)
const wurzel = h('div', null, knopf)
assert.equal(knopf.isConnected, false)
document.body.append(wurzel)
assert.equal(knopf.isConnected, true)
wurzel.replaceChildren('neu')
assert.equal(knopf.isConnected, false)
assert.equal(knopf.parentNode, null)
wurzel.textContent = ''
assert.equal(wurzel.childNodes.length, 0)
}))
test('Fake-DOM: Selektoren wie die Client-Dateien sie brauchen', mitDom(undefined, (fx) => {
const zeile = h('li', { class: 'tm-eintrag', 'data-id': 'r_abc' }, h('button', { 'data-aktion': 'bearbeiten' }, 'Bearbeiten'), h('button', { disabled: true }, 'Aus'))
const feld = h('p', { 'data-fehler': 'leistungen.2.dauer' })
const sperre = h('fieldset', { disabled: true }, h('input', { type: 'text' }))
const tafel = h('div', { role: 'tabpanel' }, h('ul', null, zeile), feld, sperre)
document.body.append(tafel)
assert.equal(tafel.querySelector('li[data-id="r_abc"] [data-aktion="bearbeiten"]').textContent, 'Bearbeiten')
assert.equal(tafel.querySelector(`[data-fehler="${CSS.escape('leistungen.2.dauer')}"]`), feld)
assert.deepEqual(zeile.querySelectorAll('button:not(:disabled)').map((n) => n.textContent), ['Bearbeiten'])
assert.equal(zeile.querySelectorAll('button:disabled').length, 1)
assert.equal(sperre.querySelector('input:not(:disabled)'), null, 'fieldset disabled sperrt die Felder darin')
assert.equal(zeile.closest('[role="tabpanel"]'), tafel)
assert.equal(zeile.closest('form'), null)
assert.deepEqual(fx.alle(tafel, 'li, p').map((n) => n.tagName), ['LI', 'P'])
assert.equal(fx.finde(tafel, (n) => n.tagName === 'FIELDSET'), sperre)
assert.equal(tafel.querySelector('ul > li'), zeile)
assert.throws(() => tafel.querySelector('a:hover'), /nicht unterstuetzt/)
}))
test('Fake-DOM: value, checked und selected wie im Browser', mitDom(undefined, async (fx) => {
const auswahl = h('select', null, h('option', { value: '' }, '– bitte wählen –'), h('option', { value: 'RP' }, 'Rheinland-Pfalz'), h('option', { value: 'SL', selected: true }, 'Saarland'))
assert.equal(auswahl.value, 'SL', 'selected-Attribut gilt')
const ohneWahl = h('select', null, h('option', { value: '' }, '–'), h('option', { value: 'RP' }, 'RP'))
assert.equal(ohneWahl.value, '', 'ohne selected zeigt der Browser die erste Option')
await fx.setze(ohneWahl, 'RP')
assert.equal(ohneWahl.value, 'RP')
const text = h('input', { type: 'text', value: 'alt' })
assert.equal(text.value, 'alt')
await fx.setze(text, 'neu')
assert.equal(text.value, 'neu')
assert.equal(text.getAttribute('value'), 'alt', 'die Eigenschaft ändert das Attribut nicht')
const haken = h('input', { type: 'checkbox', checked: true })
assert.equal(haken.checked, true)
await fx.klick(haken)
assert.equal(haken.checked, false)
const area = h('textarea', null, 'Vorgabe')
assert.equal(area.value, 'Vorgabe')
const a = h('input', { type: 'radio', name: 'x', checked: true })
const b = h('input', { type: 'radio', name: 'x' })
h('div', null, a, b)
await fx.klick(b)
assert.deepEqual([a.checked, b.checked], [false, true], 'Radios einer Gruppe schließen sich aus')
}))
test('Fake-DOM: Ereignisse steigen auf, klick auf Absenden-Knopf sendet das Formular, gesperrte Knöpfe tun nichts, async-Handler werden abgewartet', mitDom(undefined, async (fx) => {
const log = []
const knopf = h('button', { type: 'submit' }, 'Weiter')
const aus = h('button', { type: 'submit', disabled: true }, 'Aus')
const lauf = h('button', { type: 'button' }, 'Lauf')
const form = h('form', null, knopf, aus, lauf)
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
log.push('submit')
await Promise.resolve()
await new Promise((r) => setImmediate(r))
log.push('submit fertig')
})
form.addEventListener('click', (ev) => log.push(`click ${ev.target.textContent} ${ev.currentTarget.tagName}`))
lauf.addEventListener('click', async () => { await new Promise((r) => setImmediate(r)); log.push('lauf fertig') })
document.body.append(form)
await fx.klick(aus)
assert.deepEqual(log, [], 'gesperrt: kein Ereignis')
await fx.klick(knopf)
assert.deepEqual(log, ['click Weiter FORM', 'submit', 'submit fertig'])
log.length = 0
await fx.klick(lauf)
assert.deepEqual(log, ['click Lauf FORM', 'lauf fertig'], 'kein submit bei type=button')
log.length = 0
form.addEventListener('keydown', (ev) => log.push(`${ev.type} ${ev.key}`))
await fx.sende(lauf, 'keydown', { key: 'ArrowRight' })
assert.deepEqual(log, ['keydown ArrowRight'])
}))
test('Fake-DOM: fetch schreibt { method, url, body } ins Protokoll und liefert die Antworten der Reihe nach', mitDom({ antworten: [{ status: 400, body: { error: 'Nein', fehler: { 'betrieb.telefon': 'fehlt' }, code: 'X' } }, { body: { ok: 1 } }] }, async (fx) => {
await assert.rejects(request('/api/termine/einstellungen', { method: 'PATCH', body: { projectId: 'p1', version: 3 } }), (err) => err.status === 400 && err.code === 'X' && err.fehler['betrieb.telefon'] === 'fehlt')
assert.deepEqual(await request('/api/termine/x?projectId=p1'), { ok: 1 })
assert.deepEqual(await request('/api/termine/y'), {}, 'ohne Eintrag: 200 mit {}')
assert.deepEqual(fx.fetchLog, [
{ method: 'PATCH', url: '/api/termine/einstellungen', body: { projectId: 'p1', version: 3 } },
{ method: 'GET', url: '/api/termine/x?projectId=p1', body: undefined },
{ method: 'GET', url: '/api/termine/y', body: undefined },
])
}))
// ------------------------------------------------------- betrieb(ctx, e, { nur, titel, hinweis })
const BETRIEB_FELDER = ['name', 'email', 'telefon', 'adresse', 'kleinunternehmer', 'impressumUrl', 'datenschutzUrl', 'agbUrl', 'websiteUrl']
const betriebCtx = (fehlt = []) => ({ daten: { fehlt }, nurLesen: false })
test('betrieb mit nur: [email] baut nur das Feld email, lese() liefert nur { email }', mitDom(undefined, async (fx) => {
const e = salon()
const { el, lese } = einstellungenTab.betrieb(betriebCtx(), e, { nur: ['email'] })
assert.deepEqual(felderVon(fx, el), ['betrieb.email'])
assert.equal(fx.alle(el, 'input').length, 1)
const eingabe = fx.finde(el, '[data-feld="betrieb.email"]')
assert.equal(eingabe.value, BETRIEB.email, 'vorbelegt aus der Einstellung')
await fx.setze(eingabe, 'neu@example.invalid')
const gelesen = lese()
assert.deepEqual(Object.keys(gelesen), ['email'], 'Schlüsselmenge')
assert.deepEqual(gelesen, { email: 'neu@example.invalid' })
// in den Entwurf geschrieben ändert sich nur email, alles andere bleibt
const entwurf = structuredClone(e)
lese(entwurf)
assert.deepEqual(entwurf.betrieb, { ...e.betrieb, email: 'neu@example.invalid' })
}))
test('betrieb mit nur: Teilmenge in der Reihenfolge der Seite, Haken und Adressen gelesen, Unbekanntes wirft', mitDom(undefined, async (fx) => {
const e = salon()
const { el, lese } = einstellungenTab.betrieb(betriebCtx(), e, { nur: ['websiteUrl', 'name', 'kleinunternehmer'] })
assert.deepEqual(felderVon(fx, el), ['betrieb.name', 'betrieb.kleinunternehmer', 'betrieb.websiteUrl'], 'Reihenfolge der Seite, nicht der Aufzählung')
await fx.klick(fx.finde(el, '[data-feld="betrieb.kleinunternehmer"]'))
await fx.setze(fx.finde(el, '[data-feld="betrieb.websiteUrl"]'), 'https://salon.example.invalid')
const gelesen = lese()
assert.deepEqual(Object.keys(gelesen).sort(), ['kleinunternehmer', 'name', 'websiteUrl'])
assert.equal(gelesen.kleinunternehmer, true)
assert.equal(gelesen.websiteUrl, 'https://salon.example.invalid')
assert.equal(gelesen.name, BETRIEB.name)
assert.throws(() => einstellungenTab.betrieb(betriebCtx(), e, { nur: ['email', 'steuernummer'] }), /steuernummer/)
}))
test('betrieb ohne nur baut weiter alle neun Felder und schreibt alle neun in den Entwurf', mitDom(undefined, async (fx) => {
const e = salon()
const { el, lese } = einstellungenTab.betrieb(betriebCtx(), e)
assert.deepEqual(felderVon(fx, el).sort(), BETRIEB_FELDER.map((n) => `betrieb.${n}`).sort())
assert.equal(fx.alle(el, 'input').length, 9)
assert.equal(fx.text(fx.finde(el, 'h3')), 'Dein Betrieb')
const entwurf = { betrieb: {} }
lese(entwurf)
assert.deepEqual(Object.keys(entwurf.betrieb).sort(), [...BETRIEB_FELDER].sort())
assert.equal(entwurf.betrieb.telefon, BETRIEB.telefon)
assert.equal(entwurf.betrieb.kleinunternehmer, false)
}))
test('betrieb: titel und hinweis ersetzen Überschrift und Erklärsatz', mitDom(undefined, (fx) => {
const e = salon()
const eigen = einstellungenTab.betrieb(betriebCtx(), e, { nur: ['email'], titel: 'Wohin sollen die Nachrichten?', hinweis: 'Eine Adresse für alles.' })
assert.equal(fx.text(fx.finde(eigen.el, 'h3')), 'Wohin sollen die Nachrichten?')
assert.equal(fx.text(fx.finde(eigen.el, '.tm-abschnitt-text')), 'Eine Adresse für alles.')
const ohneHinweis = einstellungenTab.betrieb(betriebCtx(), e, { nur: ['email'], hinweis: null })
assert.equal(fx.finde(ohneHinweis.el, '.tm-abschnitt-text'), null)
const standard = einstellungenTab.betrieb(betriebCtx(), e, { nur: ['email'] })
assert.equal(fx.text(fx.finde(standard.el, 'h3')), 'Dein Betrieb')
assert.match(fx.text(fx.finde(standard.el, '.tm-abschnitt-text')), /Diese Angaben sehen deine Gäste/)
}))
test('betrieb: der Hinweis auf fehlende Angaben nennt mit nur nur die Felder, die zu sehen sind', mitDom(undefined, (fx) => {
const e = salon()
const fehlt = ['betrieb.telefon', 'betrieb.adresse']
const alles = einstellungenTab.betrieb(betriebCtx(fehlt), e)
assert.match(fx.text(fx.finde(alles.el, '.tm-box-warn')), /deine Telefonnummer und deine Anschrift/, 'ohne nur wie bisher')
assert.equal(fx.finde(einstellungenTab.betrieb(betriebCtx(fehlt), e, { nur: ['email'] }).el, '.tm-box-warn'), null)
const nurTelefon = fx.text(fx.finde(einstellungenTab.betrieb(betriebCtx(fehlt), e, { nur: ['telefon'] }).el, '.tm-box-warn'))
assert.match(nurTelefon, /Es fehlt noch: deine Telefonnummer\.$/, 'die Anschrift steht nicht in der Liste dessen, was fehlt')
assert.equal(fx.finde(einstellungenTab.betrieb(betriebCtx([]), e).el, '.tm-box-warn'), null)
}))
// ------------------------------------------------------- wochenplanFeld(r, pfad)
test('wochenplanFeld: sieben Tagesfelder mit Namen aus dem Pfad, gefüllt aus dem Plan der Person', mitDom(undefined, (fx) => {
const r = { id: 'r_anna0001', plan: { 1: [['09:00', '12:30'], ['13:30', '18:00']], 6: [['09:00', '13:00']] } }
const { el } = teamTab.wochenplanFeld(r, 'ressourcen.2')
assert.deepEqual(felderVon(fx, el), [1, 2, 3, 4, 5, 6, 7].map((nr) => `ressourcen.2.plan.${nr}`))
assert.deepEqual(fx.alle(el, 'label').map((l) => fx.text(l)), ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'])
const eingaben = fx.alle(el, 'input')
assert.deepEqual(eingaben.map((i) => i.value), ['09:00–12:30, 13:30–18:00', '', '', '', '', '09:00–13:00', ''])
assert.ok(eingaben.every((i) => i.getAttribute('placeholder') === 'geschlossen'))
assert.equal(fx.text(fx.finde(el, 'legend')), 'Wochenplan')
assert.equal(fx.alle(el, '[data-fehler]').length, 7, 'je Tag ein Platz für den Fehler')
}))
test('wochenplanFeld: gültige Fenster ⇒ { plan } mit genau diesen Fenstern, ein leerer Tag ist geschlossen', mitDom(undefined, async (fx) => {
const { el, lese } = teamTab.wochenplanFeld({ id: 'r1', plan: {} }, 'ressourcen.0')
await fx.setze(fx.finde(el, '[data-feld="ressourcen.0.plan.1"]'), '09:00–12:30, 13:30–18:00')
await fx.setze(fx.finde(el, '[data-feld="ressourcen.0.plan.3"]'), '8:00-12:00')
const gelesen = lese()
assert.deepEqual(gelesen, { plan: { 1: [['09:00', '12:30'], ['13:30', '18:00']], 3: [['08:00', '12:00']] } })
assert.equal(gelesen.fehler, undefined)
assert.ok(!(2 in gelesen.plan) && !(7 in gelesen.plan), 'leerer Tag ⇒ geschlossen (kein Eintrag)')
// ein vorher offener Tag, der geleert wird, ist danach geschlossen
const offen = teamTab.wochenplanFeld({ id: 'r1', plan: { 1: [['09:00', '12:00']], 2: [['10:00', '11:00']] } }, 'ressourcen.0')
await fx.setze(fx.finde(offen.el, '[data-feld="ressourcen.0.plan.1"]'), '')
assert.deepEqual(offen.lese(), { plan: { 2: [['10:00', '11:00']] } })
assert.deepEqual(teamTab.wochenplanFeld(null, 'ressourcen.0').lese(), { plan: {} }, 'ohne Person: alles geschlossen')
}))
test('wochenplanFeld: ein ungültiges Fenster ⇒ { fehler } mit Text am Feld des Tages, kein Teilplan', mitDom(undefined, async (fx) => {
const { el, lese } = teamTab.wochenplanFeld({ id: 'r1', plan: {} }, 'ressourcen.1')
await fx.setze(fx.finde(el, '[data-feld="ressourcen.1.plan.1"]'), '09:00–12:00')
await fx.setze(fx.finde(el, '[data-feld="ressourcen.1.plan.2"]'), 'abc')
await fx.setze(fx.finde(el, '[data-feld="ressourcen.1.plan.5"]'), '09:00–09:00')
const gelesen = lese()
assert.deepEqual(gelesen, { fehler: { 'ressourcen.1.plan.2': FENSTER_FEHLER, 'ressourcen.1.plan.5': FENSTER_FEHLER } })
assert.equal(gelesen.plan, undefined)
assert.ok(typeof gelesen.fehler['ressourcen.1.plan.2'] === 'string' && gelesen.fehler['ressourcen.1.plan.2'].length > 0)
}))
test('Reiter Team & Plätze: der Editor einer Person speichert den Wochenplan aus wochenplanFeld und zeigt Fehler am Tag', mitDom(undefined, async (fx) => {
const einstellung = salon()
const anna = einstellung.ressourcen[0]
const ctx = {
s: {}, projectId: 'p1', daten: { einstellung, version: 4, feeds: {}, fehlt: [] }, nurLesen: false, auswahl: {}, aktuell: () => true,
api: { send: (method, pfad, body = {}) => request(`/api/termine${pfad}`, { method, body: { projectId: 'p1', ...body } }) },
meldung() {}, hinweise() {}, zumReiter() {}, neuLaden: async () => {},
}
const tafel = h('div', { role: 'tabpanel' })
document.body.append(tafel)
teamTab.render(tafel, ctx)
await fx.ruhe()
await fx.klick(fx.finde(tafel, `li[data-id="${anna.id}"] [data-aktion="bearbeiten"]`))
const form = fx.finde(tafel, (n) => n.tagName === 'FORM' && n.getAttribute('aria-label') === `Bearbeiten: ${anna.name}`)
assert.ok(form, 'Editor ist offen')
const speichern = fx.finde(form, (n) => n.tagName === 'BUTTON' && n.type === 'submit')
const montag = fx.finde(form, '[data-feld="ressourcen.0.plan.1"]')
// Fehler: nichts wird gesendet, der Text steht am Tag
await fx.setze(montag, 'abc')
await fx.klick(speichern)
assert.deepEqual(fx.fetchLog, [])
assert.equal(fx.text(fx.finde(form, '[data-fehler="ressourcen.0.plan.1"]')), FENSTER_FEHLER)
assert.equal(montag.getAttribute('aria-invalid'), 'true')
// gültig: PATCH mit dem Plan (alle anderen Tage unverändert)
await fx.setze(montag, '10:00-13:00')
fx.antworten.push({ body: { einstellung, version: 5, fehlt: [] } })
await fx.klick(speichern)
assert.equal(fx.fetchLog.length, 1)
const [aufruf] = fx.fetchLog
assert.deepEqual([aufruf.method, aufruf.url], ['PATCH', '/api/termine/einstellungen'])
assert.equal(aufruf.body.version, 4)
assert.deepEqual(aufruf.body.einstellung.ressourcen[0].plan[1], [['10:00', '13:00']])
assert.deepEqual(aufruf.body.einstellung.ressourcen[0].plan[2], anna.plan[2])
}))
// ------------------------------------------------------- termine-speichern.js: fehlt aus der Antwort
const speicherCtx = (einstellung, fehlt) => ({
aktuell: () => true,
daten: { einstellung, version: 3, fehlt },
api: { send: (method, pfad, body = {}) => request(`/api/termine${pfad}`, { method, body: { projectId: 'p1', ...body } }) },
hinweise() {}, meldung() {}, neuLaden: async () => {},
})
test('Speichern: antwortet der PATCH mit fehlt, steht es danach in den Daten des Reiters', mitDom({ antworten: [{ body: { einstellung: salon(), version: 4, fehlt: ['betrieb.telefon'] } }] }, async (fx) => {
const ctx = speicherCtx(salon(), ['betrieb.telefon', 'betrieb.adresse'])
const wurzel = h('div')
document.body.append(wurzel)
const sp = neuerSpeicher(ctx, wurzel, () => {})
assert.equal(await sp.speichere((entwurf) => { entwurf.betrieb.adresse = 'Hauptstraße 1' }, { container: wurzel }), true)
assert.deepEqual(ctx.daten.fehlt, ['betrieb.telefon'])
assert.equal(ctx.daten.version, 4)
assert.deepEqual(fx.fetchLog.map((a) => [a.method, a.url]), [['PATCH', '/api/termine/einstellungen']])
}))
test('Speichern: ohne fehlt in der Antwort ist es leer', mitDom({ antworten: [{ body: { einstellung: salon(), version: 4 } }] }, async () => {
const ctx = speicherCtx(salon(), ['betrieb.telefon'])
const wurzel = h('div')
document.body.append(wurzel)
const sp = neuerSpeicher(ctx, wurzel, () => {})
assert.equal(await sp.speichere(() => true, { container: wurzel }), true)
assert.deepEqual(ctx.daten.fehlt, [])
}))
// ------------------------------------------------------- termine.js: baueCtx
test('baueCtx: Schnittstelle, Daten und Meldungen wie der Reiter-Kontext', mitDom({ antworten: [{}, {}, {}, { body: { version: 9, einstellung: null } }, { body: { version: 11 } }] }, async (fx) => {
const s = { daten: { version: 3 }, nurLesen: false, meldungEl: h('div', { class: 'tm-meldung', tabindex: '-1' }) }
document.body.append(s.meldungEl)
let aktuell = true
let gezeichnet = 0
const ctx = huelle.baueCtx(s, { projekt: { projectId: 'p 1' }, aktuell: () => aktuell, hinweise: () => { gezeichnet += 1 } })
assert.equal(ctx.s, s)
assert.equal(ctx.projectId, 'p 1')
assert.equal(ctx.daten, s.daten)
assert.equal(ctx.nurLesen, false)
assert.equal(typeof ctx.auswahl, 'object')
assert.equal(ctx.aktuell(), true)
assert.equal(typeof ctx.zumReiter, 'function')
// Schnittstelle: GET und DELETE mit projectId in der Adresse, PATCH/POST/PUT mit projectId im Body
await ctx.api.get('/buchungen', { von: '2026-10-05' })
await ctx.api.send('DELETE', '/abwesenheiten/a1')
await ctx.api.send('PATCH', '/einstellungen', { version: 3 })
assert.deepEqual(fx.fetchLog.map((a) => [a.method, a.url, a.body]), [
['GET', '/api/termine/buchungen?projectId=p+1&von=2026-10-05', undefined],
['DELETE', '/api/termine/abwesenheiten/a1?projectId=p+1', undefined],
['PATCH', '/api/termine/einstellungen', { projectId: 'p 1', version: 3 }],
])
// Meldung nur solange die Ansicht aktuell ist; hinweise() zeichnet nur dann
ctx.meldung('Gespeichert.')
assert.equal(s.meldungEl.textContent, 'Gespeichert.')
ctx.hinweise()
assert.equal(gezeichnet, 1)
// neu laden: Daten und ctx.daten werden ersetzt und die Hinweise neu gezeichnet
const daten = await ctx.neuLaden()
assert.deepEqual(daten, { version: 9, einstellung: null })
assert.equal(s.daten, daten)
assert.equal(ctx.daten, daten)
assert.equal(gezeichnet, 2)
assert.equal(fx.fetchLog.at(-1).url, '/api/termine/einstellungen?projectId=p%201')
aktuell = false
ctx.meldung('Zu spät.')
ctx.hinweise()
await ctx.neuLaden()
assert.equal(s.meldungEl.textContent, 'Gespeichert.', 'eine veraltete Ansicht bekommt keine Meldung')
assert.equal(gezeichnet, 2)
assert.equal(ctx.daten, daten, 'und keine neuen Daten')
// ohne hinweise/zumReiter ist der Kontext trotzdem benutzbar (Fragebogen)
const schlicht = huelle.baueCtx(s, { projekt: { projectId: 'p1' }, aktuell: () => true })
schlicht.hinweise()
schlicht.zumReiter('team')
}))
// ------------------------------------------------------- request: ganzer Body des Fehlers (TP2, Task C8)
test('request: eine Fehlerantwort behält den ganzen Body (err.body), z. B. offen und consent der Bestellung', mitDom({
antworten: [{ status: 409, body: { error: 'Nicht vollständig.', code: 'bestellung_offen', offen: [{ schritt: 'website', text: 'Impressum fehlt.' }] } }, { status: 500, body: 'kein Objekt' }],
}, async () => {
await assert.rejects(request('/api/funktionen/termine/checkout', { method: 'POST', body: { projectId: 'p1' } }), (err) => {
assert.equal(err.status, 409)
assert.equal(err.code, 'bestellung_offen')
assert.equal(err.message, 'Nicht vollständig.')
assert.deepEqual(err.body.offen, [{ schritt: 'website', text: 'Impressum fehlt.' }])
return true
})
await assert.rejects(request('/x'), (err) => err.status === 500 && typeof err.body === 'object' && err.body !== null, 'auch ohne Objekt im Body ist err.body ein Objekt')
}))
// ------------------------------------------------------- funktion-bestellen.js: Bestellseite und Statuskarten (TP2, Task C8)
// Die Datei ist bewusst kein termine*.js (einzige mit href-Links); ihre Regeln pruefen die statischen Tests unten.
const BELEHRUNG = 'Widerrufsbelehrung\nDu hast das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.\n\nMuster-Widerrufsformular\n(Wenn Du den Vertrag widerrufen willst, fülle dieses Formular aus.)'
const KNOPF_URL = 'https://checkout.stripe.test/c/pay/cs_test_1'
const bestellungTest = async (aenderung = {}) => ({
...(await bestellungAnsicht(featureConfig('termine'))),
belehrung: BELEHRUNG,
dokumente: [
{ id: 'leistungsbeschreibung', titel: 'Leistungsbeschreibung', version: '2026-10-05', url: '/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.html' },
{ id: 'avv', titel: 'Auftragsverarbeitungsvertrag (AVV)', version: '2026-10-05', url: 'https://webklar.example.invalid/avv-terminplaner.html' },
],
...aenderung,
})
/** Antwort von GET /api/termine/einstellungen in off, die die Bestellseite braucht (bestellbarer Entwurf, nichts offen). */
const bestellDaten = async (aenderung = {}, bestellungAenderung = {}) => ({
state: 'off',
einstellung: bestellbarerStand({ now: JETZT }).einstellung,
fragebogen: bestellbarerStand({ now: JETZT }).fragebogen,
version: 1,
branchen: BRANCHEN.map(({ key, name, gesundheit }) => ({ key, name, gesundheit })),
kundenansicht: false,
abo: { ok: true },
offen: [],
bestellung: await bestellungTest(bestellungAenderung),
...aenderung,
})
const registerHaken = () => featureConfig('termine').consent.items.filter((item) => item.haken).map((item) => item.id)
/** Bestellseite in den Baum haengen. => { wurzel, ctx, schritte } (schritte: die Schluessel, zu denen zumSchritt gerufen wurde). */
async function bestellSeite(daten) {
const wurzel = h('div')
document.body.append(wurzel)
const ctx = huelle.baueCtx({ daten, nurLesen: false }, { projekt: { projectId: 'p1' }, aktuell: () => true })
const schritte = []
bestellen.renderBestellen(wurzel, { ctx, daten, zumSchritt: (key) => schritte.push(key) })
return { wurzel, ctx, schritte }
}
const bestellKnopf = (fx, wurzel) => fx.finde(wurzel, '[data-aktion="bestellen"]')
const hakenFelder = (fx, wurzel) => fx.alle(wurzel, 'input[type="checkbox"]')
const alleAnhaken = async (fx, wurzel) => { for (const haken of hakenFelder(fx, wurzel)) if (!haken.checked) await fx.klick(haken) }
const meldungVon = (fx, wurzel) => fx.text(fx.finde(wurzel, '[data-meldung="bestellen"]'))
// ------------------------------------------------------- sicherLink, bestellKoerper, fortsetzenKoerper
test('sicherLink: https-Adressen und Pfade unter /rechtliches/ gehen durch, alles andere wird zu ""', () => {
assert.equal(bestellen.sicherLink('https://x.de/a'), 'https://x.de/a')
assert.equal(bestellen.sicherLink('https://x.de'), 'https://x.de')
assert.equal(bestellen.sicherLink('/rechtliches/avv-terminplaner-v1.html'), '/rechtliches/avv-terminplaner-v1.html')
assert.equal(bestellen.sicherLink('/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.pdf'), '/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.pdf')
for (const schlecht of [
'javascript:alert(1)', 'JavaScript:alert(1)', ' javascript:alert(1)', 'data:text/html,<script>alert(1)</script>', 'vbscript:x', 'blob:https://x.de/a',
'http://x.de/a', 'ftp://x.de/a', '//evil.example', '//evil.example/rechtliches/x.html', '/\\evil.example', '\\\\evil.example',
'/vertragsunterlagen/x.html', '/rechtliches', '/rechtliches/', '/Rechtliches/x.html', 'rechtliches/x.html', '/x/rechtliches/x.html', '../rechtliches/x.html',
'/rechtliches/../app.js', '/rechtliches/%2e%2e/app.js', '/rechtliches/%2E./app.js', 'https:', 'https://', 'https:///x', 'https://user:pw@x.de/a', 'https://user@x.de/a',
' https://x.de/a', 'https://x.de/a ', '\thttps://x.de/a', 'https://x.de/a\n', 'https://x.de/ a', 'https://x.de/\u0000a', 'java\nscript:alert(1)', '/rechtliches/\nx.html', '/rechtliches/ x.html',
'', ' ', null, undefined, 42, {}, ['https://x.de/a'],
]) assert.equal(bestellen.sicherLink(schlecht), '', `muss abgewiesen werden: ${JSON.stringify(schlecht)}`)
})
test('bestellKoerper: genau { projectId, zustimmung: { version, haken } } mit den angehakten IDs der Haken-Items, nie Texte', async () => {
const bestellung = await bestellungTest()
const koerper = bestellen.bestellKoerper({ projectId: 'p1', bestellung, gewaehlt: new Set(registerHaken()) })
assert.deepEqual(Object.keys(koerper), ['projectId', 'zustimmung'])
assert.deepEqual(Object.keys(koerper.zustimmung), ['version', 'haken'])
assert.equal(koerper.projectId, 'p1')
assert.equal(koerper.zustimmung.version, bestellung.consent.version)
assert.deepEqual(koerper.zustimmung.haken, registerHaken())
const json = JSON.stringify(koerper)
for (const item of bestellung.consent.items) assert.ok(!json.includes(item.text), 'kein Text des Registers im Körper')
assert.ok(!json.includes(bestellung.consent.knopf), 'auch der Knopftext nicht')
assert.equal(koerper.consent, undefined)
})
test('bestellKoerper: nur angehakte IDs von Items mit haken: true, in Registerreihenfolge, Fremdes und Doppeltes fällt weg', () => {
const bestellung = {
consent: {
version: 'v-test',
knopf: 'Bestellen',
items: [
{ id: 'a', text: 'Hinweis ohne Haken', haken: false },
{ id: 'b', text: 'Erster Haken', haken: true },
{ id: 'c', text: 'Zweiter Haken', haken: true },
{ id: 'd', text: 'Noch ein Hinweis', haken: false },
{ id: 'e', text: 'Dritter Haken', haken: true },
],
},
}
// Array und Set, andere Reihenfolge, eine ID eines reinen Textes (a), eine unbekannte (zz) und ein Doppelter (e)
assert.deepEqual(bestellen.bestellKoerper({ projectId: 'p', bestellung, gewaehlt: ['e', 'c', 'a', 'zz', 'e', 'b'] }).zustimmung.haken, ['b', 'c', 'e'])
assert.deepEqual(bestellen.bestellKoerper({ projectId: 'p', bestellung, gewaehlt: new Set(['c']) }).zustimmung, { version: 'v-test', haken: ['c'] })
assert.deepEqual(bestellen.bestellKoerper({ projectId: 'p', bestellung, gewaehlt: [] }).zustimmung.haken, [])
assert.deepEqual(bestellen.bestellKoerper({ projectId: 'p', bestellung, gewaehlt: undefined }).zustimmung.haken, [])
})
test('bestellKoerper: die gesendeten IDs sind Teilmenge der Register-IDs (Querprüfung gegen featureConfig("termine").consent.items)', async () => {
const register = featureConfig('termine').consent
const bestellung = await bestellungTest()
const alle = bestellen.bestellKoerper({ projectId: 'p1', bestellung, gewaehlt: register.items.map((item) => item.id) })
assert.equal(alle.zustimmung.version, register.version, 'die Fassung des Registers')
const registerIds = new Set(register.items.map((item) => item.id))
assert.ok(alle.zustimmung.haken.length > 0)
assert.ok(alle.zustimmung.haken.every((id) => registerIds.has(id)))
assert.deepEqual(alle.zustimmung.haken, register.items.filter((item) => item.haken).map((item) => item.id), 'alle Haken des Registers, in Registerreihenfolge')
})
test('fortsetzenKoerper: { projectId, fortsetzen: true } ohne zustimmung', () => {
const koerper = bestellen.fortsetzenKoerper('p1')
assert.deepEqual(koerper, { projectId: 'p1', fortsetzen: true })
assert.deepEqual(Object.keys(koerper), ['projectId', 'fortsetzen'])
assert.equal(koerper.zustimmung, undefined)
})
// ------------------------------------------------------- statische Regeln
const BESTELLEN_DATEI = 'public/funktion-bestellen.js'
const quelleBestellen = () => readFileSync(new URL(`../${BESTELLEN_DATEI}`, import.meta.url), 'utf8')
const ohneKommentare = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '')
test('funktion-bestellen.js statisch: Fremdtext nur als Text, keine Dialoge, kein Browserspeicher, Du-Form, kein Knopftext-Literal, kein details', () => {
const roh = quelleBestellen()
const code = ohneKommentare(roh)
assert.ok(code.length > 2000, 'die Datei ist gefüllt')
assert.doesNotMatch(code, /innerHTML|insertAdjacentHTML|outerHTML|document\.write/, 'Daten nie als HTML')
assert.doesNotMatch(code, /\bconfirm\(|\balert\(|\bprompt\(/, 'Rückfragen stehen in der Seite')
assert.doesNotMatch(code, /localStorage|sessionStorage|document\.cookie/, 'kein Browserspeicher')
assert.doesNotMatch(code, /\bSie\b|\bIhr(e[nmrs]?)?\b/, 'Portaltexte in der Du-Form (die Rechtstexte kommen vom Server)')
assert.doesNotMatch(roh, /zahlungspflichtig/i, 'der Knopftext kommt aus bestellung.consent.knopf, nie aus dem Client')
assert.doesNotMatch(code, /details|summary/i, 'die Widerrufsbelehrung ist nie eingeklappt')
assert.match(code, /bestellung\.consent\.knopf/, 'der Knopf zeigt den Registertext')
})
test('funktion-bestellen.js statisch: jedes href nur über sicherLink(, location.href nur nach Prüfung auf https:', () => {
const code = ohneKommentare(quelleBestellen())
const hrefs = code.match(/(?:\bhref:|setAttribute\('href',)[^\n]*/g) || []
assert.ok(hrefs.length >= 2, 'Unterlagen und Rechnung sind Links')
for (const zeile of hrefs) assert.match(zeile, /sicherLink\(/, `href nur über sicherLink: ${zeile}`)
const zuweisungen = [...code.matchAll(/location\.href\s*=[^=]/g)]
assert.equal(zuweisungen.length, 1, 'genau eine Stelle navigiert')
const davor = code.slice(Math.max(0, zuweisungen[0].index - 250), zuweisungen[0].index)
assert.match(davor, /istHttps\(|'https:'/, 'unmittelbar davor wird auf https: geprüft')
assert.doesNotMatch(code, /location\.(assign|replace)\(|window\.open\(/, 'keine andere Navigation')
const neueTabs = code.split('\n').filter((zeile) => zeile.includes("'_blank'"))
assert.ok(neueTabs.length >= 2)
for (const zeile of neueTabs) assert.match(zeile, /rel: 'noopener noreferrer'/, `Links in neuem Tab tragen rel: ${zeile}`)
})
test('funktion-bestellen.js statisch: kein termine*.js, relative Importe, von keinem absoluten Import geladen', () => {
const dateien = readdirSync(new URL('../public/', import.meta.url)).filter((f) => f.endsWith('.js'))
assert.ok(dateien.includes('funktion-bestellen.js'))
assert.doesNotMatch('funktion-bestellen.js', /^termine.*\.js$/, 'nicht in der Dateiliste der termine*.js-Regeln')
const code = ohneKommentare(quelleBestellen())
assert.deepEqual(code.match(/from '[^']+'/g), ["from './termine-dom.js'", "from './termine-view.js'"], 'nur relative Importe, nur diese zwei')
assert.doesNotMatch(code, /import\s*\(/, 'kein dynamischer Import')
for (const datei of dateien) {
assert.doesNotMatch(readFileSync(new URL(`../public/${datei}`, import.meta.url), 'utf8'), /from '\/funktion-bestellen\.js'|import\('\/funktion-bestellen\.js'\)/, `${datei}: kein absoluter Import`)
}
const fn = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8')
assert.deepEqual(fn.match(/from '\/[^']+'/g), ["from '/analyse-view.js'", "from '/geraete.js'"], 'funktionen.js behält genau diese beiden absoluten Importe')
assert.doesNotMatch(fn, /funktion-bestellen/)
})
test('termine.css: Klassen der Bestellseite, des Rechtstextes (scrollbar) und der Statuskarte', () => {
const css = readFileSync(new URL('../public/termine.css', import.meta.url), 'utf8')
for (const klasse of ['tm-bestellen', 'tm-rechtstext', 'tm-statuskarte']) assert.match(css, new RegExp(`\\.${klasse}[\\s{,.:\\[]`), `.${klasse} ist gestaltet`)
const rechtstext = css.match(/\.tm-rechtstext\s*\{[^}]*\}/)?.[0] ?? ''
assert.match(rechtstext, /max-height:/, 'begrenzte Höhe')
assert.match(rechtstext, /overflow-y:\s*auto/, 'scrollbar, nie eingeklappt')
assert.match(rechtstext, /white-space:\s*pre-wrap/, 'Absätze und Zeilen der Belehrung bleiben erhalten')
})
// ------------------------------------------------------- renderBestellen: Aufbau
test('renderBestellen: Reihenfolge Zusammenfassung, offene Punkte, Preis, Belehrung, Unterlagen, Haken, Knopf', mitDom(undefined, async (fx) => {
const daten = await bestellDaten({ offen: [{ schritt: 'website', text: 'Bitte trage die Adresse deines Impressums ein.' }] })
const { wurzel } = await bestellSeite(daten)
const alle = fx.alle(wurzel, '*')
const stelle = (selektor) => {
const el = fx.finde(wurzel, selektor)
assert.ok(el, `${selektor} fehlt`)
return alle.indexOf(el)
}
const stellen = ['.tm-bestellen-zusammenfassung', '.tm-bestellen-offen', '.tm-bestellen-preis', '.tm-rechtstext', '.tm-bestellen-dokumente', '.tm-bestellen-haken input', '[data-aktion="bestellen"]'].map(stelle)
assert.deepEqual(stellen, [...stellen].sort((a, b) => a - b), 'Dokumentreihenfolge')
assert.equal(new Set(stellen).size, stellen.length)
assert.equal(fx.alle(wurzel, '[data-aktion="bestellen"]').length, 1, 'genau ein Bestell-Knopf')
assert.equal(fx.alle(wurzel, 'button').at(-1), bestellKnopf(fx, wurzel), 'der Knopf steht zuletzt')
}))
test('renderBestellen: ohne offene Punkte gibt es den Block „offen“ nicht', mitDom(undefined, async (fx) => {
const { wurzel } = await bestellSeite(await bestellDaten())
assert.equal(fx.finde(wurzel, '.tm-bestellen-offen'), null)
const { wurzel: wurzel2 } = await bestellSeite(await bestellDaten({ offen: null }))
assert.equal(fx.finde(wurzel2, '.tm-bestellen-offen'), null, 'offen: null (kein off-Zustand) zählt wie leer')
}))
test('renderBestellen: Knopftext == bestellung.consent.knopf == Registertext, kein Text im Client', mitDom(undefined, async (fx) => {
const daten = await bestellDaten()
const { wurzel } = await bestellSeite(daten)
const knopf = bestellKnopf(fx, wurzel)
assert.equal(fx.text(knopf), daten.bestellung.consent.knopf)
assert.equal(fx.text(knopf), featureConfig('termine').consent.knopf)
// ein anderer Text im Register ist sofort der Text des Knopfes
const andere = await bestellDaten({}, { consent: { ...daten.bestellung.consent, knopf: 'Jetzt kaufen · 1 €' } })
const { wurzel: wurzel2 } = await bestellSeite(andere)
assert.equal(fx.text(bestellKnopf(fx, wurzel2)), 'Jetzt kaufen · 1 €')
}))
test('renderBestellen: die vollständige Widerrufsbelehrung steht als Text in einem scrollbaren Bereich vor dem Knopf, nie eingeklappt', mitDom(undefined, async (fx) => {
const { wurzel } = await bestellSeite(await bestellDaten())
const bereich = fx.finde(wurzel, '.tm-rechtstext')
assert.equal(bereich.textContent, BELEHRUNG, 'der ganze Text, unverändert (auch Zeilenumbrüche)')
assert.ok(bereich.textContent.includes('Muster-Widerrufsformular'))
assert.equal(bereich.getAttribute('tabindex'), '0', 'mit der Tastatur scrollbar')
assert.equal(bereich.hidden, false)
assert.equal(bereich.closest('details'), null)
assert.equal(fx.alle(wurzel, 'details').length, 0)
assert.equal(fx.alle(wurzel, '[hidden]').length, 0, 'nichts auf der Seite ist versteckt')
assert.equal(fx.alle(wurzel, '[aria-expanded]').length, 0, 'und nichts klappt auf oder zu')
const alle = fx.alle(wurzel, '*')
assert.ok(alle.indexOf(bereich) < alle.indexOf(bestellKnopf(fx, wurzel)), 'der Belehrungstext steht vor dem Knopf')
const hakenStellen = hakenFelder(fx, wurzel).map((haken) => alle.indexOf(haken))
assert.ok(hakenStellen.every((stelle) => stelle > alle.indexOf(bereich)), 'und vor den Haken')
assert.equal(fx.text(fx.finde(wurzel, '.tm-bestellen-belehrung h4')), 'Widerrufsbelehrung', 'mit Überschrift')
}))
test('renderBestellen: ohne Belehrungstext (belehrung: null) gibt es den Bereich nicht', mitDom(undefined, async (fx) => {
const { wurzel } = await bestellSeite(await bestellDaten({}, { belehrung: null }))
assert.equal(fx.finde(wurzel, '.tm-rechtstext'), null)
const { wurzel: leer } = await bestellSeite(await bestellDaten({}, { belehrung: '' }))
assert.equal(fx.finde(leer, '.tm-rechtstext'), null)
}))
test('renderBestellen: Unterlagen sind Links nur über sicherLink, unsichere Adressen werden ausgelassen', mitDom(undefined, async (fx) => {
const daten = await bestellDaten({}, {
dokumente: [
{ id: 'leistungsbeschreibung', titel: 'Leistungsbeschreibung', version: 'v1', url: '/rechtliches/leistungsbeschreibung-terminplaner-v1.html' },
{ id: 'avv', titel: 'Auftragsverarbeitungsvertrag (AVV)', version: 'v1', url: 'https://webklar.example.invalid/avv.html' },
{ id: 'widerruf', titel: 'Widerrufsbelehrung', version: 'v1', url: 'javascript:alert(1)' },
{ id: 'x', titel: 'Fremd', version: 'v1', url: '//evil.example/x.html' },
{ id: 'y', titel: 'Unverschlüsselt', version: 'v1', url: 'http://webklar.example.invalid/y.html' },
],
})
const { wurzel } = await bestellSeite(daten)
const links = fx.alle(wurzel, '.tm-bestellen-dokumente a')
assert.deepEqual(links.map((a) => [fx.text(a), a.getAttribute('href')]), [
['Leistungsbeschreibung', '/rechtliches/leistungsbeschreibung-terminplaner-v1.html'],
['Auftragsverarbeitungsvertrag (AVV)', 'https://webklar.example.invalid/avv.html'],
])
for (const a of links) {
assert.equal(a.getAttribute('target'), '_blank')
assert.equal(a.getAttribute('rel'), 'noopener noreferrer')
}
for (const a of fx.alle(wurzel, 'a')) assert.notEqual(a.getAttribute('href'), 'javascript:alert(1)')
// ohne verwendbare Unterlagen entfällt der Block
const { wurzel: ohne } = await bestellSeite(await bestellDaten({}, { dokumente: [] }))
assert.equal(fx.finde(ohne, '.tm-bestellen-dokumente'), null)
}))
test('renderBestellen: Preis mit Hinweis aus dem Server und Einrichtungsdauer', mitDom(undefined, async (fx) => {
const daten = await bestellDaten()
const { wurzel } = await bestellSeite(daten)
const preis = fx.text(fx.finde(wurzel, '.tm-bestellen-preis'))
assert.ok(preis.includes(daten.bestellung.preis), 'der Preis')
assert.ok(preis.includes(daten.bestellung.preisHinweis), 'der Hinweis steht im Text vom Server')
assert.ok(preis.includes(daten.bestellung.einrichtung), 'die Einrichtungsdauer')
}))
test('renderBestellen: Zustimmungs-Haken in Registerreihenfolge mit dem Wortlaut des Registers, reine Texte ohne Haken', mitDom(undefined, async (fx) => {
const daten = await bestellDaten()
const { wurzel } = await bestellSeite(daten)
const felder = hakenFelder(fx, wurzel)
assert.deepEqual(felder.map((haken) => haken.dataset.haken), registerHaken(), 'IDs in Registerreihenfolge')
const items = featureConfig('termine').consent.items.filter((item) => item.haken)
felder.forEach((haken, i) => {
assert.equal(fx.text(haken.closest('label')), items[i].text, 'Wortlaut des Registers')
assert.equal(haken.checked, false, 'nichts ist vorab angehakt')
})
// gemischt: Items ohne haken sind reiner Text an ihrer Stelle
const consent = {
version: 'v-test',
knopf: 'Bestellen',
items: [
{ id: 'a', text: 'Hinweis vorab', haken: false },
{ id: 'b', text: 'Ich stimme dem ersten zu.', haken: true },
{ id: 'c', text: 'Hinweis in der Mitte', haken: false },
{ id: 'd', text: 'Ich stimme dem zweiten zu.', haken: true },
],
}
const { wurzel: gemischt } = await bestellSeite(await bestellDaten({}, { consent }))
const bereich = fx.finde(gemischt, '.tm-bestellen-haken')
assert.deepEqual(fx.alle(bereich, 'input[type="checkbox"]').map((haken) => haken.dataset.haken), ['b', 'd'])
const zeilen = bereich.children.filter((el) => el.matches('p, label')).map((el) => [el.tagName, fx.text(el)])
assert.deepEqual(zeilen, [['P', 'Hinweis vorab'], ['LABEL', 'Ich stimme dem ersten zu.'], ['P', 'Hinweis in der Mitte'], ['LABEL', 'Ich stimme dem zweiten zu.']])
}))
test('renderBestellen: Kundentext und Registertext landen nur als Text im Baum', mitDom(undefined, async (fx) => {
const boese = '<img src=x onerror=alert(1)>'
const daten = await bestellDaten({
offen: [{ schritt: 'website', text: boese }],
einstellung: { ...bestellbarerStand({ now: JETZT }).einstellung, betrieb: { ...BETRIEB, name: boese } },
}, { belehrung: boese, consent: { version: 'v', knopf: boese, items: [{ id: 'a', text: boese, haken: true }] }, preis: boese, einrichtung: boese })
const { wurzel } = await bestellSeite(daten)
assert.equal(fx.alle(wurzel, 'img').length, 0)
assert.ok(fx.text(wurzel).includes(boese))
}))
test('renderBestellen: fehlt die Bestellung (kein off-Zustand), steht eine Fehlerkarte statt der Seite', mitDom(undefined, async (fx) => {
const { wurzel } = await bestellSeite(await bestellDaten({ bestellung: null }))
assert.equal(fx.finde(wurzel, '[data-aktion="bestellen"]'), null)
assert.match(fx.text(wurzel), /Bestellseite/)
}))
// ------------------------------------------------------- Zusammenfassung und offene Punkte
test('renderBestellen: Zusammenfassung zeigt die acht Gruppen aus zusammenfassung(), „Ändern“ ruft zumSchritt(key)', mitDom(undefined, async (fx) => {
const daten = await bestellDaten()
const { wurzel, schritte } = await bestellSeite(daten)
const erwartet = zusammenfassung(daten.einstellung, daten.fragebogen, daten.branchen)
const gruppen = fx.alle(wurzel, '.tm-bestellen-gruppe')
assert.deepEqual(gruppen.map((g) => g.dataset.schritt), FRAGEBOGEN_SCHRITTE.map(([key]) => key), 'Reihenfolge der Schritte')
gruppen.forEach((gruppe, i) => {
assert.equal(fx.text(fx.finde(gruppe, 'h4')), erwartet[i].titel)
assert.deepEqual(fx.alle(gruppe, 'li').map((li) => fx.text(li)), erwartet[i].zeilen, 'Zeilen unverändert als Text')
})
assert.ok(erwartet.some((g) => g.zeilen.length > 0))
assert.ok(fx.text(gruppen[0]).includes(`Branche: ${daten.branchen.find((b) => b.key === 'salon').name}`), 'die Angaben des Kunden stehen da')
const aendern = fx.alle(wurzel, '[data-aktion="aendern"]')
assert.equal(aendern.length, 8, 'je Gruppe ein Knopf')
assert.ok(aendern.every((knopf) => fx.text(knopf) === 'Ändern'))
assert.match(aendern[2].getAttribute('aria-label'), /^Ändern: /)
await fx.klick(aendern[2])
await fx.klick(aendern[7])
assert.deepEqual(schritte, ['leistungen', 'website'])
}))
test('renderBestellen: eine Gruppe ohne Angaben sagt es und hat trotzdem „Ändern“; fehlendes zumSchritt ist kein Fehler', mitDom(undefined, async (fx) => {
const daten = await bestellDaten({ einstellung: null, fragebogen: null })
const wurzel = h('div')
document.body.append(wurzel)
const ctx = huelle.baueCtx({ daten, nurLesen: false }, { projekt: { projectId: 'p1' }, aktuell: () => true })
bestellen.renderBestellen(wurzel, { ctx, daten })
const gruppen = fx.alle(wurzel, '.tm-bestellen-gruppe')
assert.equal(gruppen.length, 8)
assert.match(fx.text(gruppen[0]), /Noch keine Angaben/)
await fx.klick(fx.finde(gruppen[0], '[data-aktion="aendern"]'))
}))
test('renderBestellen: offene Punkte mit „Zum Schritt“ zu ihrem Schritt, der Abo-Punkt (bestellen) ohne Knopf; Knopf gesperrt', mitDom(undefined, async (fx) => {
const offen = [
{ schritt: 'branche', text: 'Bitte trage deine Anschrift ein.' },
{ schritt: 'website', text: 'Bitte trage die Adresse deines Impressums ein.' },
{ schritt: 'bestellen', text: 'Das Abo konnte gerade nicht geprüft werden.' },
]
const { wurzel, schritte } = await bestellSeite(await bestellDaten({ offen }))
const punkte = fx.alle(wurzel, '.tm-bestellen-offen li')
assert.deepEqual(punkte.map((li) => fx.finde(li, 'span')?.textContent), offen.map((p) => p.text))
const knoepfe = fx.alle(wurzel, '[data-aktion="zum-schritt"]')
assert.deepEqual(knoepfe.map((k) => k.dataset.schritt), ['branche', 'website'], 'bestellen ist diese Seite')
assert.ok(knoepfe.every((k) => fx.text(k) === 'Zum Schritt'))
await fx.klick(knoepfe[1])
assert.deepEqual(schritte, ['website'])
await alleAnhaken(fx, wurzel)
assert.equal(bestellKnopf(fx, wurzel).disabled, true, 'mit offenen Punkten bleibt der Knopf gesperrt, auch mit allen Haken')
assert.match(fx.text(fx.finde(wurzel, '[data-hinweis="bestellen"]')), /offenen Punkte/)
}))
// ------------------------------------------------------- Knopf: gesperrt oder frei
test('renderBestellen: Knopf gesperrt, bis alle Haken gesetzt sind; ein abgenommener Haken sperrt ihn wieder', mitDom(undefined, async (fx) => {
const { wurzel } = await bestellSeite(await bestellDaten())
const knopf = bestellKnopf(fx, wurzel)
const felder = hakenFelder(fx, wurzel)
assert.ok(felder.length >= 2)
assert.equal(knopf.disabled, true, 'am Anfang gesperrt')
for (const haken of felder.slice(0, -1)) {
await fx.klick(haken)
assert.equal(knopf.disabled, true, 'solange ein Haken fehlt')
}
await fx.klick(felder.at(-1))
assert.equal(knopf.disabled, false, 'alle Haken ⇒ frei')
await fx.klick(felder[0])
assert.equal(knopf.disabled, true, 'Haken weg ⇒ wieder gesperrt')
await fx.klick(felder[0])
assert.equal(knopf.disabled, false)
assert.equal(fx.fetchLog.length, 0, 'bis hierher keine Anfrage')
}))
test('renderBestellen: in der Kundenansicht bleibt der Knopf gesperrt, auch mit allen Haken, mit Hinweis und ohne Anfrage', mitDom(undefined, async (fx) => {
const { wurzel } = await bestellSeite(await bestellDaten({ kundenansicht: true }))
await alleAnhaken(fx, wurzel)
const knopf = bestellKnopf(fx, wurzel)
assert.equal(knopf.disabled, true)
assert.equal(fx.text(fx.finde(wurzel, '[data-hinweis="bestellen"]')), 'In der Kundenansicht kann nicht bestellt werden.')
await fx.klick(knopf)
assert.equal(fx.fetchLog.length, 0)
}))
// ------------------------------------------------------- Bestellen: Klick, Fehler, 409
test('Bestellen: der Klick sperrt den Knopf, sendet bestellKoerper, navigiert nur auf eine https-Adresse; ein zweiter Klick sendet nichts', mitDom({ antworten: [{ body: { url: KNOPF_URL } }] }, async (fx) => {
const daten = await bestellDaten()
const { wurzel } = await bestellSeite(daten)
await alleAnhaken(fx, wurzel)
const knopf = bestellKnopf(fx, wurzel)
const erster = fx.klick(knopf)
assert.equal(knopf.disabled, true, 'sofort gesperrt, noch vor der Antwort')
const zweiter = fx.klick(knopf)
await Promise.all([erster, zweiter])
assert.equal(fx.fetchLog.length, 1, 'genau eine Anfrage')
assert.deepEqual(fx.fetchLog[0], {
method: 'POST',
url: '/api/funktionen/termine/checkout',
body: { projectId: 'p1', zustimmung: { version: daten.bestellung.consent.version, haken: registerHaken() } },
})
assert.equal(fx.window.location.href, KNOPF_URL)
assert.equal(knopf.disabled, true, 'die Seite wird verlassen, der Knopf bleibt gesperrt')
}))
test('Bestellen: eine Adresse, die nicht https ist, wird nie angesteuert; Meldung am Knopf, der Knopf ist wieder frei', mitDom(undefined, async (fx) => {
for (const url of ['http://checkout.stripe.test/x', 'javascript:alert(1)', '//evil.example/x', '/relativ', '', undefined, 42]) {
fx.antworten.push({ body: { url } })
const { wurzel } = await bestellSeite(await bestellDaten())
await alleAnhaken(fx, wurzel)
const knopf = bestellKnopf(fx, wurzel)
await fx.klick(knopf)
assert.equal(fx.window.location.href, 'http://localhost/', `keine Navigation für ${JSON.stringify(url)}`)
assert.match(meldungVon(fx, wurzel), /Zahlungsseite/)
assert.equal(knopf.disabled, false, 'wieder frei')
}
}))
test('Bestellen: ein Fehler (500, kein Netz) steht als Meldung am Knopf, Haken bleiben, der Knopf ist wieder frei', mitDom({
antworten: [{ status: 500, body: { error: 'Die Bestellung konnte gerade nicht gestartet werden. Bitte versuch es später noch einmal oder schreib uns im Chat.' } }, () => { throw new TypeError('Failed to fetch') }, { status: 400, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }],
}, async (fx) => {
const { wurzel } = await bestellSeite(await bestellDaten())
await alleAnhaken(fx, wurzel)
const knopf = bestellKnopf(fx, wurzel)
await fx.klick(knopf)
assert.equal(meldungVon(fx, wurzel), 'Die Bestellung konnte gerade nicht gestartet werden. Bitte versuch es später noch einmal oder schreib uns im Chat.')
assert.equal(knopf.disabled, false)
assert.ok(hakenFelder(fx, wurzel).every((haken) => haken.checked), 'die Haken bleiben')
await fx.klick(knopf)
assert.equal(meldungVon(fx, wurzel), 'Keine Verbindung. Bitte versuch es gleich noch einmal.')
await fx.klick(knopf)
assert.equal(meldungVon(fx, wurzel), 'Bitte stimme der Erklärung zu, um zu bestellen.')
assert.equal(knopf.disabled, false)
assert.equal(fx.fetchLog.length, 3, 'kein Nachladen bei Fehlern außer 409')
assert.equal(fx.window.location.href, 'http://localhost/')
}))
test('Bestellen 409 bestellung_offen: lädt die Daten neu, setzt die Haken zurück und zeigt die Punkte aus body.offen', mitDom(undefined, async (fx) => {
const daten = await bestellDaten()
const neu = await bestellDaten({ offen: [] })
const offen = [{ schritt: 'website', text: 'Bitte trage die Adresse deines Impressums ein.' }, { schritt: 'zeiten', text: 'Bitte wähle das Bundesland deines Betriebs.' }]
fx.antworten.push(
{ status: 409, body: { error: 'Die Bestellung ist noch nicht vollständig. Bitte erledige zuerst die offenen Punkte.', code: 'bestellung_offen', offen } },
{ body: neu },
)
const { wurzel, schritte } = await bestellSeite(daten)
await alleAnhaken(fx, wurzel)
await fx.klick(bestellKnopf(fx, wurzel))
assert.deepEqual(fx.fetchLog.map((a) => [a.method, a.url]), [['POST', '/api/funktionen/termine/checkout'], ['GET', '/api/termine/einstellungen?projectId=p1']])
assert.deepEqual(fx.alle(wurzel, '.tm-bestellen-offen li').map((li) => fx.finde(li, 'span').textContent), offen.map((p) => p.text))
assert.ok(hakenFelder(fx, wurzel).every((haken) => !haken.checked), 'Haken zurückgesetzt')
assert.equal(bestellKnopf(fx, wurzel).disabled, true)
assert.equal(meldungVon(fx, wurzel), 'Die Bestellung ist noch nicht vollständig. Bitte erledige zuerst die offenen Punkte.')
await fx.klick(fx.alle(wurzel, '[data-aktion="zum-schritt"]')[1])
assert.deepEqual(schritte, ['zeiten'], 'die neue Seite führt zu den Schritten')
}))
test('Bestellen 409 fassung_veraltet: zeigt die neue Fassung aus body.consent, Haken zurück, der nächste Körper trägt die neue Version', mitDom(undefined, async (fx) => {
const daten = await bestellDaten()
const neueFassung = {
version: '2026-11-01',
knopf: 'Neuer Knopf · 149 €',
items: [{ id: 'unterlagen', text: 'Neuer Wortlaut der Unterlagen.', haken: true }, { id: 'hinweis', text: 'Ein neuer Hinweis.', haken: false }, { id: 'beginn', text: 'Neuer Wortlaut des Beginns.', haken: true }],
}
fx.antworten.push(
{ status: 409, body: { error: 'Die Erklärungen wurden inzwischen geändert. Bitte lies sie noch einmal und stimme neu zu.', code: 'fassung_veraltet', consent: neueFassung } },
{ body: await bestellDaten() },
{ body: { url: KNOPF_URL } },
)
const { wurzel } = await bestellSeite(daten)
await alleAnhaken(fx, wurzel)
await fx.klick(bestellKnopf(fx, wurzel))
assert.equal(meldungVon(fx, wurzel), 'Die Erklärungen wurden inzwischen geändert. Bitte lies sie noch einmal und stimme neu zu.')
assert.equal(fx.text(bestellKnopf(fx, wurzel)), 'Neuer Knopf · 149 €')
assert.deepEqual(hakenFelder(fx, wurzel).map((haken) => [haken.dataset.haken, fx.text(haken.closest('label')), haken.checked]), [
['unterlagen', 'Neuer Wortlaut der Unterlagen.', false], ['beginn', 'Neuer Wortlaut des Beginns.', false],
])
assert.ok(fx.text(wurzel).includes('Ein neuer Hinweis.'))
assert.equal(bestellKnopf(fx, wurzel).disabled, true)
await alleAnhaken(fx, wurzel)
await fx.klick(bestellKnopf(fx, wurzel))
assert.deepEqual(fx.fetchLog.at(-1).body, { projectId: 'p1', zustimmung: { version: '2026-11-01', haken: ['unterlagen', 'beginn'] } })
assert.equal(fx.window.location.href, KNOPF_URL)
}))
test('Bestellen 409 mit Abo-Code: der Text aus der Antwort steht am Knopf, Daten neu geladen, Haken zurück', mitDom(undefined, async (fx) => {
for (const [code, text] of [
['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.'],
['nicht_aktiv', 'Dein Wartungs-Abo ist gerade nicht aktiv (zum Beispiel wegen einer offenen Zahlung). Sobald es wieder aktiv ist, kannst du den Terminplaner bestellen.'],
]) {
fx.fetchLog.length = 0
fx.antworten.push({ status: 409, body: { error: text, code, grund: code } }, { body: await bestellDaten({ abo: { ok: false, grund: code }, offen: [{ schritt: 'bestellen', text }] }) })
const { wurzel } = await bestellSeite(await bestellDaten())
await alleAnhaken(fx, wurzel)
await fx.klick(bestellKnopf(fx, wurzel))
assert.equal(meldungVon(fx, wurzel), text, code)
assert.equal(fx.fetchLog.length, 2, 'Daten neu geladen')
assert.ok(hakenFelder(fx, wurzel).every((haken) => !haken.checked))
assert.equal(bestellKnopf(fx, wurzel).disabled, true)
}
}))
test('Bestellen 409: schlägt das Neuladen fehl, bleibt die Seite stehen und die Meldung steht am Knopf', mitDom(undefined, async (fx) => {
fx.antworten.push({ status: 409, body: { error: 'Dein Wartungs-Abo ist gekündigt.', code: 'gekuendigt' } }, { status: 500, body: { error: 'Server kaputt.' } })
const { wurzel } = await bestellSeite(await bestellDaten())
await alleAnhaken(fx, wurzel)
const knopf = bestellKnopf(fx, wurzel)
await fx.klick(knopf)
assert.equal(bestellKnopf(fx, wurzel), knopf, 'dieselbe Seite')
assert.equal(meldungVon(fx, wurzel), 'Dein Wartungs-Abo ist gekündigt.')
assert.equal(knopf.disabled, false)
}))
test('Bestellen 409: ist die Bestellung inzwischen aufgegeben (keine bestellung mehr in den Daten), steht nur noch die Meldung mit Hinweis zum Neuladen', mitDom(undefined, async (fx) => {
fx.antworten.push({ status: 409, body: { error: 'Der Terminplaner ist für dieses Projekt schon bestellt.' } }, { body: await bestellDaten({ state: 'ordered', bestellung: null, offen: null }) })
const { wurzel } = await bestellSeite(await bestellDaten())
await alleAnhaken(fx, wurzel)
await fx.klick(bestellKnopf(fx, wurzel))
assert.equal(fx.finde(wurzel, '[data-aktion="bestellen"]'), null, 'die Bestellseite ist weg')
assert.match(fx.text(wurzel), /Der Terminplaner ist für dieses Projekt schon bestellt\./)
assert.match(fx.text(wurzel), /Lade die Seite neu/)
}))
test('Bestellen 409: eine inzwischen verlassene Ansicht (aktuell() falsch) wird nicht neu gezeichnet', mitDom(undefined, async (fx) => {
fx.antworten.push({ status: 409, body: { error: 'Nicht vollständig.', code: 'bestellung_offen', offen: [{ schritt: 'website', text: 'x' }] } }, { body: await bestellDaten() })
const daten = await bestellDaten()
const wurzel = h('div')
document.body.append(wurzel)
let aktuell = true
const ctx = huelle.baueCtx({ daten, nurLesen: false }, { projekt: { projectId: 'p1' }, aktuell: () => aktuell })
bestellen.renderBestellen(wurzel, { ctx, daten, zumSchritt() {} })
await alleAnhaken(fx, wurzel)
const knopf = bestellKnopf(fx, wurzel)
const klick = fx.klick(knopf)
aktuell = false
await klick
assert.equal(bestellKnopf(fx, wurzel), knopf, 'die alte Seite bleibt unberührt')
}))
// ------------------------------------------------------- statusKarte
const statusProjekt = (aenderung = {}) => ({
projectId: 'p1',
projectName: 'Salon Muster',
features: { termine: { state: 'ordered', orderedAt: '2026-10-05T10:00:00.000Z', ticketWoid: null, invoiceUrl: null, ...aenderung } },
})
const EINRICHTUNG = 'meist innerhalb von 1–2 Wochen'
function karte(aenderung = {}, optionen = {}, rueckruf = {}) {
const projekt = statusProjekt(aenderung)
const wurzel = h('div')
document.body.append(wurzel)
wurzel.append(bestellen.statusKarte({ projekt, bestellung: projekt.features.termine, einrichtung: EINRICHTUNG, danke: false, kundenansicht: false, ...optionen }, rueckruf))
return wurzel
}
test('statusKarte ordered: Pille „Zahlung offen“, Bestelldatum, SEPA-Hinweis, „Zahlung fortsetzen“ und Chat-Knopf', mitDom(undefined, (fx) => {
const wurzel = karte()
const el = fx.finde(wurzel, '.tm-statuskarte')
assert.ok(el)
assert.equal(fx.text(fx.finde(el, '.tm-pille')), 'Zahlung offen')
assert.match(fx.text(el), /Bestellt am\s*05\.10\.2026/)
assert.match(fx.text(el), /SEPA[^.]*einige Tage/)
assert.equal(fx.text(fx.finde(el, '[data-aktion="fortsetzen"]')), 'Zahlung fortsetzen')
assert.equal(fx.text(fx.finde(el, '[data-view="chat"]')).includes('Chat'), true)
assert.doesNotMatch(fx.text(el), /Ticketnummer|In Einrichtung|Danke!/)
assert.equal(fx.alle(el, 'a').length, 0, 'in ordered gibt es keine Rechnung')
}))
test('statusKarte ordered: „Zahlung fortsetzen“ sendet fortsetzenKoerper und navigiert zur offenen Sitzung', mitDom({ antworten: [{ body: { url: KNOPF_URL } }] }, async (fx) => {
const wurzel = karte()
const knopf = fx.finde(wurzel, '[data-aktion="fortsetzen"]')
const klick = fx.klick(knopf)
assert.equal(knopf.disabled, true, 'ein Klick sperrt ihn')
await klick
assert.deepEqual(fx.fetchLog, [{ method: 'POST', url: '/api/funktionen/termine/checkout', body: { projectId: 'p1', fortsetzen: true } }])
assert.equal(fx.window.location.href, KNOPF_URL)
}))
test('statusKarte ordered: 409 zustimmung_fehlt ⇒ weiter() lädt neu und zeigt das Bestellformular, keine Navigation', mitDom({
antworten: [{ status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }],
}, async (fx) => {
let weiter = 0
const wurzel = karte({}, {}, { weiter: async () => { weiter += 1 } })
await fx.klick(fx.finde(wurzel, '[data-aktion="fortsetzen"]'))
assert.equal(weiter, 1)
assert.equal(fx.window.location.href, 'http://localhost/')
}))
test('statusKarte ordered: ohne weiter() oder bei anderem Fehler steht die Meldung am Knopf, der Knopf ist wieder frei', mitDom({
antworten: [{ status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }, { status: 409, body: { error: 'Der Terminplaner ist für dieses Projekt schon bestellt.' } }, { body: { url: 'http://unsicher.example/x' } }],
}, async (fx) => {
let weiter = 0
const ohne = karte()
const knopf = fx.finde(ohne, '[data-aktion="fortsetzen"]')
await fx.klick(knopf)
assert.equal(fx.text(fx.finde(ohne, '[data-meldung="fortsetzen"]')), 'Bitte stimme der Erklärung zu, um zu bestellen.')
assert.equal(knopf.disabled, false)
const mit = karte({}, {}, { weiter: () => { weiter += 1 } })
await fx.klick(fx.finde(mit, '[data-aktion="fortsetzen"]'))
assert.equal(weiter, 0, 'weiter() nur bei zustimmung_fehlt')
assert.equal(fx.text(fx.finde(mit, '[data-meldung="fortsetzen"]')), 'Der Terminplaner ist für dieses Projekt schon bestellt.')
const unsicher = karte()
await fx.klick(fx.finde(unsicher, '[data-aktion="fortsetzen"]'))
assert.equal(fx.window.location.href, 'http://localhost/', 'keine http-Navigation')
assert.match(fx.text(fx.finde(unsicher, '[data-meldung="fortsetzen"]')), /Zahlungsseite/)
}))
test('statusKarte ordered: in der Kundenansicht ist „Zahlung fortsetzen“ gesperrt, mit Hinweis und ohne Anfrage', mitDom(undefined, async (fx) => {
const wurzel = karte({}, { kundenansicht: true })
const knopf = fx.finde(wurzel, '[data-aktion="fortsetzen"]')
assert.equal(knopf.disabled, true)
assert.match(fx.text(wurzel), /In der Kundenansicht kann nicht bestellt werden\./)
await fx.klick(knopf)
assert.equal(fx.fetchLog.length, 0)
}))
test('statusKarte setup: „In Einrichtung“, Ticketnummer, Bestelldatum, Rechnungslink, Zeiteinschätzung, gesperrte Einstellungen, kein „Zahlung fortsetzen“', mitDom(undefined, (fx) => {
const wurzel = karte({ state: 'setup', ticketWoid: 100342, invoiceUrl: 'https://invoice.stripe.test/i/in_1' })
const el = fx.finde(wurzel, '.tm-statuskarte')
assert.equal(fx.text(fx.finde(el, '.tm-pille')), 'In Einrichtung')
assert.match(fx.text(el), /Ticketnummer\s*#100342/)
assert.match(fx.text(el), /Bestellt am\s*05\.10\.2026/)
assert.match(fx.text(el), new RegExp(`Zeiteinschätzung\\s*${EINRICHTUNG}`))
assert.ok(fx.text(el).includes('Deine Einstellungen sind jetzt gesperrt, Änderungen laufen über den Chat.'))
const rechnung = fx.alle(el, 'a')
assert.deepEqual(rechnung.map((a) => [fx.text(a), a.getAttribute('href'), a.getAttribute('rel'), a.getAttribute('target')]), [['Rechnung ansehen', 'https://invoice.stripe.test/i/in_1', 'noopener noreferrer', '_blank']])
assert.equal(fx.finde(el, '[data-aktion="fortsetzen"]'), null)
assert.ok(fx.finde(el, '[data-view="chat"]'))
assert.doesNotMatch(fx.text(el), /SEPA|Zahlung offen/)
}))
test('statusKarte setup: ohne Ticket „Wir legen dein Ticket gerade an.“, ohne sichere Rechnungsadresse kein Link', mitDom(undefined, (fx) => {
const ohneTicket = fx.text(karte({ state: 'setup', ticketWoid: null }))
assert.ok(ohneTicket.includes('Wir legen dein Ticket gerade an.'))
assert.doesNotMatch(ohneTicket, /#/)
for (const invoiceUrl of ['javascript:alert(1)', 'http://invoice.stripe.test/x', '//evil.example/i', null, '']) {
assert.equal(fx.alle(karte({ state: 'setup', invoiceUrl }), 'a').length, 0, `kein Link für ${JSON.stringify(invoiceUrl)}`)
}
}))
test('statusKarte: „Danke!“ nach der Rückkehr von Stripe, in ordered und setup, sonst nicht', mitDom(undefined, (fx) => {
const DANKE = 'Danke! Sobald die Zahlung da ist, legen wir los.'
for (const state of ['ordered', 'setup']) {
const mit = karte({ state }, { danke: true })
assert.equal(fx.text(fx.finde(mit, '[data-danke]')), DANKE, state)
assert.equal(fx.finde(mit, '[data-danke]').getAttribute('role'), 'status')
assert.ok(!fx.text(karte({ state }, { danke: false })).includes('Danke!'))
}
}))
test('statusKarte: Texte vom Server (Ticketnummer, Datum, Einrichtung) nur als Text', mitDom(undefined, (fx) => {
const boese = '<img src=x onerror=alert(1)>'
const projekt = statusProjekt({ state: 'setup', ticketWoid: boese, orderedAt: boese })
const wurzel = h('div')
document.body.append(wurzel)
wurzel.append(bestellen.statusKarte({ projekt, bestellung: projekt.features.termine, einrichtung: boese, danke: false, kundenansicht: false }, {}))
assert.equal(fx.alle(wurzel, 'img').length, 0)
assert.ok(fx.text(wurzel).includes(boese))
}))