Files
Webklar-Kundenbereich/test/termineFragebogenView.test.js
T
ClaudeandClaude Opus 5.5 af63b69ec1 Merge origin/main in feature/terminplaner-tp2 (Analyse-Cockpit, Subdomain-Sperre, Projekte)
Konflikte: funktionen.js folgt der Aufteilung aus main (fn-util.js, analyse.js); der Link
'Zu Verträge' der Analyse-Seite kommt jetzt auch in analyse.js aus vertraege-link.js.
Checkout und Team-Ansicht übernehmen assertOwnProject(..., { forNewOrder: true }) und
includeArchived aus main. Test-Pins der absoluten Importe nachgezogen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:11:58 +00:00

3652 lines
226 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 * as fragebogenAnsicht from '../public/termine-fragebogen.js'
import { FENSTER_FEHLER } from '../public/termine-view.js'
import { h, request } from '../public/termine-dom.js'
import { neuerSpeicher } from '../public/termine-speichern.js'
import { stornoMuster } from '../public/termine-gaestetexte.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('B5: zusammenfassung nennt in „Branche und Betrieb“ ausdrücklich „Kleinunternehmer: ja“ oder „nein“; ohne Angabe keine Zeile', () => {
const zeilen = (einstellung, fragebogen) => gruppe(zusammenfassung(einstellung, fragebogen, BRANCHEN), 'branche').zeilen
const nein = stand()
assert.equal(nein.einstellung.betrieb.kleinunternehmer, false)
assert.deepEqual(zeilen(nein.einstellung, nein.fragebogen).filter((z) => z.startsWith('Kleinunternehmer')), ['Kleinunternehmer: nein'])
const ja = stand((e) => { e.betrieb.kleinunternehmer = true })
assert.deepEqual(zeilen(ja.einstellung, ja.fragebogen).filter((z) => z.startsWith('Kleinunternehmer')), ['Kleinunternehmer: ja'])
assert.deepEqual(zeilen(ja.einstellung, ja.fragebogen).slice(0, 4), ['Branche: Friseur und Salon', 'Betrieb: Salon Muster', 'Anschrift: Hauptstraße 1, 67655 Kaiserslautern', 'Kleinunternehmer: ja'], 'nach der Anschrift')
// fehlt die Angabe oder ist sie kein Wahrheitswert, wird nichts erfunden
for (const wert of [undefined, null, 'ja', 1]) {
const ohne = stand((e) => { e.betrieb.kleinunternehmer = wert })
assert.ok(!zeilen(ohne.einstellung, ohne.fragebogen).some((z) => z.startsWith('Kleinunternehmer')), String(wert))
}
const keine = zusammenfassung(null, null, BRANCHEN)
assert.ok(!JSON.stringify(keine).includes('Kleinunternehmer'))
})
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 '/fn-util.js'", "from '/analyse-view.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“ (mit zumSchritt)', mitDom(undefined, async (fx) => {
const daten = await bestellDaten({ einstellung: null, fragebogen: null })
const { wurzel, schritte } = await bestellSeite(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"]'))
assert.deepEqual(schritte, ['branche'])
}))
test('B4: ohne zumSchritt (keine Funktion) zeichnet die Bestellseite weder „Ändern“ noch „Zum Schritt“ - keine Knöpfe, die nichts tun', mitDom(undefined, async (fx) => {
const offen = [{ schritt: 'website', text: 'Bitte trage die Adresse deines Impressums ein.' }, { schritt: 'bestellen', text: 'Das Abo konnte gerade nicht geprüft werden.' }]
for (const zumSchritt of [undefined, null, 'website', 42, {}]) {
const daten = await bestellDaten({ offen })
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, zumSchritt })
const name = JSON.stringify(zumSchritt)
assert.equal(fx.alle(wurzel, '.tm-bestellen-gruppe').length, 8, `${name}: die Zusammenfassung steht`)
assert.equal(fx.alle(wurzel, '[data-aktion="aendern"]').length, 0, `${name}: kein „Ändern“`)
assert.equal(fx.alle(wurzel, '[data-aktion="zum-schritt"]').length, 0, `${name}: kein „Zum Schritt“`)
assert.deepEqual(fx.alle(wurzel, '.tm-bestellen-offen li span').map((span) => span.textContent), offen.map((p) => p.text), `${name}: die offenen Punkte bleiben als Text`)
}
// mit Funktion ist alles wie bisher da
const { wurzel } = await bestellSeite(await bestellDaten({ offen }))
assert.equal(fx.alle(wurzel, '[data-aktion="aendern"]').length, 8)
assert.deepEqual(fx.alle(wurzel, '[data-aktion="zum-schritt"]').map((k) => k.dataset.schritt), ['website'])
}))
test('B4: nach einem 409 bekommt die Meldung den Fokus (tabindex -1), auf der neuen Seite, bei fehlgeschlagenem Neuladen und auf der Hinweiskarte', mitDom(undefined, async (fx) => {
const fokusAufMeldung = (wurzel, was) => {
const meldung = fx.finde(wurzel, '[data-meldung="bestellen"]')
assert.ok(meldung, was)
assert.equal(meldung.getAttribute('tabindex'), '-1', `${was}: fokussierbar, aber nicht im Tabulatorweg`)
assert.equal(fx.document.activeElement, meldung, `${was}: Fokus auf der Meldung`)
}
// a) Seite wird neu gezeichnet
fx.antworten.push({ status: 409, body: { error: 'Die Bestellung ist noch nicht vollständig.', code: 'bestellung_offen', offen: [{ schritt: 'website', text: 'x' }] } }, { body: await bestellDaten() })
const a = await bestellSeite(await bestellDaten())
await alleAnhaken(fx, a.wurzel)
await fx.klick(bestellKnopf(fx, a.wurzel))
fokusAufMeldung(a.wurzel, 'neu gezeichnet')
// b) Neuladen scheitert: dieselbe Seite, Meldung mit Fokus
fx.antworten.push({ status: 409, body: { error: 'Dein Wartungs-Abo ist gekündigt.', code: 'gekuendigt' } }, { status: 500, body: { error: 'Server kaputt.' } })
const b = await bestellSeite(await bestellDaten())
await alleAnhaken(fx, b.wurzel)
await fx.klick(bestellKnopf(fx, b.wurzel))
fokusAufMeldung(b.wurzel, 'Neuladen gescheitert')
// c) die Bestellung ist inzwischen aufgegeben: die Karte mit der Meldung bekommt den Fokus
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 c = await bestellSeite(await bestellDaten())
await alleAnhaken(fx, c.wurzel)
await fx.klick(bestellKnopf(fx, c.wurzel))
const karteEl = fx.finde(c.wurzel, '.tm-info')
assert.ok(karteEl)
assert.equal(karteEl.getAttribute('tabindex'), '-1')
assert.equal(fx.document.activeElement, karteEl, 'Fokus auf der Hinweiskarte')
}))
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('B3: zurück aus dem Bfcache (pageshow persisted) lädt die Karte „Zahlung fortsetzen“ neu - nicht gesperrt und ohne Meldung stehen lassen', mitDom({ antworten: [{ body: { url: KNOPF_URL } }] }, async (fx) => {
const wurzel = karte()
const knopf = fx.finde(wurzel, '[data-aktion="fortsetzen"]')
await fx.klick(knopf)
assert.equal(fx.window.location.href, KNOPF_URL)
assert.equal(knopf.disabled, true, 'bis zum Verlassen der Seite gesperrt')
// ein normales pageshow (erste Anzeige) ändert nichts
fx.window.dispatchEvent({ type: 'pageshow', persisted: false })
assert.deepEqual(fx.reloads, [])
// Zurück aus der Zahlungsseite: der Browser stellt die Seite samt gesperrtem Knopf wieder her
fx.window.dispatchEvent({ type: 'pageshow', persisted: true })
assert.equal(fx.reloads.length, 1, 'die Seite wird neu geladen')
}))
test('B3: eine Karte, die nicht mehr im Baum steht, lädt nichts neu und ihr Handler verschwindet', mitDom(undefined, (fx) => {
const wurzel = karte()
assert.equal(fx.window.anzahlHandler('pageshow'), 1)
wurzel.remove()
fx.window.dispatchEvent({ type: 'pageshow', persisted: true })
assert.deepEqual(fx.reloads, [])
assert.equal(fx.window.anzahlHandler('pageshow'), 0)
}))
test('B3: auch die Bestellseite lädt bei der Rückkehr aus dem Bfcache neu, solange sie im Baum steht', mitDom({ antworten: [{ body: { url: KNOPF_URL } }] }, async (fx) => {
const { wurzel } = await bestellSeite(await bestellDaten())
await alleAnhaken(fx, wurzel)
await fx.klick(bestellKnopf(fx, wurzel))
assert.equal(fx.window.location.href, KNOPF_URL)
assert.equal(bestellKnopf(fx, wurzel).disabled, true)
fx.window.dispatchEvent({ type: 'pageshow', persisted: false })
assert.deepEqual(fx.reloads, [])
fx.window.dispatchEvent({ type: 'pageshow', persisted: true })
assert.equal(fx.reloads.length, 1)
}))
test('statusKarte ordered: 409 zustimmung_fehlt ⇒ weiter({ bestellen: true, meldung }) 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) => {
const aufrufe = []
const wurzel = karte({}, {}, { weiter: async (fall) => { aufrufe.push(fall) } })
await fx.klick(fx.finde(wurzel, '[data-aktion="fortsetzen"]'))
assert.deepEqual(aufrufe, [{ bestellen: true, meldung: 'Bitte stimme der Erklärung zu, um zu bestellen.' }])
assert.equal(fx.window.location.href, 'http://localhost/')
}))
test('statusKarte ordered: jeder andere Fehler ruft weiter({ bestellen: false, meldung }) (R110), die Karte bleibt nie veraltet stehen', mitDom({
antworten: [
{ status: 400, 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.' } },
{ status: 500, body: {} },
],
}, async (fx) => {
const aufrufe = []
// wie im Browser zeichnet sich die Ansicht nach weiter() neu: der Knopf der alten Karte bleibt gesperrt, jeder Fall hat seine Karte
for (let i = 0; i < 3; i += 1) await fx.klick(fx.finde(karte({}, {}, { weiter: (fall) => { aufrufe.push(fall) } }), '[data-aktion="fortsetzen"]'))
assert.deepEqual(aufrufe, [
{ bestellen: false, meldung: 'Bitte stimme der Erklärung zu, um zu bestellen.' },
{ bestellen: false, meldung: 'Der Terminplaner ist für dieses Projekt schon bestellt.' },
{ bestellen: false, meldung: 'Fehler 500' },
], 'auch bei 400 (Zustand inzwischen setup oder active) und 409 ohne zustimmung_fehlt')
}))
test('statusKarte ordered: scheitert weiter() (Neuladen nicht möglich), bleibt die Karte mit dem Fehler am Knopf; ohne weiter() steht die Meldung des Servers dort', 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.' } },
{ 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) => {
const kaputt = () => { throw new Error('Keine Verbindung. Bitte versuch es gleich noch einmal.') }
const mit = karte({}, {}, { weiter: kaputt })
const meldungMit = fx.finde(mit, '[data-meldung="fortsetzen"]')
const knopfMit = fx.finde(mit, '[data-aktion="fortsetzen"]')
await fx.klick(knopfMit)
assert.equal(fx.text(meldungMit), 'Keine Verbindung. Bitte versuch es gleich noch einmal.', 'bei fehlender Zustimmung der Grund des Neuladens')
assert.equal(knopfMit.disabled, false)
await fx.klick(knopfMit)
assert.equal(fx.text(meldungMit), 'Der Terminplaner ist für dieses Projekt schon bestellt.', 'sonst der Fehler des Versuchs')
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 ohneZwei = karte()
await fx.klick(fx.finde(ohneZwei, '[data-aktion="fortsetzen"]'))
assert.equal(fx.text(fx.finde(ohneZwei, '[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: meldung steht beim Zeichnen am Knopf „Zahlung fortsetzen“ (als Text)', mitDom(undefined, (fx) => {
const wurzel = karte({}, { meldung: 'Deine Zahlung läuft schon. <b>x</b>' })
assert.equal(fx.text(fx.finde(wurzel, '[data-meldung="fortsetzen"]')), 'Deine Zahlung läuft schon. <b>x</b>')
assert.equal(fx.alle(wurzel, 'b').length, 0)
assert.equal(fx.text(fx.finde(karte(), '[data-meldung="fortsetzen"]')), '')
}))
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))
}))
// ------------------------------------------------------- Fragebogen-Oberfläche: Gerüst und Schritte 1 bis 3 (TP2, Task C9a)
// renderFragebogen(root, ctx) in public/termine-fragebogen.js. Fake-DOM und Fake-fetch wie oben; ctx kommt aus baueCtx der Hülle. Ohne
// ausdrückliches neuLaden-Protokoll ersetzt ein Stummel neuLaden (er liest nichts nach), damit fetchLog genau die Schreibzugriffe zeigt.
const FB_GET = '/api/termine/einstellungen?projectId=p1'
const FB_PATCH = ['PATCH', '/api/termine/einstellungen']
const FB_PUT = ['PUT', '/api/termine/fragebogen']
const FB_POST = ['POST', '/api/termine/einstellungen/vorlage']
const BETRIEB_KONTO = { name: 'Salon Muster', adresse: 'Hauptstraße 1, 67655 Kaiserslautern', email: 'salon@example.invalid', telefon: '06301 12345', websiteUrl: '' }
/** Antwort von GET /api/termine/einstellungen im Zustand off mit Entwurf (alles, was der Fragebogen aus den Daten liest). */
const fbDaten = (aenderung = {}) => ({
state: 'off',
einstellung: salon(),
version: 3,
fragebogen: { ...leerFragebogen(), branche: 'salon' },
branchen: BRANCHEN.map(({ key, name, gesundheit }) => ({ key, name, gesundheit })),
vorlagen: [{ key: 'salon', name: 'Friseur und Salon', beschreibung: 'Haarschnitt, Färben und Bartpflege.' }],
kundenansicht: false,
fehlt: [],
abo: { ok: true },
offen: [],
bestellung: null,
...aenderung,
})
/** Daten ohne Entwurf: der Kunde sieht die Branchenwahl. */
const fbStart = (aenderung = {}) => fbDaten({ einstellung: null, version: 0, fragebogen: leerFragebogen(), betriebVorschlag: BETRIEB_KONTO, ...aenderung })
/** Fragebogen, dessen erste n Schritte bestätigt sind. */
const fbBis = (n, aenderung = {}) => ({ ...leerFragebogen(), branche: 'salon', bestaetigt: SCHRITTE.slice(0, n), ...aenderung })
const fbAntwort = (n, aenderung = {}) => ({ body: { fragebogen: fbBis(n, aenderung), offen: [] } })
const fbPatchAntwort = (version = 4, einstellung = salon()) => ({ body: { einstellung, version, fehlt: [] } })
/** Fragebogen in den Baum hängen. => { wurzel, ctx, s }; stumm: neuLaden liest nichts nach (Standard). */
function fbSeite(daten, { stumm = true } = {}) {
const wurzel = h('div')
document.body.append(wurzel)
const s = { daten, nurLesen: false, meldungEl: h('div', { tabindex: '-1' }) }
document.body.append(s.meldungEl)
const ctx = huelle.baueCtx(s, { projekt: { projectId: 'p1' }, aktuell: () => true })
if (stumm) ctx.neuLaden = async () => ctx.daten
fragebogenAnsicht.renderFragebogen(wurzel, ctx)
return { wurzel, ctx, s }
}
const fbSchritt = (fx, wurzel) => fx.finde(wurzel, 'li[aria-current="step"]')?.dataset.schritt
const fbWeiter = (fx, wurzel) => fx.finde(wurzel, '[data-aktion="weiter"]')
const fbAufrufe = (fx) => fx.fetchLog.map((a) => [a.method, a.url])
const fbKnoepfe = (fx, wurzel) => fx.alle(wurzel, 'button').map((k) => fx.text(k))
const fbRadio = (fx, baum, wert) => fx.finde(baum, `input[type="radio"][value="${wert}"]`)
const fbGehe = (fx, wurzel, key) => fx.klick(fx.finde(wurzel, `li[data-schritt="${key}"] button`))
const fbFeld = (fx, baum, name) => fx.finde(baum, `[data-feld="${name}"]`)
const fbFehler = (fx, baum, name) => fx.text(fx.finde(baum, `[data-fehler="${name}"]`))
// ------------------------------------------------------- Gerüst
test('renderFragebogen: Fortschrittsleiste ist eine <ol> mit neun Punkten, genau einer trägt aria-current="step"', mitDom(undefined, (fx) => {
const { wurzel } = fbSeite(fbDaten({ fragebogen: fbBis(2) }))
const liste = fx.finde(wurzel, 'ol.tm-schritte')
assert.ok(liste, 'geordnete Liste')
const punkte = fx.alle(liste, 'li.tm-schritt')
assert.deepEqual(punkte.map((li) => li.dataset.schritt), [...SCHRITTE, 'bestellen'])
assert.deepEqual(punkte.map((li) => fx.text(fx.finde(li, '.tm-schritt-titel'))), [...SCHRITTE.map((key) => SCHRITT_TITEL[key]), SCHRITT_TITEL.bestellen])
assert.deepEqual(fx.alle(wurzel, '[aria-current]').map((el) => [el.tagName, el.getAttribute('aria-current'), el.dataset.schritt]), [['LI', 'step', 'leistungen']])
assert.deepEqual(punkte.map((li) => li.hasAttribute('data-erledigt')), [true, true, false, false, false, false, false, false, false], 'erledigt: nur die bestätigten')
assert.match(fx.text(punkte[0]), /erledigt/, 'auch für den Screenreader')
}))
test('renderFragebogen: Handy-Zähler „Schritt n von 8“ und Titel des Schritts als Überschrift', mitDom(undefined, (fx) => {
const { wurzel } = fbSeite(fbDaten({ fragebogen: fbBis(2) }))
assert.equal(fx.text(fx.finde(wurzel, '.tm-schritt-stand')), 'Schritt 3 von 8')
assert.equal(fx.text(fx.finde(wurzel, 'h3')), 'Leistungen')
const { wurzel: start } = fbSeite(fbStart())
assert.equal(fx.text(fx.finde(start, '.tm-schritt-stand')), 'Schritt 1 von 8')
}))
test('renderFragebogen: Start beim ersten offenen Schritt (die erste Lücke, nicht die Zahl der Haken)', mitDom(undefined, (fx) => {
assert.equal(fbSchritt(fx, fbSeite(fbDaten({ fragebogen: fbBis(0) })).wurzel), 'branche')
assert.equal(fbSchritt(fx, fbSeite(fbDaten({ fragebogen: fbBis(2) })).wurzel), 'leistungen')
assert.equal(fbSchritt(fx, fbSeite(fbDaten({ fragebogen: fbBis(6) })).wurzel), 'benachrichtigung')
const luecke = { ...fbBis(0), bestaetigt: ['branche', 'leistungen', 'zeiten'] }
assert.equal(fbSchritt(fx, fbSeite(fbDaten({ fragebogen: luecke })).wurzel), 'wer', 'Lücke in der Mitte')
assert.equal(fbSchritt(fx, fbSeite(fbStart()).wurzel), 'branche', 'ohne Entwurf')
}))
test('renderFragebogen: sind alle Schritte bestätigt, steht die Bestellseite da', mitDom(undefined, async (fx) => {
const daten = await bestellDaten()
const { wurzel } = fbSeite(daten)
assert.equal(fbSchritt(fx, wurzel), 'bestellen')
assert.ok(fx.finde(wurzel, '.tm-bestellen'), 'renderBestellen hat die Seite gezeichnet')
assert.equal(fx.text(bestellKnopf(fx, wurzel)), daten.bestellung.consent.knopf)
assert.equal(fx.text(fx.finde(wurzel, '.tm-schritt-stand')), 'Prüfen und bestellen')
assert.equal(fx.alle(wurzel, '.tm-schritt[data-erledigt]').length, 8)
assert.deepEqual(fx.fetchLog, [], 'nichts nachgeladen')
}))
test('renderFragebogen: „Ändern“ und „Zum Schritt“ der Bestellseite springen in den Schritt', mitDom(undefined, async (fx) => {
const daten = await bestellDaten({ offen: [{ schritt: 'website', text: 'Bitte trage die Adresse deines Impressums ein.' }] })
const { wurzel } = fbSeite(daten)
await fx.klick(fx.finde(wurzel, '[data-aktion="aendern"][data-schritt="zeiten"]'))
assert.equal(fbSchritt(fx, wurzel), 'zeiten')
assert.equal(fx.finde(wurzel, '.tm-bestellen'), null, 'die Bestellseite ist weg')
assert.equal(fx.text(fx.finde(wurzel, 'h3')), 'Zeiten')
}))
test('renderFragebogen: freie Navigation über die Liste; der Fokus geht auf die Schrittüberschrift', mitDom(undefined, async (fx) => {
const { wurzel } = fbSeite(fbDaten({ fragebogen: fbBis(2) }))
await fx.klick(fx.finde(wurzel, 'li[data-schritt="wer"] button'))
assert.equal(fbSchritt(fx, wurzel), 'wer')
const kopf = fx.finde(wurzel, 'h3')
assert.equal(fx.text(kopf), 'Wer wird gebucht')
assert.equal(kopf.getAttribute('tabindex'), '-1')
assert.ok(document.activeElement === kopf)
assert.deepEqual(fx.fetchLog, [], 'Navigation allein ruft nichts auf')
}))
test('renderFragebogen: die Leiste führt auch zur Bestellseite; dort geht der Fokus auf die Überschrift', mitDom(undefined, async (fx) => {
const { wurzel } = fbSeite(await bestellDaten({ fragebogen: fbBis(2) }))
assert.equal(fbSchritt(fx, wurzel), 'leistungen')
await fx.klick(fx.finde(wurzel, 'li[data-schritt="bestellen"] button'))
assert.equal(fbSchritt(fx, wurzel), 'bestellen')
assert.ok(fx.finde(wurzel, '.tm-bestellen'))
assert.ok(document.activeElement === fx.finde(wurzel, 'h3'), 'Fokus auf der Überschrift der Bestellseite')
assert.deepEqual(fx.fetchLog, [])
}))
test('renderFragebogen: der erste Aufruf nimmt keinen Fokus, nur der Wechsel', mitDom(undefined, (fx) => {
const { wurzel } = fbSeite(fbDaten({ fragebogen: fbBis(2) }))
assert.ok(document.activeElement !== fx.finde(wurzel, 'h3'))
}))
test('renderFragebogen: ohne Entwurf führt die Liste nur in die Branchenwahl (die anderen Schritte brauchen eine Einstellung)', mitDom(undefined, async (fx) => {
const { wurzel } = fbSeite(fbStart())
const knoepfe = fx.alle(wurzel, '.tm-schritt button')
assert.deepEqual(knoepfe.map((k) => k.disabled), [false, true, true, true, true, true, true, true, true])
await fx.klick(knoepfe[3])
assert.equal(fbSchritt(fx, wurzel), 'branche')
}))
test('renderFragebogen: Hinweis zur Aufbewahrung des Entwurfs steht auf jedem Schritt', mitDom(undefined, async (fx) => {
const HINWEIS = 'Wir speichern deinen Entwurf bis zu 90 Tage nach deiner letzten Änderung.'
const { wurzel } = fbSeite(fbDaten({ fragebogen: fbBis(2) }))
assert.ok(fx.text(wurzel).includes(HINWEIS))
await fx.klick(fx.finde(wurzel, 'li[data-schritt="bestellen"] button'))
assert.ok(fx.text(wurzel).includes(HINWEIS), 'auch auf der Bestellseite')
assert.ok(fx.text(fbSeite(fbStart()).wurzel).includes(HINWEIS), 'auch bei der Branchenwahl')
}))
test('renderFragebogen: Kundenansicht zeigt den Hinweis, sonst nicht', mitDom(undefined, (fx) => {
const mit = fbSeite(fbDaten({ kundenansicht: true })).wurzel
assert.match(fx.text(fx.finde(mit, '.tm-box[role="note"]')), /Kundenansicht/)
assert.equal(fx.finde(fbSeite(fbDaten()).wurzel, '.tm-box[role="note"]'), null)
}))
// ------------------------------------------------------- Schritt 1: Branche und Betrieb, ohne Entwurf
test('Schritt 1 ohne Entwurf: Radiogruppe aus daten.branchen, Betrieb mit Vorschlag, Knopf „Mit dieser Branche starten“ gesperrt bis zur Wahl', mitDom(undefined, async (fx) => {
const { wurzel } = fbSeite(fbStart())
const radios = fx.alle(wurzel, 'input[type="radio"]')
assert.deepEqual(radios.map((r) => r.getAttribute('value')), BRANCHEN.map((b) => b.key))
assert.equal(new Set(radios.map((r) => r.name)).size, 1, 'eine Gruppe')
assert.ok(radios.every((r) => !r.checked), 'keine Vorauswahl')
assert.deepEqual(fbKnoepfe(fx, wurzel).filter((t) => /starten|Weiter/.test(t)), ['Mit dieser Branche starten'])
const start = fx.finde(wurzel, '[data-aktion="branche-starten"]')
assert.equal(start.disabled, true)
assert.deepEqual(felderVon(fx, wurzel).filter((n) => n.startsWith('betrieb.')), ['betrieb.name', 'betrieb.adresse', 'betrieb.kleinunternehmer'], 'nur diese drei Betriebsfelder')
assert.equal(fbFeld(fx, wurzel, 'betrieb.name').value, BETRIEB_KONTO.name, 'betriebVorschlag')
assert.equal(fbFeld(fx, wurzel, 'betrieb.adresse').value, BETRIEB_KONTO.adresse)
assert.equal(fbFeld(fx, wurzel, 'betrieb.kleinunternehmer').checked, false)
await fx.klick(fbRadio(fx, wurzel, 'salon'))
assert.equal(fx.finde(wurzel, '[data-aktion="branche-starten"]').disabled, false)
assert.ok(fx.text(wurzel).includes('Friseur und Salon'))
assert.ok(fx.text(wurzel).includes('Haarschnitt, Färben und Bartpflege.'), 'Beschreibung der Vorlage')
}))
test('Schritt 1 (a): Gesundheitsbereich zeigt den Erklärtext statt „Weiter“, es gibt keinen Start-Knopf und es geht keine Anfrage raus', mitDom(undefined, async (fx) => {
const { wurzel } = fbSeite(fbStart())
await fx.klick(fbRadio(fx, wurzel, 'salon'))
assert.ok(fx.finde(wurzel, '[data-aktion="branche-starten"]'))
await fx.klick(fbRadio(fx, wurzel, 'gesundheit'))
const hinweis = fx.finde(wurzel, '[data-hinweis="gesundheit"]')
assert.ok(hinweis, 'der Erklärtext steht im Baum')
assert.match(fx.text(hinweis), /Gesundheitsbereich/)
assert.match(fx.text(hinweis), /nicht verfügbar/)
assert.equal(fx.finde(wurzel, '[data-aktion="branche-starten"]'), null)
assert.deepEqual(fbKnoepfe(fx, wurzel).filter((t) => /Mit dieser Branche starten|Weiter/.test(t)), [])
// auch ein Absenden des Formulars (Enter in einem Feld) ruft nichts auf
await fx.sende(fx.finde(wurzel, 'form'), 'submit')
await fx.klick(fx.finde(wurzel, 'li[data-schritt="branche"] button'))
assert.deepEqual(fx.fetchLog, [], 'kein einziger Aufruf')
// zurück zu einer anderen Branche: Knopf wieder da, Erklärtext weg
await fx.klick(fbRadio(fx, wurzel, 'kosmetik'))
assert.ok(fx.finde(wurzel, '[data-aktion="branche-starten"]'))
assert.equal(fx.finde(wurzel, '[data-hinweis="gesundheit"]'), null)
}))
test('Schritt 1 starten: POST vorlage { branche, betrieb }, danach Neuladen und PUT mit „branche“ in bestaetigt, weiter zu Schritt 2', mitDom({
antworten: [
{ status: 201, body: { einstellung: salon(), version: 1, fehlt: [], fragebogen: fbBis(0) } },
{ body: fbDaten({ version: 1, fragebogen: fbBis(0) }) },
fbAntwort(1),
],
}, async (fx) => {
const { wurzel, ctx } = fbSeite(fbStart(), { stumm: false })
await fx.setze(fbFeld(fx, wurzel, 'betrieb.name'), 'Salon Neu')
await fx.klick(fbRadio(fx, wurzel, 'salon'))
await fx.klick(fx.finde(wurzel, '[data-aktion="branche-starten"]'))
assert.deepEqual(fbAufrufe(fx), [FB_POST, ['GET', FB_GET], FB_PUT])
assert.deepEqual(fx.fetchLog[0].body, { projectId: 'p1', branche: 'salon', betrieb: { name: 'Salon Neu', adresse: BETRIEB_KONTO.adresse, kleinunternehmer: false } })
assert.equal(fx.fetchLog[2].body.projectId, 'p1')
assert.deepEqual(fx.fetchLog[2].body.fragebogen.bestaetigt, ['branche'])
assert.equal(fx.fetchLog[2].body.fragebogen.branche, 'salon')
assert.equal(fbSchritt(fx, wurzel), 'wer')
assert.deepEqual(ctx.daten.fragebogen.bestaetigt, ['branche'], 'die Antwort des PUT gilt')
assert.ok(document.activeElement === fx.finde(wurzel, 'h3'))
}))
test('Schritt 1 starten: die Website des Projekts aus dem Vorschlag geht mit in betrieb (nur der Vorschlag kennt sie)', mitDom({
antworten: [
{ status: 201, body: { einstellung: salon(), version: 1, fehlt: [], fragebogen: fbBis(0) } },
{ body: fbDaten({ version: 1, fragebogen: fbBis(0) }) },
fbAntwort(1),
],
}, async (fx) => {
const { wurzel } = fbSeite(fbStart({ betriebVorschlag: { ...BETRIEB_KONTO, websiteUrl: 'https://salon.example.invalid' } }), { stumm: false })
await fx.klick(fbRadio(fx, wurzel, 'salon'))
await fx.klick(fx.finde(wurzel, '[data-aktion="branche-starten"]'))
assert.equal(fx.fetchLog[0].body.betrieb.websiteUrl, 'https://salon.example.invalid')
assert.deepEqual(Object.keys(fx.fetchLog[0].body.betrieb).sort(), ['adresse', 'kleinunternehmer', 'name', 'websiteUrl'])
}))
test('Schritt 1 starten: übernimmt der Server „Kleinunternehmer“ nicht (er kennt nur fünf Betriebsfelder), geht es in einem Nachtrag-PATCH nach', mitDom({
antworten: [
{ status: 201, body: { einstellung: salon(), version: 1, fehlt: [], fragebogen: fbBis(0) } },
{ body: fbDaten({ version: 1, fragebogen: fbBis(0) }) },
fbPatchAntwort(2),
{ body: fbDaten({ version: 2, fragebogen: fbBis(0) }) },
fbAntwort(1),
],
}, async (fx) => {
const { wurzel } = fbSeite(fbStart(), { stumm: false })
await fx.klick(fbFeld(fx, wurzel, 'betrieb.kleinunternehmer'))
await fx.klick(fbRadio(fx, wurzel, 'salon'))
await fx.klick(fx.finde(wurzel, '[data-aktion="branche-starten"]'))
assert.deepEqual(fbAufrufe(fx), [FB_POST, ['GET', FB_GET], FB_PATCH, ['GET', FB_GET], FB_PUT])
assert.equal(fx.fetchLog[0].body.betrieb.kleinunternehmer, true)
assert.equal(fx.fetchLog[2].body.version, 1)
assert.equal(fx.fetchLog[2].body.einstellung.betrieb.kleinunternehmer, true)
assert.equal(fbSchritt(fx, wurzel), 'wer')
}))
test('Schritt 1 starten: ein Fehler des Servers steht am Feld, danach kein Neuladen und kein PUT, der Knopf ist wieder frei', mitDom({
antworten: [{ status: 400, body: { error: 'Bitte gib den Namen an.', fehler: { 'betrieb.name': 'Bitte gib den Namen deines Betriebs an.' } } }],
}, async (fx) => {
const { wurzel } = fbSeite(fbStart(), { stumm: false })
await fx.klick(fbRadio(fx, wurzel, 'salon'))
await fx.klick(fx.finde(wurzel, '[data-aktion="branche-starten"]'))
assert.deepEqual(fbAufrufe(fx), [FB_POST])
assert.equal(fbFehler(fx, wurzel, 'betrieb.name'), 'Bitte gib den Namen deines Betriebs an.')
assert.equal(fbFeld(fx, wurzel, 'betrieb.name').getAttribute('aria-invalid'), 'true')
assert.equal(fx.finde(wurzel, '[data-aktion="branche-starten"]').disabled, false)
assert.equal(fbSchritt(fx, wurzel), 'branche')
}))
test('Schritt 1 starten in der Kundenansicht: POST und Neuladen ja, PUT nein, weiter zu Schritt 2', mitDom({
antworten: [
{ status: 201, body: { einstellung: salon(), version: 1, fehlt: [], fragebogen: fbBis(0) } },
{ body: fbDaten({ version: 1, fragebogen: fbBis(0), kundenansicht: true }) },
],
}, async (fx) => {
const { wurzel } = fbSeite(fbStart({ kundenansicht: true }), { stumm: false })
await fx.klick(fbRadio(fx, wurzel, 'salon'))
await fx.klick(fx.finde(wurzel, '[data-aktion="branche-starten"]'))
assert.deepEqual(fbAufrufe(fx), [FB_POST, ['GET', FB_GET]])
assert.equal(fbSchritt(fx, wurzel), 'wer')
}))
test('Schritt 1 starten: Fehlschlag des PUT nach angelegtem Entwurf zeichnet den Schritt mit Entwurf neu und meldet den Fehler', mitDom({
antworten: [
{ status: 201, body: { einstellung: salon(), version: 1, fehlt: [], fragebogen: fbBis(0) } },
{ body: fbDaten({ version: 1, fragebogen: fbBis(0) }) },
{ status: 500, body: { error: 'Fehler 500' } },
],
}, async (fx) => {
const { wurzel } = fbSeite(fbStart(), { stumm: false })
await fx.klick(fbRadio(fx, wurzel, 'salon'))
await fx.klick(fx.finde(wurzel, '[data-aktion="branche-starten"]'))
assert.equal(fbSchritt(fx, wurzel), 'branche')
assert.equal(fx.finde(wurzel, '[data-aktion="branche-starten"]'), null, 'der Entwurf besteht: keine zweite Anlage')
assert.ok(fbWeiter(fx, wurzel), 'stattdessen „Weiter“')
assert.equal(fbFehler(fx, wurzel, '_'), 'Fehler 500')
}))
// ------------------------------------------------------- Schritt 1 mit Entwurf: Branche wechseln
test('Schritt 1 mit Entwurf: gewählte Branche, Betriebsfelder, „Weiter“ speichert in einem PATCH und bestätigt mit PUT', mitDom({
antworten: [fbPatchAntwort(4), fbAntwort(1)],
}, async (fx) => {
const { wurzel } = fbSeite(fbDaten())
assert.match(fx.text(fx.finde(wurzel, '.tm-fb-branche')), /Friseur und Salon/)
assert.deepEqual(felderVon(fx, wurzel).filter((n) => n.startsWith('betrieb.')), ['betrieb.name', 'betrieb.adresse', 'betrieb.kleinunternehmer'])
assert.equal(fx.alle(wurzel, 'input[type="radio"]').length, 0, 'die Branche wechselt man nur über die Rückfrage')
await fx.setze(fbFeld(fx, wurzel, 'betrieb.adresse'), 'Neue Straße 2, 67655 Kaiserslautern')
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fbAufrufe(fx), [FB_PATCH, FB_PUT])
assert.equal(fx.fetchLog[0].body.einstellung.betrieb.adresse, 'Neue Straße 2, 67655 Kaiserslautern')
assert.equal(fx.fetchLog[0].body.version, 3)
assert.deepEqual(fx.fetchLog[1].body.fragebogen.bestaetigt, ['branche'])
assert.equal(fbSchritt(fx, wurzel), 'wer')
}))
test('Schritt 1 mit Entwurf: ohne gespeicherte Branche kein Aufruf, der Hinweis steht allgemein', mitDom(undefined, async (fx) => {
const { wurzel } = fbSeite(fbDaten({ fragebogen: { ...leerFragebogen(), branche: null } }))
assert.equal(fx.text(fx.finde(wurzel, '[data-aktion="branche-wechseln"]')), 'Branche wählen')
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fx.fetchLog, [])
assert.match(fbFehler(fx, wurzel, '_'), /Branche/)
}))
test('Branche wechseln: Rückfrage in der Seite mit dem Wortlaut, Abbrechen ruft nichts auf', mitDom(undefined, async (fx) => {
const { wurzel } = fbSeite(fbDaten({ fragebogen: fbBis(3) }))
await fbGehe(fx, wurzel, 'branche')
assert.equal(fx.finde(wurzel, 'form[aria-label="Branche wechseln?"]'), null)
await fx.klick(fx.finde(wurzel, '[data-aktion="branche-wechseln"]'))
const frage = fx.finde(wurzel, 'form[aria-label="Branche wechseln?"]')
assert.ok(frage, 'Rückfrage')
assert.match(fx.text(frage), /ersetzt deine Leistungen, Zeiten und Regeln/)
assert.match(fx.text(frage), /Bestätigungen gehen zurück/)
assert.ok(document.activeElement === frage)
assert.equal(frage.closest('form[aria-labelledby]'), null, 'nicht in das Formular des Schritts geschachtelt')
assert.equal(fx.finde(frage, '[data-aktion="branche-starten"]').disabled, true, 'bis zur Wahl gesperrt')
await fx.klick(fx.alle(frage, 'button').find((k) => fx.text(k) === 'Abbrechen'))
assert.equal(fx.finde(wurzel, 'form[aria-label="Branche wechseln?"]'), null)
assert.deepEqual(fx.fetchLog, [])
assert.ok(document.activeElement === fx.finde(wurzel, '[data-aktion="branche-wechseln"]'), 'Fokus zurück auf den Knopf')
}))
test('Branche wechseln: „Ja, Branche wechseln“ sendet ersetzen: true mit version, übernimmt den Betrieb und zeigt Schritt 1 neu; kein PUT', mitDom({
antworten: [
{ status: 201, body: { einstellung: salon(), version: 6, fehlt: [], fragebogen: fbBis(0, { branche: 'kosmetik' }) } },
// der Server übernimmt Impressum/Datenschutz/AGB/Kleinunternehmer nicht: im neuen Entwurf fehlen sie
{ body: fbDaten({ version: 6, fragebogen: fbBis(0, { branche: 'kosmetik' }), einstellung: { ...salon(), betrieb: { ...salon().betrieb, impressumUrl: '', datenschutzUrl: '' } } }) },
fbPatchAntwort(7),
{ body: fbDaten({ version: 7, fragebogen: fbBis(0, { branche: 'kosmetik' }) }) },
],
}, async (fx) => {
const daten = fbDaten({ fragebogen: fbBis(3), version: 5 })
const { wurzel } = fbSeite(daten, { stumm: false })
await fbGehe(fx, wurzel, 'branche')
await fx.klick(fx.finde(wurzel, '[data-aktion="branche-wechseln"]'))
const frage = fx.finde(wurzel, 'form[aria-label="Branche wechseln?"]')
await fx.klick(fbRadio(fx, frage, 'kosmetik'))
await fx.klick(fx.finde(frage, '[data-aktion="branche-starten"]'))
assert.deepEqual(fbAufrufe(fx), [FB_POST, ['GET', FB_GET], FB_PATCH, ['GET', FB_GET]])
const post = fx.fetchLog[0].body
assert.equal(post.ersetzen, true)
assert.equal(post.version, 5)
assert.equal(post.branche, 'kosmetik')
assert.equal(post.projectId, 'p1')
assert.equal(post.betrieb.name, BETRIEB.name)
assert.equal(post.betrieb.telefon, BETRIEB.telefon)
assert.equal(post.betrieb.email, BETRIEB.email)
assert.equal(post.betrieb.adresse, BETRIEB.adresse)
assert.equal(fx.fetchLog[2].body.einstellung.betrieb.impressumUrl, BETRIEB.impressumUrl, 'Nachtrag: was der Server nicht übernimmt, kommt zurück')
assert.equal(fx.fetchLog[2].body.einstellung.betrieb.datenschutzUrl, BETRIEB.datenschutzUrl)
assert.equal(fx.fetchLog[2].body.version, 6)
assert.equal(fbSchritt(fx, wurzel), 'branche', 'Schritt 1 neu, alle Bestätigungen sind weg')
assert.equal(fx.alle(wurzel, '.tm-schritt[data-erledigt]').length, 0)
assert.equal(fx.finde(wurzel, 'form[aria-label="Branche wechseln?"]'), null)
}))
test('Branche wechseln: Gesundheitsbereich in der Rückfrage ⇒ Erklärtext, kein Bestätigen-Knopf, kein Aufruf', mitDom(undefined, async (fx) => {
const { wurzel } = fbSeite(fbDaten())
await fx.klick(fx.finde(wurzel, '[data-aktion="branche-wechseln"]'))
const frage = fx.finde(wurzel, 'form[aria-label="Branche wechseln?"]')
await fx.klick(fbRadio(fx, frage, 'gesundheit'))
assert.ok(fx.finde(frage, '[data-hinweis="gesundheit"]'))
assert.equal(fx.finde(frage, '[data-aktion="branche-starten"]'), null)
await fx.sende(frage, 'submit')
assert.deepEqual(fx.fetchLog, [])
}))
test('Branche wechseln: 409 EINSTELLUNG_VERALTET zeigt den Hinweis „Neu laden“, die Rückfrage bleibt ohne Folgeaufruf', mitDom({
antworten: [{ status: 409, body: { error: 'Die Einstellungen wurden inzwischen geändert.', code: 'EINSTELLUNG_VERALTET' } }],
}, async (fx) => {
const { wurzel } = fbSeite(fbDaten(), { stumm: false })
await fx.klick(fx.finde(wurzel, '[data-aktion="branche-wechseln"]'))
const frage = fx.finde(wurzel, 'form[aria-label="Branche wechseln?"]')
await fx.klick(fbRadio(fx, frage, 'kosmetik'))
await fx.klick(fx.finde(frage, '[data-aktion="branche-starten"]'))
assert.deepEqual(fbAufrufe(fx), [FB_POST])
const konflikt = fx.finde(wurzel, '.tm-box-warn[role="alert"]')
assert.equal(konflikt.hidden, false)
assert.match(fx.text(konflikt), /Neu laden/)
}))
test('Branche wechseln: ein anderer Fehler steht in der Rückfrage', mitDom({
antworten: [{ status: 409, body: { error: 'Die Branche lässt sich jetzt nicht mehr wechseln.', code: 'nicht_ersetzbar' } }],
}, async (fx) => {
const { wurzel } = fbSeite(fbDaten(), { stumm: false })
await fx.klick(fx.finde(wurzel, '[data-aktion="branche-wechseln"]'))
const frage = fx.finde(wurzel, 'form[aria-label="Branche wechseln?"]')
await fx.klick(fbRadio(fx, frage, 'kosmetik'))
await fx.klick(fx.finde(frage, '[data-aktion="branche-starten"]'))
assert.equal(fbFehler(fx, frage, '_'), 'Die Branche lässt sich jetzt nicht mehr wechseln.')
assert.equal(fx.finde(frage, '[data-aktion="branche-starten"]').disabled, false)
}))
// ------------------------------------------------------- Schritt 2: Wer wird gebucht
const fbWerSeite = (aenderung = {}) => fbSeite(fbDaten({ fragebogen: fbBis(1), ...aenderung }))
const fbNamen = (fx, wurzel) => fbFeld(fx, wurzel, 'ressourcen')
test('Schritt 2: Einzelperson / Team / Plätze, vorbelegt aus den Ressourcen; die Namen stehen je Zeile', mitDom(undefined, async (fx) => {
const { wurzel } = fbWerSeite()
assert.equal(fbSchritt(fx, wurzel), 'wer')
assert.deepEqual(fx.alle(wurzel, 'input[name="termine-wer-art"]').map((r) => r.getAttribute('value')), ['einzel', 'team', 'plaetze'])
assert.equal(fbRadio(fx, wurzel, 'einzel').checked, true, 'eine Person: Einzelperson')
assert.equal(fbNamen(fx, wurzel).value, 'Stylist/in')
const e = salon()
e.ressourcen = zweiRessourcen()
const team = fbWerSeite({ einstellung: e }).wurzel
assert.equal(fbRadio(fx, team, 'team').checked, true)
assert.equal(fbNamen(fx, team).value, 'Anna\nBen')
const plaetze = salon()
plaetze.ressourcen = zweiRessourcen().map((r) => ({ ...r, typ: 'platz' }))
assert.equal(fbRadio(fx, fbWerSeite({ einstellung: plaetze }).wurzel, 'plaetze').checked, true)
}))
test('Schritt 2 (f): die Namen „Anna“ und „Ben“ ⇒ der PATCH enthält beide Ressourcen und jede Leistung ist beiden zugeordnet', mitDom({
antworten: [fbPatchAntwort(4), fbAntwort(2)],
}, async (fx) => {
const { wurzel, ctx } = fbWerSeite()
const alt = ctx.daten.einstellung.ressourcen[0]
await fx.klick(fbRadio(fx, wurzel, 'team'))
await fx.setze(fbNamen(fx, wurzel), 'Anna\nBen')
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fbAufrufe(fx), [FB_PATCH, FB_PUT])
const einstellung = fx.fetchLog[0].body.einstellung
assert.deepEqual(einstellung.ressourcen.map((r) => [r.name, r.typ, r.aktiv]), [['Anna', 'person', true], ['Ben', 'person', true]])
assert.equal(einstellung.ressourcen[0].id, alt.id, 'der erste Eintrag wird umbenannt: ID bleibt')
assert.deepEqual(einstellung.ressourcen[1].plan, einstellung.ressourcen[0].plan, 'der neue Eintrag übernimmt den Plan')
const ids = einstellung.ressourcen.map((r) => r.id)
assert.equal(new Set(ids).size, 2)
assert.ok(einstellung.leistungen.length > 0)
for (const l of einstellung.leistungen) assert.deepEqual([...l.ressourcen].sort(), [...ids].sort(), `${l.name}: beiden zugeordnet`)
assert.deepEqual(fx.fetchLog[1].body.fragebogen.bestaetigt, ['branche', 'wer'])
assert.equal(fbSchritt(fx, wurzel), 'leistungen')
}))
test('Schritt 2: entfernte Personen verschwinden aus den Leistungen und aus den Abwesenheiten; eine Abwesenheit für alle bleibt', mitDom({
antworten: [fbPatchAntwort(4), fbAntwort(2)],
}, async (fx) => {
const e = salon()
e.ressourcen = zweiRessourcen()
for (const l of e.leistungen) l.ressourcen = ['r_anna0001', 'r_ben00002']
e.abwesenheiten = [
{ id: 'a_test0001', ressourceId: 'r_ben00002', von: '2026-12-24', bis: '2026-12-26', titel: 'Urlaub Ben' },
{ id: 'a_test0002', ressourceId: 'r_anna0001', von: '2027-01-02', bis: '2027-01-03', titel: 'Urlaub Anna' },
{ id: 'a_test0003', ressourceId: null, von: '2027-01-04', bis: '2027-01-04', titel: 'Inventur' },
]
const { wurzel } = fbWerSeite({ einstellung: e })
await fx.setze(fbNamen(fx, wurzel), 'Anna')
await fx.klick(fbRadio(fx, wurzel, 'einzel'))
await fx.klick(fbWeiter(fx, wurzel))
const einstellung = fx.fetchLog[0].body.einstellung
assert.deepEqual(einstellung.ressourcen.map((r) => r.id), ['r_anna0001'])
for (const l of einstellung.leistungen) assert.deepEqual(l.ressourcen, ['r_anna0001'])
assert.deepEqual(einstellung.abwesenheiten.map((a) => a.id), ['a_test0002', 'a_test0003'])
}))
test('Schritt 2: Plätze ⇒ typ platz für alle Einträge', mitDom({ antworten: [fbPatchAntwort(4), fbAntwort(2)] }, async (fx) => {
const { wurzel } = fbWerSeite()
await fx.klick(fbRadio(fx, wurzel, 'plaetze'))
await fx.setze(fbNamen(fx, wurzel), 'Stuhl 1\nStuhl 2\n\n Stuhl 3 ')
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fx.fetchLog[0].body.einstellung.ressourcen.map((r) => [r.name, r.typ]), [['Stuhl 1', 'platz'], ['Stuhl 2', 'platz'], ['Stuhl 3', 'platz']])
}))
test('Schritt 2: mindestens ein Name, sonst Fehler am Feld und kein Aufruf', mitDom(undefined, async (fx) => {
const { wurzel } = fbWerSeite()
await fx.setze(fbNamen(fx, wurzel), ' \n \n')
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fx.fetchLog, [])
assert.match(fbFehler(fx, wurzel, 'ressourcen'), /mindestens einen Namen/)
assert.equal(fbNamen(fx, wurzel).getAttribute('aria-invalid'), 'true')
assert.equal(fbSchritt(fx, wurzel), 'wer')
}))
test('Schritt 2: Einzelperson mit zwei Namen, zu viele Einträge und zu lange Namen werden am Feld abgewiesen', mitDom(undefined, async (fx) => {
const { wurzel } = fbWerSeite()
await fx.setze(fbNamen(fx, wurzel), 'Anna\nBen')
await fx.klick(fbWeiter(fx, wurzel))
assert.match(fbFehler(fx, wurzel, 'ressourcen'), /Einzelperson/)
await fx.klick(fbRadio(fx, wurzel, 'team'))
await fx.setze(fbNamen(fx, wurzel), Array.from({ length: LIMITS.ressourcen + 1 }, (_, i) => `Person ${i + 1}`).join('\n'))
await fx.klick(fbWeiter(fx, wurzel))
assert.match(fbFehler(fx, wurzel, 'ressourcen'), new RegExp(String(LIMITS.ressourcen)))
await fx.setze(fbNamen(fx, wurzel), 'x'.repeat(MAX.ressourcenName + 1))
await fx.klick(fbWeiter(fx, wurzel))
assert.match(fbFehler(fx, wurzel, 'ressourcen'), new RegExp(String(MAX.ressourcenName)))
assert.deepEqual(fx.fetchLog, [], 'nichts davon geht raus')
}))
// ------------------------------------------------------- Schritt 3: Leistungen
const fbLeistungenSeite = (aenderung = {}) => fbSeite(fbDaten({ fragebogen: fbBis(2), ...aenderung }))
test('Schritt 3: der Reiter Leistungen steckt unverändert im Schritt, dazu der Haken „Preise geprüft“', mitDom(undefined, (fx) => {
const { wurzel } = fbLeistungenSeite()
assert.equal(fbSchritt(fx, wurzel), 'leistungen')
assert.ok(fx.finde(wurzel, '[data-werkzeug="leistung-neu"]'), '+ Leistung anlegen')
assert.equal(fx.alle(wurzel, 'li.tm-eintrag[data-id]').length, salon().leistungen.length, 'die Leistungen der Vorlage')
const haken = fbFeld(fx, wurzel, 'preiseGeprueft')
assert.equal(haken.type, 'checkbox')
assert.equal(haken.checked, false)
assert.equal(fx.text(haken.closest('label')), 'Ich habe die Preise geprüft (die Preise der Vorlage sind Platzhalter)')
assert.equal(fbSeite(fbDaten({ fragebogen: fbBis(2, { preiseGeprueft: true }) })).wurzel.querySelector('[data-feld="preiseGeprueft"]').checked, true, 'wie gespeichert')
const form = fbWeiter(fx, wurzel).closest('form')
assert.equal(form.querySelector('form'), null)
assert.equal(fx.alle(form, '.tm-eintrag').length, 0, 'die Leistungen stehen nicht im Formular des Schritts (kein verschachteltes Formular)')
}))
test('Schritt 3 (c): mit Preisen ohne den Haken meldet „Weiter“ den Fehler am Haken und ruft nichts auf', mitDom(undefined, async (fx) => {
const { wurzel } = fbLeistungenSeite()
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fx.fetchLog, [])
assert.match(fbFehler(fx, wurzel, 'preiseGeprueft'), /Preise/)
assert.equal(fbFeld(fx, wurzel, 'preiseGeprueft').getAttribute('aria-invalid'), 'true')
assert.ok(document.activeElement === fbFeld(fx, wurzel, 'preiseGeprueft'))
assert.equal(fbSchritt(fx, wurzel), 'leistungen')
}))
test('Schritt 3 (c): mit dem Haken genau ein PUT (kein PATCH) mit preiseGeprueft: true und dem Schritt in bestaetigt', mitDom({
antworten: [fbAntwort(3, { preiseGeprueft: true })],
}, async (fx) => {
const { wurzel } = fbLeistungenSeite()
await fx.klick(fbFeld(fx, wurzel, 'preiseGeprueft'))
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fbAufrufe(fx), [FB_PUT])
assert.equal(fx.fetchLog[0].body.projectId, 'p1')
assert.equal(fx.fetchLog[0].body.fragebogen.preiseGeprueft, true)
assert.deepEqual(fx.fetchLog[0].body.fragebogen.bestaetigt, ['branche', 'wer', 'leistungen'])
assert.equal(fbSchritt(fx, wurzel), 'zeiten')
}))
test('Schritt 3: ohne Preise braucht „Weiter“ den Haken nicht', mitDom({ antworten: [fbAntwort(3)] }, async (fx) => {
const e = salon()
for (const l of e.leistungen) Object.assign(l, { preisArt: 'keiner', preisCent: 0 })
const { wurzel } = fbLeistungenSeite({ einstellung: e })
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fbAufrufe(fx), [FB_PUT])
assert.equal(fx.fetchLog[0].body.fragebogen.preiseGeprueft, false)
}))
test('Schritt 3: ohne sichtbare Leistung (alle inaktiv oder ohne aktive Person) meldet „Weiter“ den Fehler und ruft nichts auf', mitDom(undefined, async (fx) => {
const keine = salon()
keine.leistungen = []
const inaktiv = salon()
for (const l of inaktiv.leistungen) l.aktiv = false
const ohnePerson = salon()
ohnePerson.ressourcen[0].aktiv = false
for (const e of [keine, inaktiv, ohnePerson]) {
const { wurzel } = fbLeistungenSeite({ einstellung: e })
await fx.klick(fbFeld(fx, wurzel, 'preiseGeprueft'))
await fx.klick(fbWeiter(fx, wurzel))
assert.match(fbFehler(fx, wurzel, 'leistungen'), /mindestens eine aktive Leistung/)
}
assert.deepEqual(fx.fetchLog, [])
}))
test('Schritt 3: Leistungen, die der Kunde im Schritt ändert, zählen sofort (Prüfung liest ctx.daten frisch)', mitDom({ antworten: [fbAntwort(3)] }, async (fx) => {
const { wurzel, ctx } = fbLeistungenSeite()
ctx.daten.einstellung.leistungen = []
await fx.klick(fbWeiter(fx, wurzel))
assert.match(fbFehler(fx, wurzel, 'leistungen'), /mindestens eine aktive Leistung/)
ctx.daten.einstellung.leistungen = salon().leistungen.map((l) => ({ ...l, ressourcen: ctx.daten.einstellung.ressourcen.map((r) => r.id), preisArt: 'keiner', preisCent: 0 }))
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fbAufrufe(fx), [FB_PUT])
}))
// ------------------------------------------------------- „Weiter“ allgemein (Schritt 7 als Schritt mit Feldern)
const fbSiebenSeite = (aenderung = {}) => fbSeite(fbDaten({ fragebogen: fbBis(6), ...aenderung }))
test('Schritt 7: Betrieb nur mit E-Mail, Anrede und Farbe, Erinnerung; Zurück und Weiter', mitDom(undefined, (fx) => {
const { wurzel } = fbSiebenSeite()
assert.equal(fbSchritt(fx, wurzel), 'benachrichtigung')
assert.deepEqual(felderVon(fx, wurzel).filter((n) => n.startsWith('betrieb.')), ['betrieb.email'])
assert.ok(fbFeld(fx, wurzel, 'anrede'))
assert.ok(fbFeld(fx, wurzel, 'erinnerung.anbieten'))
assert.deepEqual(fbKnoepfe(fx, wurzel).filter((t) => /Zurück|Weiter/.test(t)), ['Zurück', 'Weiter'])
}))
test('„Weiter“ (b): genau ein PATCH, danach ein PUT mit dem Schritt in bestaetigt; PATCH und PUT in dieser Reihenfolge', mitDom({
antworten: [fbPatchAntwort(4), fbAntwort(7)],
}, async (fx) => {
const { wurzel } = fbSiebenSeite()
await fx.setze(fbFeld(fx, wurzel, 'betrieb.email'), 'neu@example.invalid')
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fx.fetchLog.map((a) => [a.method, a.url]), [FB_PATCH, FB_PUT])
const [patch, put] = fx.fetchLog
assert.equal(patch.body.projectId, 'p1')
assert.equal(patch.body.version, 3)
assert.equal(patch.body.einstellung.betrieb.email, 'neu@example.invalid')
assert.equal(put.body.projectId, 'p1')
assert.deepEqual(put.body.fragebogen.bestaetigt, [...SCHRITTE.slice(0, 6), 'benachrichtigung'])
assert.equal(fbSchritt(fx, wurzel), 'website')
assert.ok(document.activeElement === fx.finde(wurzel, 'h3'), 'Fokus auf der Überschrift des nächsten Schritts')
}))
test('„Weiter“ (b): liest der Speicher nach dem PATCH neu (GET), steht der PUT danach und trägt den frischen Fragebogen', mitDom({
antworten: [fbPatchAntwort(4), { body: fbDaten({ version: 4, fragebogen: fbBis(6, { bundeslandGewaehlt: true }) }) }, fbAntwort(7, { bundeslandGewaehlt: true })],
}, async (fx) => {
const { wurzel } = fbSeite(fbDaten({ fragebogen: fbBis(6) }), { stumm: false })
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fbAufrufe(fx), [FB_PATCH, ['GET', FB_GET], FB_PUT])
assert.equal(fx.fetchLog[2].body.fragebogen.bundeslandGewaehlt, true, 'der PUT baut auf dem frisch geladenen Stand auf')
}))
test('„Weiter“ (b): antwortet der PATCH mit 400 und fehler, folgt kein PUT, der Fehler steht am Feld, der Schritt bleibt', mitDom({
antworten: [{ status: 400, body: { error: 'Bitte gib eine gültige E-Mail-Adresse an.', fehler: { 'betrieb.email': 'Bitte gib eine gültige E-Mail-Adresse an.' } } }],
}, async (fx) => {
const { wurzel } = fbSiebenSeite()
await fx.setze(fbFeld(fx, wurzel, 'betrieb.email'), 'kaputt')
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fbAufrufe(fx), [FB_PATCH], 'kein PUT')
assert.equal(fbFehler(fx, wurzel, 'betrieb.email'), 'Bitte gib eine gültige E-Mail-Adresse an.')
assert.equal(fbFeld(fx, wurzel, 'betrieb.email').getAttribute('aria-invalid'), 'true')
assert.equal(fbSchritt(fx, wurzel), 'benachrichtigung')
assert.equal(fbWeiter(fx, wurzel).disabled, false, 'wieder frei')
}))
test('„Weiter“: 409 EINSTELLUNG_VERALTET zeigt den Hinweis „Neu laden“ ohne PUT', mitDom({
antworten: [{ status: 409, body: { error: 'Die Einstellungen wurden inzwischen geändert.', code: 'EINSTELLUNG_VERALTET' } }],
}, async (fx) => {
const { wurzel } = fbSiebenSeite()
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fbAufrufe(fx), [FB_PATCH])
assert.equal(fx.finde(wurzel, '.tm-box-warn[role="alert"]').hidden, false)
assert.equal(fbSchritt(fx, wurzel), 'benachrichtigung')
}))
test('„Weiter“: schlägt der PUT fehl, bleibt der Schritt stehen und der Fehler steht im Formular', mitDom({
antworten: [fbPatchAntwort(4), { status: 409, body: { error: 'Es gibt noch keine Einstellungen.', code: 'keine_einstellung' } }],
}, async (fx) => {
const { wurzel } = fbSiebenSeite()
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fbAufrufe(fx), [FB_PATCH, FB_PUT])
assert.equal(fbFehler(fx, wurzel, '_'), 'Es gibt noch keine Einstellungen.')
assert.equal(fbSchritt(fx, wurzel), 'benachrichtigung')
assert.equal(fbWeiter(fx, wurzel).disabled, false)
}))
test('„Weiter“: ein PUT-Fehler mit Feldfehlern steht am Feld, einer ohne Platz im Formular allgemein', mitDom({
antworten: [
{ status: 400, body: { error: 'Bitte „ja“ oder „nein“ wählen.', fehler: { preiseGeprueft: 'Bitte „ja“ oder „nein“ wählen.' } } },
{ status: 400, body: { error: 'Der Fragebogen ist ungültig.', fehler: { 'einbau.modus': 'Bitte wähle den Modus.' } } },
],
}, async (fx) => {
const { wurzel } = fbLeistungenSeite()
const form = fbWeiter(fx, wurzel).closest('form')
await fx.klick(fbFeld(fx, wurzel, 'preiseGeprueft'))
await fx.klick(fbWeiter(fx, wurzel))
assert.equal(fbFehler(fx, form, 'preiseGeprueft'), 'Bitte „ja“ oder „nein“ wählen.')
assert.equal(fbSchritt(fx, wurzel), 'leistungen')
await fx.klick(fbWeiter(fx, wurzel))
assert.equal(fbFehler(fx, form, '_'), 'Bitte wähle den Modus.', 'ohne Feld im Formular: allgemein')
assert.equal(fbFehler(fx, form, 'preiseGeprueft'), '', 'der alte Fehler ist weg')
}))
test('„Weiter“: bleibt der Schritt stehen, hat der Knopf danach wieder den Fokus, der ihn beim Sperren verlieren kann; ein Feld mit Fehler behält ihn', mitDom({
antworten: [{ status: 409, body: { error: 'Es gibt noch keine Einstellungen.', code: 'keine_einstellung' } }],
}, async (fx) => {
const { wurzel } = fbLeistungenSeite({ fragebogen: fbBis(2, { preiseGeprueft: true }) })
const knopf = fbWeiter(fx, wurzel)
knopf.focus()
await fx.klick(knopf)
assert.deepEqual(fbAufrufe(fx), [FB_PUT])
assert.ok(document.activeElement === knopf, 'wieder auf „Weiter“')
// Fehler am Feld: der Fokus gehört dem Feld
await fx.klick(fbFeld(fx, wurzel, 'preiseGeprueft'))
knopf.focus()
await fx.klick(knopf)
assert.ok(document.activeElement === fbFeld(fx, wurzel, 'preiseGeprueft'))
}))
test('„Weiter“: zwei Absendungen gleichzeitig ergeben einen PATCH und einen PUT', mitDom({ antworten: [fbPatchAntwort(4), fbAntwort(7)] }, async (fx) => {
const { wurzel } = fbSiebenSeite()
const form = fbWeiter(fx, wurzel).closest('form')
await Promise.all([fx.sende(form, 'submit'), fx.sende(form, 'submit')])
assert.deepEqual(fbAufrufe(fx), [FB_PATCH, FB_PUT])
}))
test('„Weiter“: während des Speicherns ist der Knopf gesperrt', mitDom(undefined, async (fx) => {
let freigeben
const warten = new Promise((r) => { freigeben = r })
const { wurzel } = fbSiebenSeite()
fx.antworten.push(fbPatchAntwort(4), fbAntwort(7))
// die Antwort des PATCH wird zurückgehalten: solange sie fehlt, ist der Knopf gesperrt
const original = globalThis.fetch
globalThis.fetch = async (url, optionen) => { await warten; return original(url, optionen) }
const knopf = fbWeiter(fx, wurzel)
const klick = fx.klick(knopf)
await fx.ruhe()
assert.equal(knopf.disabled, true)
assert.equal(fx.finde(wurzel, 'section[data-schritt]').getAttribute('aria-busy'), 'true', 'der Schritt ist aria-busy')
freigeben()
await klick
assert.deepEqual(fbAufrufe(fx), [FB_PATCH, FB_PUT])
assert.equal(fbSchritt(fx, wurzel), 'website')
}))
test('„Weiter“ (d): in der Kundenansicht sendet Schritt 7 den PATCH, aber keinen PUT und wechselt trotzdem zum nächsten Schritt', mitDom({ antworten: [fbPatchAntwort(4)] }, async (fx) => {
const { wurzel } = fbSiebenSeite({ kundenansicht: true })
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fbAufrufe(fx), [FB_PATCH])
assert.equal(fbSchritt(fx, wurzel), 'website')
}))
test('„Weiter“ (d): in der Kundenansicht geht Schritt 3 ohne jeden Aufruf weiter', mitDom(undefined, async (fx) => {
const { wurzel } = fbLeistungenSeite({ kundenansicht: true })
await fx.klick(fbFeld(fx, wurzel, 'preiseGeprueft'))
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fx.fetchLog, [])
assert.equal(fbSchritt(fx, wurzel), 'zeiten')
}))
test('Kundenansicht: die Bestellseite bleibt gesperrt, auch wenn alles bestätigt und angehakt ist', mitDom(undefined, async (fx) => {
const { wurzel } = fbSeite(await bestellDaten({ kundenansicht: true }))
await alleAnhaken(fx, wurzel)
assert.equal(bestellKnopf(fx, wurzel).disabled, true)
assert.match(fx.text(wurzel), /In der Kundenansicht kann nicht bestellt werden/)
}))
test('„Weiter“: verlässt der Kunde den Schritt oder die Ansicht, während der PATCH läuft, wird nichts mehr bestätigt oder gezeichnet', mitDom({ antworten: [fbPatchAntwort(4), fbAntwort(7)] }, async (fx) => {
const wurzel = h('div')
document.body.append(wurzel)
const s = { daten: fbDaten({ fragebogen: fbBis(6) }), nurLesen: false, meldungEl: h('div') }
let aktuell = true
const ctx = huelle.baueCtx(s, { projekt: { projectId: 'p1' }, aktuell: () => aktuell })
ctx.neuLaden = async () => ctx.daten
fragebogenAnsicht.renderFragebogen(wurzel, ctx)
const klick = fx.klick(fbWeiter(fx, wurzel))
aktuell = false
await klick
assert.deepEqual(fbAufrufe(fx), [FB_PATCH], 'kein PUT für eine verlassene Ansicht')
assert.equal(fbSchritt(fx, wurzel), 'benachrichtigung', 'und kein Wechsel')
}))
test('Zurück: wechselt ohne Aufruf zum vorigen Schritt, im ersten Schritt gibt es den Knopf nicht', mitDom(undefined, async (fx) => {
const { wurzel } = fbSiebenSeite()
await fx.klick(fx.finde(wurzel, '[data-aktion="zurueck"]'))
assert.equal(fbSchritt(fx, wurzel), 'gastfelder')
assert.deepEqual(fx.fetchLog, [])
assert.equal(fx.finde(fbSeite(fbDaten()).wurzel, '[data-aktion="zurueck"]'), null)
}))
test('Texte der Kunden stehen nur als Text im Baum (Namen, Vorschlag)', mitDom(undefined, (fx) => {
const boese = '<img src=x onerror=alert(1)>'
const e = salon()
e.ressourcen[0].name = boese
const { wurzel } = fbWerSeite({ einstellung: e })
assert.equal(fx.alle(wurzel, 'img').length, 0)
assert.equal(fbNamen(fx, wurzel).value, boese)
const start = fbSeite(fbStart({ betriebVorschlag: { ...BETRIEB_KONTO, name: boese } })).wurzel
assert.equal(fx.alle(start, 'img').length, 0)
assert.equal(fbFeld(fx, start, 'betrieb.name').value, boese)
}))
// ------------------------------------------------------- Fragebogen-Oberfläche: Schritte 4 bis 8 und Bestellseite (TP2, Task C9b)
// Die Schritte bauen auf den Abschnitten der Reiter auf (wochenplanFeld aus termine-tab-team.js, abwesenheiten/sonderoeffnungen aus termine-team-ausnahmen.js,
// betrieb/darstellung/regeln/gastFelder/erinnerung/storno/rechtliches/zahlung aus termine-tab-einstellungen.js); hier steht, was der Fragebogen daraus macht.
const fbZeitenSeite = (aenderung = {}, optionen) => fbSeite(fbDaten({ fragebogen: fbBis(3), ...aenderung }), optionen)
const fbRegelnSeite = (aenderung = {}) => fbSeite(fbDaten({ fragebogen: fbBis(4, { bundeslandGewaehlt: true }), ...aenderung }))
const fbGastSeite = (aenderung = {}) => fbSeite(fbDaten({ fragebogen: fbBis(5, { bundeslandGewaehlt: true }), ...aenderung }))
const fbWebsiteSeite = (aenderung = {}) => fbSeite(fbDaten({ fragebogen: fbBis(7, { bundeslandGewaehlt: true }), ...aenderung }))
const EINBAU_LEER = { modus: null, knopfText: '', stelle: '', gestaltung: '', bemerkung: '' }
/** Entwurf mit zwei Personen (Anna behält den Plan der Vorlage, Ben hat einen eigenen) und einem Platz. */
function dreiEintraege() {
const e = salon()
const [anna] = e.ressourcen
e.ressourcen = [
{ ...anna, name: 'Anna' },
{ id: 'r_ben00002', typ: 'person', name: 'Ben', aktiv: true, plan: { 2: [['13:00', '17:00']] } },
{ id: 'r_stuhl003', typ: 'platz', name: 'Stuhl 3', aktiv: false, plan: {} },
]
return e
}
test('alle acht Schritte haben Inhalt: ein Formular mit „Weiter“ (außer Schritt 1 ohne Entwurf), keinen Platzhalter', mitDom(undefined, async (fx) => {
const { wurzel } = fbSeite(fbDaten({ fragebogen: fbBis(0) }))
for (const [key, titel] of FRAGEBOGEN_SCHRITTE) {
await fbGehe(fx, wurzel, key)
assert.equal(fbSchritt(fx, wurzel), key)
assert.equal(fx.text(fx.finde(wurzel, 'h3')), titel)
assert.ok(fx.finde(wurzel, 'form'), `${key}: ein Formular`)
assert.ok(fbWeiter(fx, wurzel), `${key}: „Weiter“`)
assert.doesNotMatch(fx.text(wurzel), /noch nicht verfügbar/, `${key}: kein Platzhalter`)
}
assert.deepEqual(fx.fetchLog, [], 'das Anzeigen allein ruft nichts auf')
}))
// ------------------------------------------------------- Schritt 4: Zeiten
test('Schritt 4: je Person und Platz ein Wochenplan (wochenplanFeld, Felder ressourcen.n.plan.1 bis 7), vorbelegt aus dem Plan', mitDom(undefined, (fx) => {
const { wurzel } = fbZeitenSeite({ einstellung: dreiEintraege() })
assert.equal(fbSchritt(fx, wurzel), 'zeiten')
const gruppen = fx.alle(wurzel, '[data-ressource]')
assert.deepEqual(gruppen.map((g) => fx.text(fx.finde(g, 'h4'))), ['Anna', 'Ben', 'Stuhl 3 (nicht aktiv)'])
assert.equal(fx.alle(wurzel, 'fieldset.tm-wochenplan').length, 3, 'drei Wochenpläne')
const plaene = felderVon(fx, wurzel).filter((n) => n.includes('.plan.'))
assert.deepEqual(plaene, [0, 1, 2].flatMap((i) => [1, 2, 3, 4, 5, 6, 7].map((nr) => `ressourcen.${i}.plan.${nr}`)))
assert.equal(fbFeld(fx, wurzel, 'ressourcen.0.plan.2').value, '09:00–12:30, 13:30–18:00')
assert.equal(fbFeld(fx, wurzel, 'ressourcen.0.plan.1').value, '', 'Montag geschlossen')
assert.equal(fbFeld(fx, wurzel, 'ressourcen.1.plan.2').value, '13:00–17:00')
assert.deepEqual(fbKnoepfe(fx, wurzel).filter((t) => /Zurück|Weiter/.test(t)), ['Zurück', 'Weiter'])
}))
test('Schritt 4 (a): Bundesland ohne Vorauswahl – die erste Option hat value "", keine Option ist gewählt, obwohl der Entwurf RP trägt', mitDom(undefined, (fx) => {
const { wurzel, ctx } = fbZeitenSeite()
assert.equal(ctx.daten.einstellung.bundesland, 'RP', 'der Serverstandard steht im Entwurf …')
const land = fbFeld(fx, wurzel, 'bundesland')
assert.equal(land.tagName, 'SELECT')
const optionen = fx.alle(land, 'option')
assert.equal(optionen.length, ansicht.BUNDESLAENDER.length + 1, 'alle 16 Länder und die leere Option')
assert.equal(optionen[0].value, '', 'die erste Option ist leer')
assert.ok(optionen.every((o) => !o.selected && !o.hasAttribute('selected')), 'keine Option ist gewählt')
assert.equal(land.value, '', '… die Auswahl zeigt trotzdem nichts')
assert.deepEqual(optionen.slice(1).map((o) => [o.value, fx.text(o)]), ansicht.BUNDESLAENDER)
const haken = fbFeld(fx, wurzel, 'feiertageGeschlossen')
assert.equal(haken.checked, true, '„An Feiertagen geschlossen“ steht für alte und neue Entwürfe auf ja')
assert.match(fx.text(wurzel), /An Feiertagen geschlossen/)
}))
test('Schritt 4: hat der Kunde das Bundesland schon gewählt (bundeslandGewaehlt), steht seine Wahl da – und nur sie', mitDom(undefined, (fx) => {
const e = salon()
e.bundesland = 'BY'
e.feiertageGeschlossen = false
const { wurzel } = fbZeitenSeite({ einstellung: e, fragebogen: fbBis(3, { bundeslandGewaehlt: true }) })
const land = fbFeld(fx, wurzel, 'bundesland')
assert.equal(land.value, 'BY')
assert.deepEqual(fx.alle(land, 'option').filter((o) => o.selected).map((o) => o.value), ['BY'])
assert.equal(fbFeld(fx, wurzel, 'feiertageGeschlossen').checked, false)
}))
test('Schritt 4 (a): „Weiter“ ohne Bundesland ⇒ Fehler am Feld, kein Aufruf, der Schritt bleibt', mitDom(undefined, async (fx) => {
const { wurzel } = fbZeitenSeite()
await fx.klick(fbWeiter(fx, wurzel))
assert.match(fbFehler(fx, wurzel, 'bundesland'), /Bundesland/)
assert.equal(fbFeld(fx, wurzel, 'bundesland').getAttribute('aria-invalid'), 'true')
assert.deepEqual(fx.fetchLog, [], 'weder PATCH noch PUT')
assert.equal(fbSchritt(fx, wurzel), 'zeiten')
assert.equal(fbWeiter(fx, wurzel).disabled, false, 'wieder frei')
}))
test('Schritt 4 (a): mit Wahl RP ein PATCH (bundesland, Wochenplan aus wochenplanFeld), danach der PUT mit bundeslandGewaehlt und zeiten', mitDom({
antworten: [fbPatchAntwort(4), fbAntwort(4, { bundeslandGewaehlt: true })],
}, async (fx) => {
const { wurzel, ctx } = fbZeitenSeite()
const plan = structuredClone(ctx.daten.einstellung.ressourcen[0].plan)
const ressourceId = ctx.daten.einstellung.ressourcen[0].id
await fx.setze(fbFeld(fx, wurzel, 'bundesland'), 'RP')
await fx.setze(fbFeld(fx, wurzel, 'ressourcen.0.plan.1'), '10:00–14:00')
await fx.setze(fbFeld(fx, wurzel, 'ressourcen.0.plan.6'), '') // Samstag zu
await fx.setze(fbFeld(fx, wurzel, 'feiertageGeschlossen'), false)
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fbAufrufe(fx), [FB_PATCH, FB_PUT], 'genau ein PATCH, danach der PUT')
const [patch, put] = fx.fetchLog
assert.equal(patch.body.version, 3)
assert.equal(patch.body.einstellung.bundesland, 'RP')
assert.equal(patch.body.einstellung.feiertageGeschlossen, false)
const erwartet = { ...plan, 1: [['10:00', '14:00']] }
delete erwartet[6]
assert.deepEqual(patch.body.einstellung.ressourcen[0].plan, erwartet, 'der Plan kommt aus wochenplanFeld')
assert.equal(patch.body.einstellung.ressourcen[0].id, ressourceId, 'dieselbe Person')
assert.equal(put.body.projectId, 'p1')
assert.equal(put.body.fragebogen.bundeslandGewaehlt, true)
assert.deepEqual(put.body.fragebogen.bestaetigt, [...SCHRITTE.slice(0, 3), 'zeiten'])
assert.equal(fbSchritt(fx, wurzel), 'regeln')
assert.ok(document.activeElement === fx.finde(wurzel, 'h3'), 'Fokus auf der Überschrift des nächsten Schritts')
}))
test('Schritt 4: mehrere Personen und ein Platz – jeder Plan wird einzeln gelesen, unberührte Pläne bleiben', mitDom({
antworten: [fbPatchAntwort(4), fbAntwort(4, { bundeslandGewaehlt: true })],
}, async (fx) => {
const e = dreiEintraege()
const { wurzel } = fbZeitenSeite({ einstellung: e })
await fx.setze(fbFeld(fx, wurzel, 'bundesland'), 'HE')
await fx.setze(fbFeld(fx, wurzel, 'ressourcen.1.plan.4'), '08:00–12:00, 14:00–16:00')
await fx.setze(fbFeld(fx, wurzel, 'ressourcen.2.plan.7'), '10:00–12:00')
await fx.klick(fbWeiter(fx, wurzel))
const ressourcen = fx.fetchLog[0].body.einstellung.ressourcen
assert.deepEqual(ressourcen.map((r) => r.id), e.ressourcen.map((r) => r.id), 'Reihenfolge und IDs bleiben')
assert.deepEqual(ressourcen[0].plan, e.ressourcen[0].plan, 'Anna unberührt')
assert.deepEqual(ressourcen[1].plan, { 2: [['13:00', '17:00']], 4: [['08:00', '12:00'], ['14:00', '16:00']] })
assert.deepEqual(ressourcen[2].plan, { 7: [['10:00', '12:00']] })
assert.equal(ressourcen[2].aktiv, false, 'andere Angaben der Einträge bleiben')
assert.equal(fx.fetchLog[0].body.einstellung.bundesland, 'HE')
}))
test('Schritt 4: ein Zeitfenster, das nicht zu lesen ist, steht am Feld seines Tages; es geht nichts raus', mitDom(undefined, async (fx) => {
const { wurzel } = fbZeitenSeite({ einstellung: dreiEintraege() })
await fx.setze(fbFeld(fx, wurzel, 'bundesland'), 'RP')
await fx.setze(fbFeld(fx, wurzel, 'ressourcen.1.plan.3'), 'nachmittags')
await fx.setze(fbFeld(fx, wurzel, 'ressourcen.0.plan.5'), 'abends')
await fx.klick(fbWeiter(fx, wurzel))
assert.equal(fbFehler(fx, wurzel, 'ressourcen.1.plan.3'), ansicht.FENSTER_FEHLER)
assert.equal(fbFehler(fx, wurzel, 'ressourcen.0.plan.5'), ansicht.FENSTER_FEHLER, 'alle Fehler auf einmal')
assert.equal(fbFehler(fx, wurzel, 'bundesland'), '', 'das Land ist gewählt')
assert.equal(fbFeld(fx, wurzel, 'ressourcen.1.plan.3').getAttribute('aria-invalid'), 'true')
assert.deepEqual(fx.fetchLog, [])
}))
test('Schritt 4: ohne Wahl und mit unlesbarem Plan zeigt „Weiter“ beide Fehler zugleich', mitDom(undefined, async (fx) => {
const { wurzel } = fbZeitenSeite()
await fx.setze(fbFeld(fx, wurzel, 'ressourcen.0.plan.1'), 'xx')
await fx.klick(fbWeiter(fx, wurzel))
assert.ok(fbFehler(fx, wurzel, 'bundesland') !== '')
assert.equal(fbFehler(fx, wurzel, 'ressourcen.0.plan.1'), ansicht.FENSTER_FEHLER)
assert.deepEqual(fx.fetchLog, [])
}))
test('Schritt 4: antwortet der PATCH mit 400 und einem Fehler am Plan, steht er am Feld und der PUT bleibt aus', mitDom({
antworten: [{ status: 400, body: { error: 'Zeiten überlappen.', fehler: { 'ressourcen.0.plan.2': 'Die Zeitfenster überlappen sich.' } } }],
}, async (fx) => {
const { wurzel } = fbZeitenSeite()
await fx.setze(fbFeld(fx, wurzel, 'bundesland'), 'RP')
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fbAufrufe(fx), [FB_PATCH])
assert.equal(fbFehler(fx, wurzel, 'ressourcen.0.plan.2'), 'Die Zeitfenster überlappen sich.')
assert.equal(fbSchritt(fx, wurzel), 'zeiten')
}))
test('Schritt 4: darunter Abwesenheiten und Sonderöffnungen mit eigenem Container; Bundesland und Feiertage gibt es nur im Formular des Schritts', mitDom(undefined, (fx) => {
const { wurzel } = fbZeitenSeite()
const ausnahmen = fx.finde(wurzel, '.tm-fb-ausnahmen')
assert.ok(ausnahmen, 'eigener Container')
const form = fx.finde(wurzel, 'form.tm-fb-form')
assert.ok(!form.contains(ausnahmen), 'nicht im Formular des Schritts (keine verschachtelten Formulare)')
assert.ok(fx.finde(ausnahmen, '[data-werkzeug="abwesenheit-neu"]'))
assert.ok(fx.finde(ausnahmen, '[data-werkzeug="sonder-neu"]'))
assert.deepEqual(fx.alle(ausnahmen, 'h3').map((el) => fx.text(el)), ['Abwesenheiten und Urlaub', 'Sonderöffnungen'])
assert.equal(fx.alle(wurzel, 'form[aria-label="Feiertage"]').length, 0, 'kein zweites Speichern für die Feiertage')
assert.equal(fx.alle(wurzel, '[data-feld="bundesland"]').length, 1)
const alle = fx.alle(wurzel, '*')
assert.ok(alle.indexOf(form) < alle.indexOf(ausnahmen), 'die Ausnahmen stehen unter dem Formular')
}))
test('Schritt 4: eine Abwesenheit speichert sofort und lässt die noch nicht gespeicherten Zeiten oben stehen; „Weiter“ nimmt beides mit', mitDom({
antworten: [
(aufruf) => ({ body: { einstellung: { ...aufruf.body.einstellung, abwesenheiten: [{ id: 'a_urlaub01', ressourceId: null, von: '2099-12-24', bis: '2099-12-31', titel: 'Betriebsurlaub' }] }, version: 4, fehlt: [] } }),
fbPatchAntwort(5),
fbAntwort(4, { bundeslandGewaehlt: true }),
],
}, async (fx) => {
const { wurzel, ctx } = fbZeitenSeite()
const plan = structuredClone(ctx.daten.einstellung.ressourcen[0].plan)
await fx.setze(fbFeld(fx, wurzel, 'ressourcen.0.plan.1'), '10:00–14:00') // nicht gespeichert
await fx.setze(fbFeld(fx, wurzel, 'bundesland'), 'RP')
await fx.klick(fx.finde(wurzel, '[data-werkzeug="abwesenheit-neu"]'))
await fx.setze(fbFeld(fx, wurzel, 'abwesenheiten.0.von'), '2099-12-24')
await fx.setze(fbFeld(fx, wurzel, 'abwesenheiten.0.bis'), '2099-12-31')
await fx.setze(fbFeld(fx, wurzel, 'abwesenheiten.0.titel'), 'Betriebsurlaub')
await fx.klick(fx.finde(wurzel, 'form[aria-label="Abwesenheit eintragen"] button[type="submit"]'))
assert.deepEqual(fbAufrufe(fx), [FB_PATCH], 'die Abwesenheit speichert selbst – ohne PUT')
const erster = fx.fetchLog[0].body
assert.equal(erster.einstellung.abwesenheiten.length, 1)
assert.deepEqual(erster.einstellung.abwesenheiten[0], { id: erster.einstellung.abwesenheiten[0].id, ressourceId: null, von: '2099-12-24', bis: '2099-12-31', titel: 'Betriebsurlaub' })
assert.deepEqual(erster.einstellung.ressourcen[0].plan, plan, 'die noch ungespeicherten Zeiten gehen in diesen PATCH nicht mit')
assert.equal(erster.einstellung.bundesland, 'RP', 'und das Bundesland ist der Stand des Entwurfs')
assert.equal(fbFeld(fx, wurzel, 'ressourcen.0.plan.1').value, '10:00–14:00', 'die Eingabe oben bleibt stehen')
assert.equal(fbFeld(fx, wurzel, 'bundesland').value, 'RP')
assert.equal(fx.alle(wurzel, 'ul[aria-label="Abwesenheiten"] li').length, 1, 'der Container unten ist neu gezeichnet')
assert.equal(fbSchritt(fx, wurzel), 'zeiten')
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fbAufrufe(fx), [FB_PATCH, FB_PATCH, FB_PUT])
const zweiter = fx.fetchLog[1].body
assert.equal(zweiter.version, 4, 'der zweite PATCH baut auf der Antwort des ersten auf')
assert.equal(zweiter.einstellung.abwesenheiten.length, 1, 'die gespeicherte Abwesenheit bleibt')
assert.deepEqual(zweiter.einstellung.ressourcen[0].plan[1], [['10:00', '14:00']], 'jetzt sind die Zeiten dabei')
}))
test('Schritt 4: eine Sonderöffnung speichert selbst und lässt das Formular des Schritts unberührt', mitDom({
antworten: [(aufruf) => ({ body: { einstellung: aufruf.body.einstellung, version: 4, fehlt: [] } })],
}, async (fx) => {
const { wurzel } = fbZeitenSeite()
await fx.setze(fbFeld(fx, wurzel, 'ressourcen.0.plan.7'), '10:00–12:00')
await fx.klick(fx.finde(wurzel, '[data-werkzeug="sonder-neu"]'))
await fx.setze(fbFeld(fx, wurzel, 'datum'), '2099-12-24')
await fx.setze(fbFeld(fx, wurzel, 'zeiten'), '09:00–13:00')
await fx.klick(fx.finde(wurzel, 'form[aria-label="Sonderöffnung eintragen"] button[type="submit"]'))
assert.deepEqual(fbAufrufe(fx), [FB_PATCH])
assert.deepEqual(fx.fetchLog[0].body.einstellung.sonderoeffnungen['2099-12-24'], [['09:00', '13:00']])
assert.equal(fbFeld(fx, wurzel, 'ressourcen.0.plan.7').value, '10:00–12:00')
assert.equal(fx.alle(wurzel, 'ul[aria-label="Sonderöffnungen"] li').length, 1)
}))
test('Schritt 4 (d): in der Kundenansicht geht der PATCH raus, ein PUT nicht, der Schritt führt trotzdem weiter', mitDom({ antworten: [fbPatchAntwort(4)] }, async (fx) => {
const { wurzel } = fbZeitenSeite({ kundenansicht: true })
await fx.setze(fbFeld(fx, wurzel, 'bundesland'), 'RP')
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fbAufrufe(fx), [FB_PATCH])
assert.equal(fbSchritt(fx, wurzel), 'regeln')
}))
// ------------------------------------------------------- Schritt 5: Regeln
const lageVon = (fx, wurzel) => fx.finde(wurzel, '[data-lage]')?.dataset.lage
const sammel = (fx, wurzel, art) => fx.finde(wurzel, `[data-aktion="alle-${art}"]`)
test('Schritt 5: Sammelknopf, Regeln, Stornobedingungen, Rechtliches und Bezahlung in dieser Reihenfolge; Zurück und Weiter', mitDom(undefined, (fx) => {
const { wurzel } = fbRegelnSeite()
assert.equal(fbSchritt(fx, wurzel), 'regeln')
assert.deepEqual(fx.alle(fx.finde(wurzel, 'form.tm-fb-form'), 'h3').map((el) => fx.text(el)), ['Buchungsart', 'Regeln fürs Buchen', 'Stornobedingungen', 'Rechtliches', 'Bezahlung'])
assert.deepEqual(felderVon(fx, wurzel), [
'regeln.raster', 'regeln.vorlaufMin', 'regeln.horizontTage', 'regeln.absageBisStd', 'regeln.maxOffeneJeEmail', 'regeln.personWaehlbar',
'storno.text', 'recht.gaesteSind', 'recht.freizeit', 'zahlung.vorOrtArten',
])
assert.equal(fx.text(sammel(fx, wurzel, 'sofort')), 'Alle sofort buchbar')
assert.equal(fx.text(sammel(fx, wurzel, 'anfrage')), 'Alle nur als Anfrage')
assert.deepEqual(fbKnoepfe(fx, wurzel).filter((t) => /Zurück|Weiter/.test(t)), ['Zurück', 'Weiter'])
}))
test('Schritt 5: der Sammelknopf zeigt die Lage (buchungsartLage) und ändert nichts, bis „Weiter“ gedrückt wird', mitDom({
antworten: [fbPatchAntwort(5), fbAntwort(5, { bundeslandGewaehlt: true })],
}, async (fx) => {
const e = salon()
e.leistungen[1].buchungsart = 'anfrage'
assert.equal(buchungsartLage(e.leistungen), 'gemischt')
const { wurzel } = fbRegelnSeite({ einstellung: e })
assert.equal(lageVon(fx, wurzel), 'gemischt')
assert.match(fx.text(fx.finde(wurzel, '[data-lage]')), /je Leistung/)
assert.equal(sammel(fx, wurzel, 'sofort').getAttribute('aria-pressed'), 'false')
await fx.klick(sammel(fx, wurzel, 'anfrage'))
assert.equal(lageVon(fx, wurzel), 'anfrage')
assert.equal(sammel(fx, wurzel, 'anfrage').getAttribute('aria-pressed'), 'true')
assert.equal(sammel(fx, wurzel, 'sofort').getAttribute('aria-pressed'), 'false')
await fx.klick(sammel(fx, wurzel, 'sofort'))
assert.equal(lageVon(fx, wurzel), 'sofort')
assert.equal(sammel(fx, wurzel, 'anfrage').getAttribute('aria-pressed'), 'false')
await fx.klick(sammel(fx, wurzel, 'anfrage'))
assert.deepEqual(fx.fetchLog, [], 'ein Klick auf den Sammelknopf ruft nichts auf')
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fbAufrufe(fx), [FB_PATCH, FB_PUT])
const leistungen = fx.fetchLog[0].body.einstellung.leistungen
assert.equal(leistungen.length, e.leistungen.length)
assert.ok(leistungen.every((l) => l.buchungsart === 'anfrage'), 'jede Leistung nur als Anfrage')
assert.deepEqual(leistungen.map((l) => l.id), e.leistungen.map((l) => l.id))
assert.deepEqual(fx.fetchLog[1].body.fragebogen.bestaetigt, [...SCHRITTE.slice(0, 4), 'regeln'])
}))
test('Schritt 5: ohne Klick auf den Sammelknopf bleiben die Buchungsarten der Leistungen, wie sie sind', mitDom({
antworten: [fbPatchAntwort(5), fbAntwort(5)],
}, async (fx) => {
const e = salon()
e.leistungen[1].buchungsart = 'anfrage'
const { wurzel } = fbRegelnSeite({ einstellung: e })
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fx.fetchLog[0].body.einstellung.leistungen.map((l) => l.buchungsart), e.leistungen.map((l) => l.buchungsart))
}))
test('Schritt 5: „Alle sofort buchbar“ setzt auch inaktive Leistungen und nimmt andere Angaben der Leistung nicht mit', mitDom({
antworten: [fbPatchAntwort(5), fbAntwort(5)],
}, async (fx) => {
const e = salon()
e.leistungen[0].buchungsart = 'anfrage'
e.leistungen[2].aktiv = false
e.leistungen[2].buchungsart = 'anfrage'
const { wurzel } = fbRegelnSeite({ einstellung: e })
await fx.klick(sammel(fx, wurzel, 'sofort'))
await fx.klick(fbWeiter(fx, wurzel))
const leistungen = fx.fetchLog[0].body.einstellung.leistungen
assert.ok(leistungen.every((l) => l.buchungsart === 'sofort'))
assert.deepEqual(leistungen.map((l) => ({ ...l, buchungsart: null })), e.leistungen.map((l) => ({ ...l, buchungsart: null })), 'sonst ändert sich nichts')
}))
test('Schritt 5: das Muster der Stornobedingungen folgt der Absagefrist, auch ungespeichert', mitDom(undefined, async (fx) => {
const { wurzel, ctx } = fbRegelnSeite()
const muster = fx.finde(wurzel, '.tm-muster-text')
assert.equal(fx.text(muster), stornoMuster(ctx.daten.einstellung.regeln.absageBisStd))
await fx.setze(fbFeld(fx, wurzel, 'regeln.absageBisStd'), '48')
assert.equal(fx.text(muster), stornoMuster(48))
assert.match(fx.text(muster), /bis 48 Stunden vorher/)
await fx.klick(fx.finde(wurzel, '[data-aktion="muster-uebernehmen"]'))
assert.equal(fbFeld(fx, wurzel, 'storno.text').value, stornoMuster(48), 'der Knopf übernimmt es ins Textfeld')
assert.deepEqual(fx.fetchLog, [])
}))
test('Schritt 5: „Weiter“ liest Regeln, Storno, Rechtliches und Bezahlung in genau einen PATCH, danach der PUT', mitDom({
antworten: [fbPatchAntwort(5), fbAntwort(5)],
}, async (fx) => {
const { wurzel } = fbRegelnSeite()
await fx.setze(fbFeld(fx, wurzel, 'regeln.vorlaufMin'), '180')
await fx.setze(fbFeld(fx, wurzel, 'regeln.absageBisStd'), '48')
await fx.setze(fbFeld(fx, wurzel, 'regeln.personWaehlbar'), false)
await fx.setze(fbFeld(fx, wurzel, 'storno.text'), 'Absagen bis 48 Stunden vorher.')
await fx.setze(fbFeld(fx, wurzel, 'recht.gaesteSind'), 'gemischt')
await fx.setze(fbFeld(fx, wurzel, 'recht.freizeit'), true)
const arten = fx.alle(fbFeld(fx, wurzel, 'zahlung.vorOrtArten'), 'input[type="checkbox"]')
await fx.setze(arten[1], false) // EC-Karte ab
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fbAufrufe(fx), [FB_PATCH, FB_PUT])
const e = fx.fetchLog[0].body.einstellung
assert.equal(e.regeln.vorlaufMin, 180)
assert.equal(e.regeln.absageBisStd, 48)
assert.equal(e.regeln.personWaehlbar, false)
assert.equal(e.regeln.raster, 15, 'unberührte Regeln bleiben')
assert.equal(e.storno.text, 'Absagen bis 48 Stunden vorher.')
assert.deepEqual(e.recht, { gaesteSind: 'gemischt', freizeit: true })
assert.deepEqual(e.zahlung, { modus: 'vorort', vorOrtArten: ['bar', 'kreditkarte'] })
assert.equal(fbSchritt(fx, wurzel), 'gastfelder')
}))
test('Schritt 5: eine Zahl, die keine ist, steht am Feld; es geht nichts raus', mitDom(undefined, async (fx) => {
const { wurzel } = fbRegelnSeite()
await fx.setze(fbFeld(fx, wurzel, 'regeln.horizontTage'), 'viel')
await fx.klick(fbWeiter(fx, wurzel))
assert.ok(fbFehler(fx, wurzel, 'regeln.horizontTage') !== '')
assert.deepEqual(fx.fetchLog, [])
assert.equal(fbSchritt(fx, wurzel), 'regeln')
}))
test('Schritt 5: Fehler des Servers stehen am Feld, der PUT bleibt aus; die Stornofrist-Eingabe bleibt stehen', mitDom({
antworten: [{ status: 400, body: { error: 'x', fehler: { 'regeln.vorlaufMin': 'Der Vorlauf darf höchstens 20160 Minuten betragen.', 'zahlung.vorOrtArten': 'Bitte wähle mindestens eine Zahlart.' } } }],
}, async (fx) => {
const { wurzel } = fbRegelnSeite()
await fx.setze(fbFeld(fx, wurzel, 'regeln.vorlaufMin'), '99999')
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fbAufrufe(fx), [FB_PATCH])
assert.equal(fbFehler(fx, wurzel, 'regeln.vorlaufMin'), 'Der Vorlauf darf höchstens 20160 Minuten betragen.')
assert.equal(fbFehler(fx, wurzel, 'zahlung.vorOrtArten'), 'Bitte wähle mindestens eine Zahlart.')
assert.equal(fbFeld(fx, wurzel, 'regeln.vorlaufMin').value, '99999')
assert.equal(fbSchritt(fx, wurzel), 'regeln')
}))
// ------------------------------------------------------- Schritt 6: Gastfelder
test('Schritt 6: gastFelder (Telefon, Nachricht, Zusatzfelder); „Weiter“ = ein PATCH, dann der PUT mit gastfelder', mitDom({
antworten: [fbPatchAntwort(5), fbAntwort(6, { bundeslandGewaehlt: true })],
}, async (fx) => {
const { wurzel } = fbGastSeite()
assert.equal(fbSchritt(fx, wurzel), 'gastfelder')
assert.deepEqual(felderVon(fx, wurzel), ['gastFelder.telefon', 'gastFelder.nachricht'])
await fx.setze(fbFeld(fx, wurzel, 'gastFelder.telefon'), 'pflicht')
await fx.setze(fbFeld(fx, wurzel, 'gastFelder.nachricht'), 'optional')
await fx.klick(fx.finde(wurzel, '[data-aktion="zusatz-neu"]'))
await fx.setze(fbFeld(fx, wurzel, 'gastFelder.zusatz.0.label'), 'Haarlänge')
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fbAufrufe(fx), [FB_PATCH, FB_PUT])
const g = fx.fetchLog[0].body.einstellung.gastFelder
assert.equal(g.telefon, 'pflicht')
assert.equal(g.nachricht, 'optional')
assert.equal(g.zusatz.length, 1)
assert.deepEqual({ ...g.zusatz[0], key: typeof g.zusatz[0].key }, { key: 'string', label: 'Haarlänge', typ: 'text', optionen: [], pflicht: false })
assert.ok(g.zusatz[0].key.length > 0)
assert.deepEqual(fx.fetchLog[1].body.fragebogen.bestaetigt, [...SCHRITTE.slice(0, 5), 'gastfelder'])
assert.equal(fbSchritt(fx, wurzel), 'benachrichtigung')
}))
test('Schritt 6: Fehler des Servers am Zusatzfeld stehen am Feld, es folgt kein PUT', mitDom({
antworten: [{ status: 400, body: { error: 'x', fehler: { 'gastFelder.zusatz.0.label': 'Bitte gib eine Beschriftung an.' } } }],
}, async (fx) => {
const { wurzel } = fbGastSeite()
await fx.klick(fx.finde(wurzel, '[data-aktion="zusatz-neu"]'))
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fbAufrufe(fx), [FB_PATCH])
assert.equal(fbFehler(fx, wurzel, 'gastFelder.zusatz.0.label'), 'Bitte gib eine Beschriftung an.')
assert.equal(fbSchritt(fx, wurzel), 'gastfelder')
}))
// ------------------------------------------------------- Schritt 7 (C9b): welche Angaben gelesen werden
const BETRIEB_ANDERS = {
name: 'Anderer Name', adresse: 'Neue Straße 2, 67655 Kaiserslautern', telefon: '0800 111', kleinunternehmer: true,
impressumUrl: 'https://neu.example.invalid/impressum', datenschutzUrl: 'https://neu.example.invalid/datenschutz', agbUrl: 'https://neu.example.invalid/agb', websiteUrl: 'https://neu.example.invalid',
}
test('Schritt 7 (b): aus dem Betrieb wird nur die E-Mail-Adresse gelesen (Schlüsselmenge), alles andere bleibt, wie es zuletzt gespeichert war', mitDom({
antworten: [fbPatchAntwort(4), fbAntwort(7)],
}, async (fx) => {
const { wurzel, ctx } = fbSiebenSeite()
assert.deepEqual(felderVon(fx, wurzel).filter((n) => n.startsWith('betrieb.')), ['betrieb.email'])
// inzwischen anderswo gespeichert (zweiter Tab, Schritt 8): die Felder dieses Schritts kennen die Werte nicht
Object.assign(ctx.daten.einstellung.betrieb, BETRIEB_ANDERS)
const vorher = structuredClone(ctx.daten.einstellung.betrieb)
await fx.setze(fbFeld(fx, wurzel, 'betrieb.email'), 'neu@example.invalid')
await fx.klick(fbWeiter(fx, wurzel))
const betrieb = fx.fetchLog[0].body.einstellung.betrieb
assert.deepEqual(betrieb, { ...vorher, email: 'neu@example.invalid' })
assert.deepEqual(Object.keys(betrieb).filter((k) => betrieb[k] !== vorher[k]), ['email'])
}))
// ------------------------------------------------------- Schritt 8: Website
const WEBSITE_FELDER = ['betrieb.telefon', 'betrieb.impressumUrl', 'betrieb.datenschutzUrl', 'betrieb.agbUrl', 'betrieb.websiteUrl']
const modusRadio = (fx, wurzel, wert) => fbRadio(fx, wurzel, wert)
const einbauFeld = (fx, wurzel, name) => fbFeld(fx, wurzel, `einbau.${name}`)
test('Schritt 8: Telefon und Links aus dem Betrieb, dazu die Einbauwünsche; der Knopftext erscheint erst bei „hinter einem Knopf“', mitDom(undefined, async (fx) => {
const { wurzel } = fbWebsiteSeite()
assert.equal(fbSchritt(fx, wurzel), 'website')
assert.deepEqual(felderVon(fx, wurzel).filter((n) => n.startsWith('betrieb.')), WEBSITE_FELDER)
assert.equal(fbFeld(fx, wurzel, 'betrieb.telefon').value, '06301 12345')
const radios = fx.alle(wurzel, 'input[name="termine-einbau-modus"]')
assert.deepEqual(radios.map((r) => r.value), ['inline', 'knopf'])
assert.ok(radios.every((r) => !r.checked), 'keine Vorauswahl: der Kunde entscheidet')
assert.match(fx.text(wurzel), /direkt auf der Seite/i)
assert.match(fx.text(wurzel), /hinter einem Knopf/i)
const knopfFeld = einbauFeld(fx, wurzel, 'knopfText').closest('.tm-feld')
assert.equal(knopfFeld.hidden, true, 'ohne Knopf kein Knopftext')
await fx.klick(modusRadio(fx, wurzel, 'knopf'))
assert.equal(knopfFeld.hidden, false)
await fx.klick(modusRadio(fx, wurzel, 'inline'))
assert.equal(knopfFeld.hidden, true)
for (const name of ['stelle', 'gestaltung', 'bemerkung']) assert.ok(einbauFeld(fx, wurzel, name), `${name}: ein Feld`)
assert.deepEqual(fbKnoepfe(fx, wurzel).filter((t) => /Zurück|Weiter/.test(t)), ['Zurück', 'Weiter'])
}))
test('Schritt 8: der Hinweis nennt, was nach Stunden geht – zweites Widget, Knöpfe je Leistung, Sonderdesign', mitDom(undefined, (fx) => {
const { wurzel } = fbWebsiteSeite()
const hinweis = fx.text(fx.finde(wurzel, '[data-hinweis="einbau-mehr"]'))
assert.match(hinweis, /zweites Widget/)
assert.match(hinweis, /Knöpfe für einzelne Leistungen/)
assert.match(hinweis, /Sonderdesign/)
assert.match(hinweis, /nach Stunden/)
}))
test('Schritt 8: vorbelegt aus dem gespeicherten Fragebogen', mitDom(undefined, (fx) => {
const einbau = { modus: 'knopf', knopfText: 'Jetzt Termin buchen', stelle: 'Unter dem Text „Kontakt“', gestaltung: 'Farben wie das Logo', bemerkung: 'Bitte vorher anrufen' }
const { wurzel } = fbWebsiteSeite({ fragebogen: fbBis(7, { bundeslandGewaehlt: true, einbau }) })
assert.equal(modusRadio(fx, wurzel, 'knopf').checked, true)
assert.equal(modusRadio(fx, wurzel, 'inline').checked, false)
assert.equal(einbauFeld(fx, wurzel, 'knopfText').closest('.tm-feld').hidden, false)
assert.equal(einbauFeld(fx, wurzel, 'knopfText').value, einbau.knopfText)
assert.equal(einbauFeld(fx, wurzel, 'stelle').value, einbau.stelle)
assert.equal(einbauFeld(fx, wurzel, 'gestaltung').value, einbau.gestaltung)
assert.equal(einbauFeld(fx, wurzel, 'bemerkung').value, einbau.bemerkung)
}))
test('Schritt 8 (b): der PATCH enthält aus dem Betrieb nur Telefon und die vier Links (Schlüsselmenge); der PUT trägt einbau mit allen fünf Angaben', mitDom({
antworten: [fbPatchAntwort(4), fbAntwort(8, { bundeslandGewaehlt: true })],
}, async (fx) => {
const { wurzel, ctx } = fbWebsiteSeite()
Object.assign(ctx.daten.einstellung.betrieb, { name: 'Anderer Name', adresse: 'Neue Straße 2', email: 'andere@example.invalid', kleinunternehmer: true })
const vorher = structuredClone(ctx.daten.einstellung.betrieb)
const neu = {
telefon: '0800 222', impressumUrl: 'https://salon.example.invalid/impr', datenschutzUrl: 'https://salon.example.invalid/ds', agbUrl: 'https://salon.example.invalid/agb', websiteUrl: 'https://salon.example.invalid',
}
for (const [name, wert] of Object.entries(neu)) await fx.setze(fbFeld(fx, wurzel, `betrieb.${name}`), wert)
await fx.klick(modusRadio(fx, wurzel, 'inline'))
await fx.setze(einbauFeld(fx, wurzel, 'stelle'), 'Unter dem Text „Kontakt“')
await fx.setze(einbauFeld(fx, wurzel, 'gestaltung'), 'Farben wie das Logo')
await fx.setze(einbauFeld(fx, wurzel, 'bemerkung'), 'Bitte vorher anrufen')
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fbAufrufe(fx), [FB_PATCH, FB_PUT])
const [patch, put] = fx.fetchLog
assert.deepEqual(patch.body.einstellung.betrieb, { ...vorher, ...neu })
assert.deepEqual(Object.keys(patch.body.einstellung.betrieb).filter((k) => patch.body.einstellung.betrieb[k] !== vorher[k]).sort(), Object.keys(neu).sort(), 'geändert sind genau diese fünf')
assert.deepEqual(put.body.fragebogen.einbau, { modus: 'inline', knopfText: '', stelle: 'Unter dem Text „Kontakt“', gestaltung: 'Farben wie das Logo', bemerkung: 'Bitte vorher anrufen' })
assert.deepEqual(Object.keys(put.body.fragebogen.einbau).sort(), ['bemerkung', 'gestaltung', 'knopfText', 'modus', 'stelle'])
assert.deepEqual(put.body.fragebogen.bestaetigt, [...SCHRITTE])
assert.equal(put.body.fragebogen.bundeslandGewaehlt, true, 'der übrige Fragebogen bleibt')
}))
test('Schritt 8: hinter einem Knopf geht der Knopftext in den PUT', mitDom({
antworten: [fbPatchAntwort(4), fbAntwort(8)],
}, async (fx) => {
const { wurzel } = fbWebsiteSeite()
await fx.klick(modusRadio(fx, wurzel, 'knopf'))
await fx.setze(einbauFeld(fx, wurzel, 'knopfText'), 'Termin buchen')
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fx.fetchLog[1].body.fragebogen.einbau, { modus: 'knopf', knopfText: 'Termin buchen', stelle: '', gestaltung: '', bemerkung: '' })
}))
test('Schritt 8: wer von „Knopf“ auf „direkt auf der Seite“ zurückwechselt, schickt keinen Knopftext mit', mitDom({
antworten: [fbPatchAntwort(4), fbAntwort(8)],
}, async (fx) => {
const { wurzel } = fbWebsiteSeite()
await fx.klick(modusRadio(fx, wurzel, 'knopf'))
await fx.setze(einbauFeld(fx, wurzel, 'knopfText'), 'Termin buchen')
await fx.klick(modusRadio(fx, wurzel, 'inline'))
await fx.klick(fbWeiter(fx, wurzel))
assert.equal(fx.fetchLog[1].body.fragebogen.einbau.modus, 'inline')
assert.equal(fx.fetchLog[1].body.fragebogen.einbau.knopfText, '')
}))
test('Schritt 8 (b): „hinter einem Knopf“ ohne Knopftext ⇒ Fehler am Feld und kein Aufruf', mitDom(undefined, async (fx) => {
const { wurzel } = fbWebsiteSeite()
await fx.klick(modusRadio(fx, wurzel, 'knopf'))
await fx.klick(fbWeiter(fx, wurzel))
assert.match(fbFehler(fx, wurzel, 'einbau.knopfText'), /Knopf/)
assert.equal(einbauFeld(fx, wurzel, 'knopfText').getAttribute('aria-invalid'), 'true')
assert.deepEqual(fx.fetchLog, [], 'weder PATCH noch PUT')
assert.equal(fbSchritt(fx, wurzel), 'website')
await fx.setze(einbauFeld(fx, wurzel, 'knopfText'), ' ')
await fx.klick(fbWeiter(fx, wurzel))
assert.match(fbFehler(fx, wurzel, 'einbau.knopfText'), /Knopf/, 'Leerzeichen allein sind kein Text')
assert.deepEqual(fx.fetchLog, [])
}))
test('Schritt 8: ohne Wahl des Modus ⇒ Fehler an der Gruppe und kein Aufruf', mitDom(undefined, async (fx) => {
const { wurzel } = fbWebsiteSeite()
await fx.klick(fbWeiter(fx, wurzel))
assert.match(fbFehler(fx, wurzel, 'einbau.modus'), /direkt auf deiner Seite oder hinter einem Knopf/)
assert.equal(fbFeld(fx, wurzel, 'einbau.modus').getAttribute('aria-invalid'), 'true')
assert.deepEqual(fx.fetchLog, [])
}))
test('Schritt 8: Fehler des Servers (PATCH am Link, PUT am Einbau) stehen am Feld; nach dem PATCH-Fehler gibt es keinen PUT', mitDom({
antworten: [
{ status: 400, body: { error: 'x', fehler: { 'betrieb.impressumUrl': 'Bitte gib eine vollständige Adresse mit https:// an.' } } },
fbPatchAntwort(4),
{ status: 400, body: { error: 'x', fehler: { 'einbau.stelle': 'Der Text ist ungültig.' } } },
],
}, async (fx) => {
const { wurzel } = fbWebsiteSeite()
await fx.klick(modusRadio(fx, wurzel, 'inline'))
await fx.setze(fbFeld(fx, wurzel, 'betrieb.impressumUrl'), 'impressum')
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fbAufrufe(fx), [FB_PATCH])
assert.equal(fbFehler(fx, wurzel, 'betrieb.impressumUrl'), 'Bitte gib eine vollständige Adresse mit https:// an.')
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fbAufrufe(fx), [FB_PATCH, FB_PATCH, FB_PUT])
assert.equal(fbFehler(fx, wurzel, 'einbau.stelle'), 'Der Text ist ungültig.')
assert.equal(fbSchritt(fx, wurzel), 'website')
}))
test('Schritt 8: Texte des Kunden stehen nur als Text im Baum', mitDom(undefined, (fx) => {
const boese = '<img src=x onerror=alert(1)>'
const { wurzel } = fbWebsiteSeite({ fragebogen: fbBis(7, { einbau: { ...EINBAU_LEER, modus: 'knopf', knopfText: boese, stelle: boese, gestaltung: boese, bemerkung: boese } }) })
assert.equal(fx.alle(wurzel, 'img').length, 0)
for (const name of ['knopfText', 'stelle', 'gestaltung', 'bemerkung']) assert.equal(einbauFeld(fx, wurzel, name).value, boese)
}))
test('Schritt 8 (d): in der Kundenansicht geht der PATCH raus, ein PUT nicht; die Bestellseite bleibt gesperrt', mitDom({ antworten: [fbPatchAntwort(4)] }, async (fx) => {
const bestellung = await bestellungTest()
const { wurzel } = fbWebsiteSeite({ kundenansicht: true, bestellung })
await fx.klick(modusRadio(fx, wurzel, 'inline'))
await fx.klick(fbWeiter(fx, wurzel))
assert.deepEqual(fbAufrufe(fx), [FB_PATCH])
assert.equal(fbSchritt(fx, wurzel), 'bestellen')
assert.equal(bestellKnopf(fx, wurzel).disabled, true)
}))
// ------------------------------------------------------- nach Schritt 8: die Bestellseite
test('Nach Schritt 8 (c) zeigt renderBestellen die Bestellseite; die Knopfbeschriftung ist bestellung.consent.knopf', mitDom({
antworten: [fbPatchAntwort(4), fbAntwort(8, { bundeslandGewaehlt: true, einbau: { ...EINBAU_LEER, modus: 'inline' } })],
}, async (fx) => {
const bestellung = await bestellungTest()
const { wurzel } = fbWebsiteSeite({ bestellung })
await fx.klick(modusRadio(fx, wurzel, 'inline'))
await fx.klick(fbWeiter(fx, wurzel))
assert.equal(fbSchritt(fx, wurzel), 'bestellen')
assert.ok(fx.finde(wurzel, '.tm-fb-bestellen'), 'die Bestellseite steht in der Hülle des Fragebogens')
const knopf = bestellKnopf(fx, wurzel)
assert.ok(knopf)
assert.equal(fx.text(knopf), bestellung.consent.knopf)
assert.equal(fx.text(knopf), featureConfig('termine').consent.knopf)
assert.ok(document.activeElement === fx.finde(wurzel, 'h3'), 'Fokus auf der Überschrift')
assert.equal(fx.fetchLog.length, 2, 'das Zeigen der Bestellseite ruft nichts auf')
}))
test('Nach Schritt 8: die offenen Punkte aus der Antwort des PUT stehen auf der Bestellseite, jeder mit „Zum Schritt“', mitDom({
antworten: [fbPatchAntwort(4), { body: { fragebogen: fbBis(8), offen: [{ schritt: 'zeiten', text: 'Bitte wähle das Bundesland deines Betriebs. Danach richten sich die Feiertage.' }] } }],
}, async (fx) => {
const { wurzel } = fbWebsiteSeite({ bestellung: await bestellungTest() })
await fx.klick(modusRadio(fx, wurzel, 'inline'))
await fx.klick(fbWeiter(fx, wurzel))
assert.match(fx.text(fx.finde(wurzel, '.tm-bestellen-offen')), /Bundesland/)
assert.equal(bestellKnopf(fx, wurzel).disabled, true)
await fx.klick(fx.finde(wurzel, '[data-aktion="zum-schritt"][data-schritt="zeiten"]'))
assert.equal(fbSchritt(fx, wurzel), 'zeiten', 'zurück in den Schritt')
}))
test('Durchlauf: von Schritt 4 bis zur Bestellseite, jeder Schritt mit genau einem PATCH und einem PUT', mitDom({
antworten: [
fbPatchAntwort(4), fbAntwort(4, { bundeslandGewaehlt: true }),
fbPatchAntwort(5), fbAntwort(5, { bundeslandGewaehlt: true }),
fbPatchAntwort(6), fbAntwort(6, { bundeslandGewaehlt: true }),
fbPatchAntwort(7), fbAntwort(7, { bundeslandGewaehlt: true }),
fbPatchAntwort(8), fbAntwort(8, { bundeslandGewaehlt: true, einbau: { ...EINBAU_LEER, modus: 'inline' } }),
],
}, async (fx) => {
const { wurzel } = fbZeitenSeite({ bestellung: await bestellungTest() })
await fx.setze(fbFeld(fx, wurzel, 'bundesland'), 'RP')
await fx.klick(fbWeiter(fx, wurzel))
assert.equal(fbSchritt(fx, wurzel), 'regeln')
await fx.klick(fbWeiter(fx, wurzel))
assert.equal(fbSchritt(fx, wurzel), 'gastfelder')
await fx.klick(fbWeiter(fx, wurzel))
assert.equal(fbSchritt(fx, wurzel), 'benachrichtigung')
await fx.klick(fbWeiter(fx, wurzel))
assert.equal(fbSchritt(fx, wurzel), 'website')
await fx.klick(modusRadio(fx, wurzel, 'inline'))
await fx.klick(fbWeiter(fx, wurzel))
assert.equal(fbSchritt(fx, wurzel), 'bestellen')
assert.deepEqual(fbAufrufe(fx), [FB_PATCH, FB_PUT, FB_PATCH, FB_PUT, FB_PATCH, FB_PUT, FB_PATCH, FB_PUT, FB_PATCH, FB_PUT])
assert.deepEqual(fx.fetchLog.filter((a) => a.method === 'PUT').map((a) => a.body.fragebogen.bestaetigt.at(-1)), ['zeiten', 'regeln', 'gastfelder', 'benachrichtigung', 'website'])
assert.equal(fx.text(bestellKnopf(fx, wurzel)), featureConfig('termine').consent.knopf)
}))
test('Texte der Kunden in den Schritten 4 bis 6 stehen nur als Text im Baum (Namen, Storno, Zusatzfeld)', mitDom(undefined, (fx) => {
const boese = '<img src=x onerror=alert(1)>'
const e = salon()
e.ressourcen[0].name = boese
e.storno = { ...e.storno, text: boese }
e.gastFelder = { ...e.gastFelder, zusatz: [{ key: 'haar', label: boese, typ: 'text', optionen: [], pflicht: false }] }
const zeiten = fbZeitenSeite({ einstellung: e }).wurzel
assert.equal(fx.alle(zeiten, 'img').length, 0)
assert.equal(fx.text(fx.finde(zeiten, '[data-ressource] h4')), boese)
const regelnSeite = fbRegelnSeite({ einstellung: e }).wurzel
assert.equal(fx.alle(regelnSeite, 'img').length, 0)
assert.equal(fbFeld(fx, regelnSeite, 'storno.text').value, boese)
const gast = fbGastSeite({ einstellung: e }).wurzel
assert.equal(fx.alle(gast, 'img').length, 0)
assert.equal(fbFeld(fx, gast, 'gastFelder.zusatz.0.label').value, boese)
}))
// ------------------------------------------------------- Quelltext und Styles
test('termine-fragebogen.js statisch: nur diese Importe, ein einziger PUT, kein PATCH, kein Browserspeicher, Du-Form', () => {
const code = ohneKommentare(fbQuelleText())
assert.ok(code.length > 3000, 'die Datei ist gefüllt')
assert.deepEqual(code.match(/from '[^']+'/g), [
"from './termine-view.js'", "from './termine-dom.js'", "from './termine-speichern.js'", "from './termine-tab-leistungen.js'", "from './termine-tab-team.js'",
"from './termine-team-ausnahmen.js'", "from './termine-tab-einstellungen.js'", "from './funktion-bestellen.js'",
])
assert.equal(code.match(/'PUT'/g).length, 1, 'PUT /fragebogen an genau einer Stelle')
assert.doesNotMatch(code, /'PATCH'/, 'PATCH nur über neuerSpeicher')
assert.doesNotMatch(code, /\bfetch\(|XMLHttpRequest/, 'Aufrufe nur über ctx.api')
assert.doesNotMatch(code, /localStorage|sessionStorage|document\.cookie|innerHTML|insertAdjacentHTML|outerHTML/)
assert.doesNotMatch(code, /\bSie\b|\bIhr(e[nmrs]?)?\b/)
assert.doesNotMatch(code, /\bhref\b/, 'kein Link in diesem Modul')
assert.match(code, /export function renderFragebogen\(root, ctx\)/)
assert.doesNotMatch(code, /import\s*\(/)
})
const fbQuelleText = () => readFileSync(new URL('../public/termine-fragebogen.js', import.meta.url), 'utf8')
test('termine.css: Fortschrittsleiste (tm-schritte, tm-schritt) und die Handy-Ansicht mit „Schritt n von 8“', () => {
const css = readFileSync(new URL('../public/termine.css', import.meta.url), 'utf8')
for (const klasse of ['tm-fragebogen', 'tm-schritte', 'tm-schritt', 'tm-schritt-knopf', 'tm-schritt-nr', 'tm-schritt-stand', 'tm-fb-schritt']) assert.match(css, new RegExp(`\\.${klasse}[\\s{,.:\\[]`), `.${klasse} ist gestaltet`)
assert.match(css, /\.tm-schritt\[aria-current='step'\]/, 'der aktuelle Schritt ist hervorgehoben')
assert.match(css.match(/\.tm-schritt-stand\s*\{[^}]*\}/)?.[0] ?? '', /display:\s*none/, 'am Rechner ohne Zähler')
const handy = css.slice(css.indexOf('@media (max-width: 880px)'))
assert.match(handy, /\.tm-schritt-stand\s*\{[^}]*display:\s*block/, 'am Handy zeigt die Leiste den Zähler')
assert.match(handy, /\.tm-schritt-titel\s*\{[^}]*(clip|position:\s*absolute)/, 'am Handy bleiben nur die Nummern sichtbar')
})
test('termine.css: Klassen der Schritte 4 bis 8 (Person, Ausnahmen, Sammelknopf, Einbau)', () => {
const css = readFileSync(new URL('../public/termine.css', import.meta.url), 'utf8')
for (const klasse of ['tm-fb-person', 'tm-fb-ausnahmen', 'tm-fb-sammel', 'tm-fb-einbau']) assert.match(css, new RegExp(`\\.${klasse}[\\s{,.:\\[]`), `.${klasse} ist gestaltet`)
assert.match(css.match(/\.tm-fb-sammel\s*\{[^}]*\}/)?.[0] ?? '', /flex-wrap:\s*wrap/, 'die Knöpfe brechen am Handy um')
})
// ------------------------------------------------------- Ansicht #termine nach Zustand und Rückkehr von Stripe (TP2, Task C10b)
// initTermine in public/termine.js. Der Fake-fetch antwortet hier nach Adresse statt der Reihe nach: Antworten von GET /api/funktionen (dort
// steht der Abo-Hinweis, R111) und GET /api/termine/einstellungen liefert der Test. Ein Array in der Tabelle sind aufeinanderfolgende Antworten
// (die letzte gilt weiter). Der Zustand der Huelle (Auswahl des Projekts und des Reiters) bleibt zwischen den Tests im Modul: jeder Test nennt
// sein Projekt (projectId), und kein Test verlangt einen bestimmten Reiter, ohne ihn zu waehlen.
const AN_FUNKTIONEN = 'GET /api/funktionen'
const AN_CHECKOUT = 'POST /api/funktionen/termine/checkout'
const an = (projectId) => `GET /api/termine/einstellungen?projectId=${projectId}`
const SERVER_HINWEIS = 'Dieser Satz steht nur beim Server, so wie der Server ihn schreibt.'
const AUFRUFE = (fx) => fx.fetchLog.map((a) => `${a.method} ${a.url}`)
function routen(fx, tabelle) {
globalThis.fetch = async (url, optionen = {}) => {
const aufruf = { method: optionen.method || 'GET', url: String(url), body: optionen.body ? JSON.parse(optionen.body) : undefined }
fx.fetchLog.push(aufruf)
let eintrag = tabelle[`${aufruf.method} ${aufruf.url}`]
if (eintrag === undefined && aufruf.method === 'GET' && aufruf.url.startsWith('/api/termine/buchungen')) eintrag = { body: { buchungen: [] } }
if (Array.isArray(eintrag)) eintrag = eintrag.length > 1 ? eintrag.shift() : eintrag[0]
if (typeof eintrag === 'function') eintrag = eintrag(aufruf)
const { status = 200, body = {} } = eintrag ?? { status: 404, body: { error: `nicht vorgesehen: ${aufruf.method} ${aufruf.url}` } }
return { ok: status >= 200 && status < 300, status, json: async () => structuredClone(body) }
}
}
/** Ansicht öffnen (Adresse mit hash, Tabelle der Antworten) und warten, bis auch die Reiter gezeichnet sind. => Wurzel der Ansicht. */
async function oeffneAnsicht(fx, { projectId = 'p1', hash = '', tabelle }) {
routen(fx, tabelle)
fx.window.location.hash = hash
const wurzel = h('div')
document.body.append(wurzel)
await huelle.initTermine(wurzel, { projectId })
await fx.ruhe()
return wurzel
}
const projektEintrag = (id, state, extra = {}) => ({ projectId: id, projectName: `Website ${id}`, liveUrl: '', features: { news: { state: 'off' }, termine: { state, ...extra } } })
const GEORDERT = { orderedAt: '2026-10-05T10:00:00.000Z', ticketWoid: null, invoiceUrl: null }
const funktionenAntwort = (projekte, termine = {}) => ({
body: { projects: projekte, termine: { verfuegbar: true, bestellbar: true, grund: null, preis: '149 €', einrichtung: EINRICHTUNG, hinweis: null, ...termine } },
})
const ohneAbo = (grund, hinweis = SERVER_HINWEIS) => ({ bestellbar: false, grund, hinweis })
const ABGESCHALTET = { grund: 'abo_ende', seit: '2026-10-04T10:00:00.000Z', loeschungAm: '2026-11-03' }
/** Antwort von GET /api/termine/einstellungen im jeweiligen Zustand (off: Entwurf mit Abo, ordered: dazu die Sicht der Bestellung ohne Abo). */
async function ansichtDaten(state, extra = {}) {
const basis = { ...fbDaten({ state }), abo: null, offen: null, bestellung: null, feeds: {}, anfragenOffen: 0, aktivSeit: null, abgeschaltet: null }
if (state === 'off') return { ...basis, ...fbDaten(), ...extra }
if (state === 'ordered') return { ...basis, offen: [], bestellung: await bestellungTest(), ...extra }
if (state === 'active') return { ...basis, aktivSeit: '2026-10-01T09:00:00.000Z', einbau: { snippet: '<script></script>' }, ...extra }
if (state === 'disabled') return { ...basis, aktivSeit: '2026-10-01T09:00:00.000Z', abgeschaltet: ABGESCHALTET, ...extra }
return { ...basis, ...extra }
}
const reiterTexte = (fx, wurzel) => fx.alle(wurzel, '[role="tab"]').map((tab) => fx.text(tab))
const meldungDerHuelle = (fx, wurzel) => fx.text(fx.finde(wurzel, '.tm-meldung'))
/** Antwort-Tabelle für ein Projekt p1 im Zustand state mit den Daten daten. */
async function einProjekt(state, { eintrag = {}, daten = {}, termine = {}, tabelle = {} } = {}) {
return {
[AN_FUNKTIONEN]: funktionenAntwort([projektEintrag('p1', state, eintrag)], termine),
[an('p1')]: { body: await ansichtDaten(state, daten) },
...tabelle,
}
}
test('Ansicht off mit laufendem Abo: der Fragebogen (Fortschrittsleiste im Baum), weder Hinweis noch Reiter', mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { fragebogen: fbBis(1) } }) })
assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt ol.tm-schritte'), 'renderFragebogen hat gezeichnet')
assert.equal(fbSchritt(fx, wurzel), 'wer', 'Start beim ersten offenen Schritt des Entwurfs')
assert.ok(fx.finde(wurzel, '[data-hinweis="abo"]') === null)
assert.ok(fx.finde(wurzel, '.tm-reiterleiste') === null)
assert.ok(fx.finde(wurzel, '.tm-statuskarte') === null)
assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1')])
}))
test('Ansicht off: ohne Entwurf die Branchenwahl; die Meldung der Hülle (ctx.meldung) steht über dem Fragebogen', mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', {
daten: { einstellung: null, version: 0, fragebogen: leerFragebogen(), betriebVorschlag: BETRIEB_KONTO },
tabelle: {
'POST /api/termine/einstellungen/vorlage': { status: 201, body: { einstellung: salon(), version: 1, fehlt: [], fragebogen: fbBis(0) } },
[an('p1')]: [{ body: fbStart() }, { body: fbDaten({ version: 1, fragebogen: fbBis(0) }) }],
'PUT /api/termine/fragebogen': fbAntwort(1),
},
}) })
assert.equal(fbSchritt(fx, wurzel), 'branche')
assert.equal(meldungDerHuelle(fx, wurzel), '')
await fx.klick(fbRadio(fx, wurzel, 'salon'))
await fx.klick(fx.finde(wurzel, '[data-aktion="branche-starten"]'))
assert.equal(fbSchritt(fx, wurzel), 'wer')
assert.match(meldungDerHuelle(fx, wurzel), /^Die Branche „Friseur und Salon“ ist angelegt\./, 'ohne meldungEl bliebe die Meldung stumm')
assert.equal(fx.finde(wurzel, '.tm-meldung').getAttribute('role'), 'status')
}))
test('Ansicht off ohne laufendes Abo: der Hinweis vom Server (kein_abo mit Link zu Verträge), kein Fragebogen, keine weitere Abfrage', mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { abo: { ok: false, grund: 'kein_abo' } }, termine: ohneAbo('kein_abo') }) })
const karteEl = fx.finde(wurzel, '[data-hinweis="abo"]')
assert.ok(karteEl)
assert.ok(fx.text(karteEl).includes(SERVER_HINWEIS), 'der Text des Servers, unverändert')
const link = fx.alle(karteEl, 'button[data-view]')
assert.deepEqual(link.map((b) => [fx.text(b), b.getAttribute('data-view'), b.getAttribute('class'), b.getAttribute('type')]), [['Zu Verträge', 'vertraege', 'kb-link', 'button']])
assert.deepEqual(fx.alle(karteEl, 'button').map((b) => b.getAttribute('data-aktion') ?? b.getAttribute('data-view')), ['vertraege', 'neu-laden'], 'dazu „Neu laden“ (B2)')
assert.equal(fx.text(fx.finde(karteEl, 'p')), `${SERVER_HINWEIS} Zu Verträge`, 'Text, Leerzeichen, Link wie in der Kachel')
assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt') === null, 'kein Fragebogen')
assert.equal(fx.alle(wurzel, 'input').length, 0)
assert.ok(fx.finde(wurzel, '.tm-reiterleiste') === null)
assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1')])
}))
test('Ansicht off ohne Abo: gekündigt, nicht aktiv, nicht lesbar und fehlendes abo zeigen nur den Text des Servers, ohne Link', mitDom(undefined, async (fx) => {
const faelle = [
['gekuendigt', { ok: false, grund: 'gekuendigt' }, ohneAbo('gekuendigt', 'Server: Dein Wartungs-Abo ist gekündigt.')],
['nicht_aktiv', { ok: false, grund: 'nicht_aktiv' }, ohneAbo('nicht_aktiv', 'Server: Dein Wartungs-Abo ist gerade nicht aktiv.')],
['Lesefehler (abo null)', null, { bestellbar: null, grund: null, hinweis: 'Server: Das Abo konnte gerade nicht geprüft werden.' }],
['abo fehlt in der Antwort', undefined, ohneAbo('gekuendigt', 'Server: ohne abo.')],
]
for (const [name, abo, termine] of faelle) {
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { abo }, termine }) })
const karteEl = fx.finde(wurzel, '[data-hinweis="abo"]')
assert.ok(karteEl, name)
assert.equal(fx.text(fx.finde(karteEl, 'p')), termine.hinweis, name)
assert.equal(fx.alle(karteEl, 'button[data-view]').length, 0, `${name}: kein Link`)
assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt') === null, name)
}
}))
test('B2: der Text zum Abo kommt mit den Daten (aboHinweis), auch wenn die Liste „bestellbar“ sagt und das Abo nicht lesbar war; Link bei kein_abo; „Neu laden“ holt alles neu', mitDom(undefined, async (fx) => {
// das Abo war beim Lesen der Daten nicht lesbar (abo null), die Liste kennt keinen Hinweis: früher stand nur die Überschrift da
const HINWEIS = 'Das Abo konnte gerade nicht geprüft werden.'
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { abo: null, aboHinweis: HINWEIS } }) })
const karteEl = fx.finde(wurzel, '[data-hinweis="abo"]')
assert.ok(karteEl)
assert.equal(fx.text(fx.finde(karteEl, 'p')), HINWEIS, 'der Text des Servers aus den Daten')
assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1')], 'dafür braucht es keine weitere Abfrage der Liste')
assert.equal(fx.alle(karteEl, 'button[data-view]').length, 0, 'ohne Grund kein_abo kein Link')
// kein_abo steht in den Daten: der Link zu Verträge auch ohne Angaben der Liste
const kein = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { abo: { ok: false, grund: 'kein_abo' }, aboHinweis: SERVER_HINWEIS } }) })
const kleiner = fx.finde(kein, '[data-hinweis="abo"]')
assert.equal(fx.text(fx.finde(kleiner, 'p')), `${SERVER_HINWEIS} Zu Verträge`)
// „Neu laden“: Daten noch einmal holen und die Ansicht nach dem neuen Stand zeichnen (das Abo läuft inzwischen: der Fragebogen)
const neu = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', {
daten: { abo: null, aboHinweis: HINWEIS },
tabelle: { [an('p1')]: [{ body: await ansichtDaten('off', { abo: null, aboHinweis: HINWEIS }) }, { body: await ansichtDaten('off', { fragebogen: fbBis(1) }) }] },
}) })
const laden = fx.finde(neu, '[data-hinweis="abo"] button[data-aktion="neu-laden"]')
assert.ok(laden, 'Knopf „Neu laden“')
assert.equal(fx.text(laden), 'Neu laden')
assert.equal(laden.getAttribute('type'), 'button')
await fx.klick(laden)
await fx.ruhe()
assert.ok(fx.finde(neu, '[data-hinweis="abo"]') === null, 'die Karte ist weg')
assert.ok(fx.finde(neu, 'nav.tm-fortschritt'), 'der Fragebogen steht da')
}))
test('Ansicht off ohne Abo: der Hinweis kommt maskiert in die Seite und der Browser kennt keinen eigenen Text', mitDom(undefined, async (fx) => {
const bose = '<img src=x onerror=alert(1)> & "Anführungszeichen"'
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { abo: { ok: false, grund: 'gekuendigt' } }, termine: ohneAbo('gekuendigt', bose) }) })
assert.equal(fx.alle(wurzel, 'img').length, 0)
assert.equal(fx.text(fx.finde(wurzel, '[data-hinweis="abo"] p')), bose)
}))
test('Ansicht off ohne Abo: fehlt der Text in der Liste (das Abo hat sich seit dem Laden geändert), wird die Liste noch einmal geholt', mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', {
daten: { abo: { ok: false, grund: 'kein_abo' } },
tabelle: { [AN_FUNKTIONEN]: [funktionenAntwort([projektEintrag('p1', 'off')]), funktionenAntwort([projektEintrag('p1', 'off')], ohneAbo('kein_abo'))] },
}) })
assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1'), AN_FUNKTIONEN])
assert.equal(fx.text(fx.finde(wurzel, '[data-hinweis="abo"] p')), `${SERVER_HINWEIS} Zu Verträge`)
// bleibt der Text auch dann aus, steht nur die Überschrift (kein erfundener Ersatztext)
const leer = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { abo: { ok: false, grund: 'gekuendigt' } } }) })
assert.ok(fx.finde(leer, '[data-hinweis="abo"] p') === null)
assert.equal(fx.text(fx.finde(leer, '[data-hinweis="abo"] h3')), 'Terminplaner einrichten')
}))
test('Ansicht off: in der Kundenansicht gilt dasselbe (Fragebogen mit Hinweis der Kundenansicht, ohne Abo der Hinweis)', mitDom(undefined, async (fx) => {
const mit = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { kundenansicht: true } }) })
assert.match(fx.text(fx.finde(mit, '.tm-box[role="note"]')), /Kundenansicht/)
const ohne = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { kundenansicht: true, abo: { ok: false, grund: 'gekuendigt' } }, termine: ohneAbo('gekuendigt') }) })
assert.ok(fx.finde(ohne, '[data-hinweis="abo"]'))
assert.ok(fx.finde(ohne, 'nav.tm-fortschritt') === null)
}))
test('Ansicht active ohne Einstellung: weiter die Vorlagenauswahl von TP1 (kein Fragebogen), mit Einstellung die vier Reiter', mitDom(undefined, async (fx) => {
const ohne = await oeffneAnsicht(fx, { tabelle: await einProjekt('active', { daten: { einstellung: null, version: 0, fragebogen: null } }) })
assert.equal(fx.alle(ohne, 'input[name="termine-vorlage"]').length, 1)
assert.ok(fx.text(ohne).includes('Womit möchtest du starten?'))
assert.ok(fx.finde(ohne, 'nav.tm-fortschritt') === null)
assert.ok(fx.finde(ohne, '.tm-reiterleiste') === null)
const mit = await oeffneAnsicht(fx, { tabelle: await einProjekt('active') })
assert.deepEqual(reiterTexte(fx, mit), ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen'])
assert.ok(fx.finde(mit, '.tm-statuskarte') === null)
assert.ok(fx.finde(mit, '.tm-hinweise .tm-box-warn') === null, 'weder Abschaltung noch fehlende Angaben')
}))
test('Ansicht active: fehlende Pflichtangaben zeigen den Hinweis „Noch nicht buchbar“ weiter wie bisher', mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('active', { daten: { fehlt: ['betrieb.telefon'] } }) })
assert.match(fx.text(fx.finde(wurzel, '.tm-hinweise .tm-box-warn')), /Noch nicht buchbar/)
}))
for (const [state, pille, ticket] of [['ordered', 'Zahlung offen', null], ['setup', 'In Einrichtung', 100342]]) {
test(`Ansicht ${state}: Statuskarte „${pille}“ und die Reiter Leistungen, Team & Plätze, Einstellungen nur zum Lesen, ohne Reiter „Termine“`, mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt(state, {
eintrag: { ...GEORDERT, ticketWoid: ticket, invoiceUrl: state === 'setup' ? 'https://invoice.stripe.test/i/in_1' : null },
daten: { fehlt: ['betrieb.telefon'], aktivSeit: null },
}) })
const karteEl = fx.finde(wurzel, '.tm-statuskarte')
assert.ok(karteEl)
assert.equal(karteEl.getAttribute('data-state'), state)
assert.equal(fx.text(fx.finde(karteEl, '.tm-pille')), pille)
assert.match(fx.text(karteEl), /Bestellt am\s*05\.10\.2026/, 'Bestelldatum aus dem Eintrag der Liste')
if (state === 'setup') {
assert.match(fx.text(karteEl), /Ticketnummer\s*#100342/)
assert.match(fx.text(karteEl), new RegExp(`Zeiteinschätzung\\s*${EINRICHTUNG}`), 'Einrichtungsdauer aus GET /api/funktionen')
}
assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt') === null, 'kein Fragebogen')
assert.deepEqual(reiterTexte(fx, wurzel), ['Leistungen', 'Team & Plätze', 'Einstellungen'])
assert.ok(fx.finde(wurzel, '#tm-tab-termine') === null)
assert.ok(fx.finde(wurzel, '.tm-hinweise .tm-box-warn') === null, 'weder Banner der Abschaltung noch „Noch nicht buchbar“')
// jeder Reiter ist schreibgeschützt: Felder gesperrt, keine Knöpfe zum Ändern
for (const [key, ohneKnopf] of [['leistungen', '[data-werkzeug="leistung-neu"]'], ['team', '[data-werkzeug="ressource-neu"]'], ['einstellungen', 'button[type="submit"]']]) {
await fx.klick(fx.finde(wurzel, `#tm-tab-${key}`))
await fx.ruhe()
const panel = fx.finde(wurzel, '#tm-panel')
assert.ok(fx.finde(panel, ohneKnopf) === null, `${key}: nichts zum Ändern`)
assert.ok(fx.finde(panel, 'fieldset[disabled]') || key !== 'einstellungen', `${key}: gesperrt`)
assert.ok(!fx.text(panel).includes('null'), `${key}: kein „null“ als Text (replaceChildren mit null)`)
}
// „Export und Status“ des Reiters Einstellungen sagt vor der Freischaltung nicht „Aktiv“
assert.ok(fx.finde(fx.finde(wurzel, '#tm-panel'), '.tm-status') === null)
assert.equal(fx.alle(fx.finde(wurzel, '#tm-panel'), 'a').length, 0, 'noch nichts zum Exportieren')
assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1')], 'der Reiter „Termine“ wird nicht gelesen (der Server antwortete mit 409)')
}))
}
test('Ansicht ordered/setup: die Pfeiltasten wandern nur durch die drei Reiter, ein gemerkter Reiter „Termine“ fällt auf den ersten zurück und bleibt für andere Websites gemerkt', mitDom(undefined, async (fx) => {
const tabelle = {
[AN_FUNKTIONEN]: funktionenAntwort([projektEintrag('p1', 'active'), projektEintrag('p2', 'ordered', GEORDERT)]),
[an('p1')]: { body: await ansichtDaten('active') },
[an('p2')]: { body: await ansichtDaten('ordered') },
}
const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', tabelle })
await fx.klick(fx.finde(wurzel, '#tm-tab-termine'))
await fx.setze(fx.finde(wurzel, '#termine-project'), 'p2')
await fx.ruhe()
assert.ok(fx.finde(wurzel, '.tm-statuskarte'))
assert.deepEqual(reiterTexte(fx, wurzel), ['Leistungen', 'Team & Plätze', 'Einstellungen'])
assert.equal(fx.finde(wurzel, '[role="tab"][aria-selected="true"]').dataset.reiter, 'leistungen', 'der erste Reiter, den es gibt')
await fx.sende(fx.finde(wurzel, '.tm-reiterleiste'), 'keydown', { key: 'ArrowLeft' })
assert.equal(fx.finde(wurzel, '[role="tab"][aria-selected="true"]').dataset.reiter, 'einstellungen', 'Pfeil links vom ersten Reiter springt zum letzten')
await fx.sende(fx.finde(wurzel, '.tm-reiterleiste'), 'keydown', { key: 'ArrowRight' })
assert.equal(fx.finde(wurzel, '[role="tab"][aria-selected="true"]').dataset.reiter, 'leistungen')
// zurück zur aktiven Website: der zuvor gewählte Reiter „Termine“ ist wieder da
await fx.setze(fx.finde(wurzel, '#termine-project'), 'p1')
await fx.ruhe()
assert.equal(fx.finde(wurzel, '[role="tab"][aria-selected="true"]').dataset.reiter, 'leistungen', 'die Wahl in p2 gilt ab jetzt')
}))
test('Ansicht ordered in der Kundenansicht: „Zahlung fortsetzen“ ist gesperrt', mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', { eintrag: GEORDERT, daten: { kundenansicht: true } }) })
assert.equal(fx.finde(wurzel, '[data-aktion="fortsetzen"]').disabled, true)
}))
test('Ansicht ordered/setup ohne Einstellung: nur die Statuskarte, keine Reiter', mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('setup', { eintrag: GEORDERT, daten: { einstellung: null, version: 0 } }) })
assert.ok(fx.finde(wurzel, '.tm-statuskarte'))
assert.ok(fx.finde(wurzel, '.tm-reiterleiste') === null)
assert.ok(!fx.text(wurzel).includes('null'))
}))
test('Ansicht disabled: Banner der Abschaltung (hinweisAbgeschaltet) mit Löschdatum aus abgeschaltet.loeschungAm und Export-Link, alle vier Reiter, unverändert (E11)', mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('disabled') })
const text = ansicht.abschaltText(ABGESCHALTET)
const banner = fx.finde(wurzel, '.tm-hinweise .tm-box-warn')
assert.ok(banner)
assert.equal(fx.text(banner), `${text.davor}exportieren${text.danach}`)
assert.match(fx.text(banner), /03\.11\.2026|2026-11-03/, 'das Löschdatum aus loeschungAm')
assert.deepEqual(fx.alle(banner, 'a').map((a) => [fx.text(a), a.getAttribute('href')]), [['exportieren', '/api/termine/export.csv?projectId=p1']])
assert.deepEqual(reiterTexte(fx, wurzel), ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen'])
assert.ok(fx.finde(wurzel, '.tm-statuskarte') === null)
assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt') === null)
await fx.klick(fx.finde(wurzel, '#tm-tab-einstellungen'))
await fx.ruhe()
assert.match(fx.text(fx.finde(wurzel, '.tm-status')), /Abgeschaltet/, 'Export und Status bleiben in disabled')
}))
test('Ansicht disabled ohne Einstellung: Banner und „Keine Termine eingerichtet“ wie bisher', mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('disabled', { daten: { einstellung: null, version: 0 } }) })
assert.ok(fx.finde(wurzel, '.tm-box-warn[role="note"]'))
assert.ok(fx.text(wurzel).includes('Keine Termine eingerichtet'))
assert.ok(fx.finde(wurzel, '.tm-reiterleiste') === null)
}))
test('Ansicht: der Zustand der Daten gilt, auch wenn die Liste einen anderen kennt (Liste: off, Daten: active)', mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { tabelle: { [AN_FUNKTIONEN]: funktionenAntwort([projektEintrag('p1', 'off')]), [an('p1')]: { body: await ansichtDaten('active') } } })
assert.deepEqual(reiterTexte(fx, wurzel), ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen'])
}))
test('Ansicht ordered: kennt die Liste einen älteren Zustand als die Daten (nach der Zahlung), wird sie neu geholt und die Karte zeigt die Ticketnummer', mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { tabelle: {
[AN_FUNKTIONEN]: [funktionenAntwort([projektEintrag('p1', 'ordered', GEORDERT)]), funktionenAntwort([projektEintrag('p1', 'setup', { ...GEORDERT, ticketWoid: 100342 })])],
[an('p1')]: { body: await ansichtDaten('setup') },
} })
assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1'), AN_FUNKTIONEN])
assert.equal(fx.text(fx.finde(wurzel, '.tm-statuskarte .tm-pille')), 'In Einrichtung')
assert.match(fx.text(fx.finde(wurzel, '.tm-statuskarte')), /Ticketnummer\s*#100342/)
// lässt sich die Liste nicht holen, gilt der Zustand der Daten, ohne Ticket
const ohne = await oeffneAnsicht(fx, { tabelle: {
[AN_FUNKTIONEN]: [funktionenAntwort([projektEintrag('p1', 'ordered', GEORDERT)]), { status: 500, body: {} }],
[an('p1')]: { body: await ansichtDaten('setup') },
} })
assert.equal(fx.text(fx.finde(ohne, '.tm-statuskarte .tm-pille')), 'In Einrichtung')
assert.match(fx.text(fx.finde(ohne, '.tm-statuskarte')), /Wir legen dein Ticket gerade an\./)
assert.ok(fx.finde(ohne, '[data-aktion="fortsetzen"]') === null, 'kein „Zahlung fortsetzen“ für eine bezahlte Bestellung')
}))
test('Ansicht: ein Fehler beim Laden zeigt die Fehlerkarte mit „Noch einmal versuchen“, das lädt neu', mitDom(undefined, async (fx) => {
const tabelle = await einProjekt('off', { tabelle: { [an('p1')]: [{ status: 500, body: { error: 'Der Server hat gerade Pause.' } }, { body: await ansichtDaten('off') }] } })
const wurzel = await oeffneAnsicht(fx, { tabelle })
assert.equal(fx.text(fx.finde(wurzel, '[role="alert"] p')), 'Der Server hat gerade Pause.')
await fx.klick(fx.finde(wurzel, '[role="alert"] button'))
await fx.ruhe()
assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt'))
}))
// ------------------------------------------------------- Rückkehr von Stripe
const zweiProjekte = async (zustandP2 = 'ordered', extraP2 = GEORDERT) => ({
[AN_FUNKTIONEN]: funktionenAntwort([projektEintrag('p1', 'off'), projektEintrag('p2', zustandP2, extraP2)]),
[an('p1')]: { body: await ansichtDaten('off') },
[an('p2')]: { body: await ansichtDaten(zustandP2) },
})
/** Aufrufe von history.replaceState mitschreiben. */
function merkeAdressen(fx) {
const adressen = []
const echt = fx.window.history.replaceState
fx.window.history.replaceState = (zustand, titel, url) => { adressen.push(url); echt(zustand, titel, url) }
return adressen
}
test('Rückkehr nach der Zahlung: #termine?bestellt=1&projekt=p2 wählt p2, zeigt „Danke!“, bereinigt die Adresse (einmal gelesen)', mitDom(undefined, async (fx) => {
const adressen = merkeAdressen(fx)
const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', hash: '#termine?bestellt=1&projekt=p2', tabelle: await zweiProjekte() })
assert.equal(fx.finde(wurzel, '#termine-project').value, 'p2', 'das Projekt der Rückkehr (vor dem gemerkten p1)')
assert.equal(fx.text(fx.finde(wurzel, '[data-danke]')), 'Danke! Sobald die Zahlung da ist, legen wir los.')
assert.equal(fx.text(fx.finde(wurzel, '.tm-statuskarte .tm-pille')), 'Zahlung offen')
assert.deepEqual(adressen, ['/dashboard.html#termine'], 'Pfad und Query bleiben, der Hash bis auf #termine weg')
assert.equal(fx.window.location.hash, '#termine')
assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p2')], 'nur das Projekt der Rückkehr wird geladen')
// ein zweiter Aufruf (Hash ist bereinigt) kennt weder Rückkehr noch Danke, behält aber die Auswahl
const wieder = await oeffneAnsicht(fx, { projectId: '', hash: fx.window.location.hash, tabelle: await zweiProjekte() })
assert.ok(fx.finde(wieder, '[data-danke]') === null)
assert.equal(fx.finde(wieder, '#termine-project').value, 'p2')
}))
test('Rückkehr nach der Zahlung, wenn der Webhook schon durch ist (setup): „Danke!“ auch dann', mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', hash: '#termine?bestellt=1&projekt=p2', tabelle: await zweiProjekte('setup', { ...GEORDERT, ticketWoid: 100342 }) })
assert.ok(fx.finde(wurzel, '[data-danke]'))
assert.equal(fx.text(fx.finde(wurzel, '.tm-statuskarte .tm-pille')), 'In Einrichtung')
}))
test('Rückkehr nach dem Abbruch: #termine?projekt=p2 wählt p2 ohne „Danke!“, die Adresse ist bereinigt; „Zahlung fortsetzen“ ist da', mitDom(undefined, async (fx) => {
const adressen = merkeAdressen(fx)
const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', hash: '#termine?projekt=p2', tabelle: await zweiProjekte() })
assert.equal(fx.finde(wurzel, '#termine-project').value, 'p2')
assert.ok(fx.finde(wurzel, '[data-danke]') === null)
assert.ok(!fx.text(wurzel).includes('Danke!'))
assert.ok(fx.finde(wurzel, '[data-aktion="fortsetzen"]'))
assert.deepEqual(adressen, ['/dashboard.html#termine'])
assert.equal(fx.window.location.hash, '#termine')
}))
test('Rückkehr: bestellt=1 ohne Projekt gilt dem gewählten Projekt; ein unbekanntes Projekt wird ignoriert (kein „Danke!“ für ein anderes)', mitDom(undefined, async (fx) => {
const ohneProjekt = await oeffneAnsicht(fx, { projectId: 'p2', hash: '#termine?bestellt=1', tabelle: await zweiProjekte() })
assert.ok(fx.finde(ohneProjekt, '[data-danke]'))
assert.equal(fx.window.location.hash, '#termine')
const unbekannt = await oeffneAnsicht(fx, { projectId: 'p2', hash: '#termine?bestellt=1&projekt=gibtsnicht', tabelle: await zweiProjekte() })
assert.ok(fx.finde(unbekannt, '[data-danke]') === null)
assert.equal(fx.finde(unbekannt, '#termine-project').value, 'p2', 'es bleibt bei der gewählten Website')
assert.equal(fx.window.location.hash, '#termine')
}))
test('Rückkehr: „Danke!“ nur für das Projekt der Zahlung und nur beim ersten Zeichnen, nicht nach einem Wechsel der Website', mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', hash: '#termine?bestellt=1&projekt=p2', tabelle: await zweiProjekte() })
assert.ok(fx.finde(wurzel, '[data-danke]'))
await fx.setze(fx.finde(wurzel, '#termine-project'), 'p1')
await fx.ruhe()
assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt'), 'p1 ist die Website im Zustand off')
await fx.setze(fx.finde(wurzel, '#termine-project'), 'p2')
await fx.ruhe()
assert.ok(fx.finde(wurzel, '.tm-statuskarte'))
assert.ok(fx.finde(wurzel, '[data-danke]') === null, 'der Dank war einmal')
}))
test('Rückkehr: bestellt=1 für ein Projekt, das nicht bestellt ist (active, off), zeigt kein „Danke!“; fremde Hashes werden nicht angefasst', mitDom(undefined, async (fx) => {
const adressen = merkeAdressen(fx)
const aktiv = await oeffneAnsicht(fx, { projectId: 'p2', hash: '#termine?bestellt=1&projekt=p2', tabelle: await zweiProjekte('active', {}) })
assert.ok(fx.finde(aktiv, '[data-danke]') === null)
assert.ok(!fx.text(aktiv).includes('Danke!'))
assert.equal(adressen.length, 1)
const fremd = await oeffneAnsicht(fx, { projectId: 'p2', hash: '#news?bestellt=1&projekt=p2', tabelle: await zweiProjekte() })
assert.ok(fx.finde(fremd, '[data-danke]') === null, 'die Rückkehr der News gehört nicht hierher')
assert.equal(adressen.length, 1, 'und ihr Hash bleibt stehen')
assert.equal(fx.window.location.hash, '#news?bestellt=1&projekt=p2')
}))
test('Rückkehr: Projekt-ID aus der Adresse landet nur als Wert in der Anfrage (codiert), nie im Baum', mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', hash: '#termine?bestellt=1&projekt=%3Cimg%20src%3Dx%3E', tabelle: await zweiProjekte() })
assert.equal(fx.alle(wurzel, 'img').length, 0)
assert.ok(!AUFRUFE(fx).some((a) => a.includes('<img')))
}))
// ------------------------------------------------------- „Zahlung fortsetzen“ scheitert: Ansicht neu aufbauen (Ruling R110)
const ORDERED_P1 = () => projektEintrag('p1', 'ordered', GEORDERT)
const fortsetzenKnopf = (fx, wurzel) => fx.finde(wurzel, '[data-aktion="fortsetzen"]')
test('R110: 409 zustimmung_fehlt ⇒ Liste und Daten werden neu geholt und das Bestellformular steht an der Stelle der Karte', mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', {
eintrag: GEORDERT,
tabelle: { [AN_CHECKOUT]: { status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } } },
}) })
assert.ok(fx.finde(wurzel, '.tm-statuskarte'))
assert.ok(fx.finde(wurzel, '.tm-bestellen') === null)
await fx.klick(fortsetzenKnopf(fx, wurzel))
await fx.ruhe()
assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1'), AN_CHECKOUT, AN_FUNKTIONEN, an('p1')])
assert.deepEqual(fx.fetchLog[2].body, { projectId: 'p1', fortsetzen: true })
assert.ok(fx.finde(wurzel, '.tm-statuskarte') === null, 'die Karte ist weg')
const seite = fx.finde(wurzel, '.tm-bestellen')
assert.ok(seite, 'das Bestellformular')
assert.equal(fx.text(bestellKnopf(fx, wurzel)), (await bestellungTest()).consent.knopf, 'Knopftext aus der Antwort des Servers')
assert.equal(bestellKnopf(fx, wurzel).disabled, true, 'gesperrt, bis alle Haken stehen')
assert.equal(hakenFelder(fx, seite).length, registerHaken().length)
assert.equal(meldungDerHuelle(fx, wurzel), 'Bitte stimme der Erklärung zu, um zu bestellen.')
assert.deepEqual(reiterTexte(fx, wurzel), ['Leistungen', 'Team & Plätze', 'Einstellungen'], 'die Reiter bleiben, nur lesbar')
// „Ändern“ führt nicht in den (gesperrten) Fragebogen, sondern erklärt, warum nicht
await fx.klick(fx.finde(wurzel, '[data-aktion="aendern"]'))
assert.match(meldungDerHuelle(fx, wurzel), /seit der Bestellung gesperrt/)
// nach dem Setzen aller Haken geht die neue Zustimmung an den Checkout und es folgt die Zahlungsseite
routen(fx, { [AN_CHECKOUT]: { body: { url: KNOPF_URL } } })
await alleAnhaken(fx, seite)
assert.equal(bestellKnopf(fx, wurzel).disabled, false)
await fx.klick(bestellKnopf(fx, wurzel))
const bestellt = fx.fetchLog.at(-1)
assert.equal(bestellt.url, '/api/funktionen/termine/checkout')
assert.equal(bestellt.body.zustimmung.version, (await bestellungTest()).consent.version)
assert.equal(fx.window.location.href, KNOPF_URL)
}))
test('R110: 400 (die Bestellung ist inzwischen bezahlt, der Server kennt sie nicht mehr als offen) ⇒ die Ansicht folgt dem neuen Zustand, die Karte steht nicht veraltet da', mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', {
eintrag: GEORDERT,
tabelle: {
[AN_CHECKOUT]: { status: 400, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } },
[AN_FUNKTIONEN]: [funktionenAntwort([ORDERED_P1()]), funktionenAntwort([projektEintrag('p1', 'setup', { ...GEORDERT, ticketWoid: 100342 })])],
[an('p1')]: [{ body: await ansichtDaten('ordered') }, { body: await ansichtDaten('setup') }],
},
}) })
const alteKarte = fx.finde(wurzel, '.tm-statuskarte')
await fx.klick(fortsetzenKnopf(fx, wurzel))
await fx.ruhe()
const karteEl = fx.finde(wurzel, '.tm-statuskarte')
assert.ok(karteEl !== alteKarte, 'neu gezeichnet')
assert.equal(fx.text(fx.finde(karteEl, '.tm-pille')), 'In Einrichtung')
assert.match(fx.text(karteEl), /Ticketnummer\s*#100342/)
assert.ok(fortsetzenKnopf(fx, wurzel) === null)
assert.ok(fx.finde(wurzel, '.tm-bestellen') === null, 'kein Bestellformular für eine bezahlte Bestellung')
assert.equal(meldungDerHuelle(fx, wurzel), '', 'der Fehler des alten Zustands steht nicht in der neuen Karte')
assert.ok(!fx.text(wurzel).includes('stimme der Erklärung'))
}))
test('R110: ist die Funktion nach dem Webhook aktiv, zeigt die Ansicht die vier Reiter statt der Karte', mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', {
eintrag: GEORDERT,
tabelle: {
[AN_CHECKOUT]: { status: 400, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } },
[AN_FUNKTIONEN]: [funktionenAntwort([ORDERED_P1()]), funktionenAntwort([projektEintrag('p1', 'active')])],
[an('p1')]: [{ body: await ansichtDaten('ordered') }, { body: await ansichtDaten('active') }],
},
}) })
await fx.klick(fortsetzenKnopf(fx, wurzel))
await fx.ruhe()
assert.ok(fx.finde(wurzel, '.tm-statuskarte') === null)
assert.deepEqual(reiterTexte(fx, wurzel), ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen'])
assert.equal(meldungDerHuelle(fx, wurzel), '')
}))
test('R110: ein anderer Fehler bei noch offener Bestellung (409, die Sitzung läuft schon) ⇒ die Karte wird neu gezeichnet und der Fehler steht am Knopf', mitDom(undefined, async (fx) => {
const FEHLER = 'Deine Zahlung läuft gerade oder ist schon da. Wir richten den Terminplaner ein, sobald sie bei uns ist.'
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', {
eintrag: GEORDERT,
tabelle: { [AN_CHECKOUT]: { status: 409, body: { error: FEHLER } } },
}) })
const alteKarte = fx.finde(wurzel, '.tm-statuskarte')
await fx.klick(fortsetzenKnopf(fx, wurzel))
await fx.ruhe()
assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1'), AN_CHECKOUT, AN_FUNKTIONEN, an('p1')])
const karteEl = fx.finde(wurzel, '.tm-statuskarte')
assert.ok(karteEl !== alteKarte)
assert.equal(fx.text(fx.finde(karteEl, '[data-meldung="fortsetzen"]')), FEHLER)
assert.equal(fortsetzenKnopf(fx, wurzel).disabled, false, 'der Knopf ist frei')
assert.ok(fx.finde(wurzel, '.tm-bestellen') === null)
}))
test('R110: lässt sich die Ansicht nicht neu laden, bleibt die Karte stehen und der Fehler steht am Knopf (bei fehlender Zustimmung der Grund des Neuladens)', mitDom(undefined, async (fx) => {
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', {
eintrag: GEORDERT,
tabelle: {
[AN_CHECKOUT]: [{ status: 409, body: { error: 'Deine Zahlung läuft gerade.' } }, { status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }],
[AN_FUNKTIONEN]: [funktionenAntwort([ORDERED_P1()]), { status: 500, body: { error: 'Der Server hat gerade Pause.' } }],
},
}) })
const karteEl = fx.finde(wurzel, '.tm-statuskarte')
await fx.klick(fortsetzenKnopf(fx, wurzel))
await fx.ruhe()
assert.ok(fx.finde(wurzel, '.tm-statuskarte') === karteEl, 'dieselbe Karte')
assert.equal(fx.text(fx.finde(karteEl, '[data-meldung="fortsetzen"]')), 'Deine Zahlung läuft gerade.')
assert.equal(fortsetzenKnopf(fx, wurzel).disabled, false)
await fx.klick(fortsetzenKnopf(fx, wurzel))
await fx.ruhe()
assert.ok(fx.finde(wurzel, '.tm-statuskarte') === karteEl)
assert.equal(fx.text(fx.finde(karteEl, '[data-meldung="fortsetzen"]')), 'Der Server hat gerade Pause.')
assert.ok(fx.finde(wurzel, '.tm-bestellen') === null)
}))
test('R110: wechselt der Nutzer die Website, während neu geladen wird, zeichnet die späte Antwort nichts mehr', mitDom(undefined, async (fx) => {
let freigeben
const spaet = new Promise((fertig) => { freigeben = fertig })
const tabelle = { ...(await zweiProjekte()), [AN_CHECKOUT]: { status: 409, body: { error: 'Deine Zahlung läuft gerade.' } } }
const wurzel = await oeffneAnsicht(fx, { projectId: 'p2', tabelle })
// ab jetzt hängt das Laden der Daten von p2, bis der Test es freigibt
const antwort = globalThis.fetch
globalThis.fetch = async (url, optionen) => {
if (String(url) !== '/api/termine/einstellungen?projectId=p2') return antwort(url, optionen)
fx.fetchLog.push({ method: 'GET', url: String(url), body: undefined })
await spaet
return { ok: true, status: 200, json: async () => structuredClone(await ansichtDaten('setup')) }
}
const klick = fx.klick(fortsetzenKnopf(fx, wurzel))
await fx.ruhe()
await fx.setze(fx.finde(wurzel, '#termine-project'), 'p1')
await fx.ruhe()
assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt'), 'p1 steht da')
freigeben()
await klick
await fx.ruhe()
assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt'), 'die späte Antwort für p2 hat p1 nicht überschrieben')
assert.ok(fx.finde(wurzel, '.tm-statuskarte') === null)
}))