Funktionen: Bestellseite und Statuskarten

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 21:27:36 +00:00
1 parent 747be432c5
commit 20583424a3
4 files changed
+1051 -2

No files matched your search

+687 -2
View File
@@ -1,11 +1,13 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
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, leerFragebogen } from '../server/services/termine/fragebogen.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'
@@ -13,6 +15,7 @@ import { installFakeDom } from './support/fakeDom.js'
import * as einstellungenTab from '../public/termine-tab-einstellungen.js'
import * as teamTab from '../public/termine-tab-team.js'
import * as huelle from '../public/termine.js'
import * as bestellen from '../public/funktion-bestellen.js'
import { FENSTER_FEHLER } from '../public/termine-view.js'
import { h, request } from '../public/termine-dom.js'
import { neuerSpeicher } from '../public/termine-speichern.js'
@@ -837,3 +840,685 @@ test('baueCtx: Schnittstelle, Daten und Meldungen wie der Reiter-Kontext', mitDo
schlicht.hinweise()
schlicht.zumReiter('team')
}))
// ------------------------------------------------------- request: ganzer Body des Fehlers (TP2, Task C8)
test('request: eine Fehlerantwort behält den ganzen Body (err.body), z. B. offen und consent der Bestellung', mitDom({
antworten: [{ status: 409, body: { error: 'Nicht vollständig.', code: 'bestellung_offen', offen: [{ schritt: 'website', text: 'Impressum fehlt.' }] } }, { status: 500, body: 'kein Objekt' }],
}, async () => {
await assert.rejects(request('/api/funktionen/termine/checkout', { method: 'POST', body: { projectId: 'p1' } }), (err) => {
assert.equal(err.status, 409)
assert.equal(err.code, 'bestellung_offen')
assert.equal(err.message, 'Nicht vollständig.')
assert.deepEqual(err.body.offen, [{ schritt: 'website', text: 'Impressum fehlt.' }])
return true
})
await assert.rejects(request('/x'), (err) => err.status === 500 && typeof err.body === 'object' && err.body !== null, 'auch ohne Objekt im Body ist err.body ein Objekt')
}))
// ------------------------------------------------------- funktion-bestellen.js: Bestellseite und Statuskarten (TP2, Task C8)
// Die Datei ist bewusst kein termine*.js (einzige mit href-Links); ihre Regeln pruefen die statischen Tests unten.
const BELEHRUNG = 'Widerrufsbelehrung\nDu hast das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.\n\nMuster-Widerrufsformular\n(Wenn Du den Vertrag widerrufen willst, fülle dieses Formular aus.)'
const KNOPF_URL = 'https://checkout.stripe.test/c/pay/cs_test_1'
const bestellungTest = async (aenderung = {}) => ({
...(await bestellungAnsicht(featureConfig('termine'))),
belehrung: BELEHRUNG,
dokumente: [
{ id: 'leistungsbeschreibung', titel: 'Leistungsbeschreibung', version: '2026-10-05', url: '/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.html' },
{ id: 'avv', titel: 'Auftragsverarbeitungsvertrag (AVV)', version: '2026-10-05', url: 'https://webklar.example.invalid/avv-terminplaner.html' },
],
...aenderung,
})
/** Antwort von GET /api/termine/einstellungen in off, die die Bestellseite braucht (bestellbarer Entwurf, nichts offen). */
const bestellDaten = async (aenderung = {}, bestellungAenderung = {}) => ({
state: 'off',
einstellung: bestellbarerStand({ now: JETZT }).einstellung,
fragebogen: bestellbarerStand({ now: JETZT }).fragebogen,
version: 1,
branchen: BRANCHEN.map(({ key, name, gesundheit }) => ({ key, name, gesundheit })),
kundenansicht: false,
abo: { ok: true },
offen: [],
bestellung: await bestellungTest(bestellungAenderung),
...aenderung,
})
const registerHaken = () => featureConfig('termine').consent.items.filter((item) => item.haken).map((item) => item.id)
/** Bestellseite in den Baum haengen. => { wurzel, ctx, schritte } (schritte: die Schluessel, zu denen zumSchritt gerufen wurde). */
async function bestellSeite(daten) {
const wurzel = h('div')
document.body.append(wurzel)
const ctx = huelle.baueCtx({ daten, nurLesen: false }, { projekt: { projectId: 'p1' }, aktuell: () => true })
const schritte = []
bestellen.renderBestellen(wurzel, { ctx, daten, zumSchritt: (key) => schritte.push(key) })
return { wurzel, ctx, schritte }
}
const bestellKnopf = (fx, wurzel) => fx.finde(wurzel, '[data-aktion="bestellen"]')
const hakenFelder = (fx, wurzel) => fx.alle(wurzel, 'input[type="checkbox"]')
const alleAnhaken = async (fx, wurzel) => { for (const haken of hakenFelder(fx, wurzel)) if (!haken.checked) await fx.klick(haken) }
const meldungVon = (fx, wurzel) => fx.text(fx.finde(wurzel, '[data-meldung="bestellen"]'))
// ------------------------------------------------------- sicherLink, bestellKoerper, fortsetzenKoerper
test('sicherLink: https-Adressen und Pfade unter /rechtliches/ gehen durch, alles andere wird zu ""', () => {
assert.equal(bestellen.sicherLink('https://x.de/a'), 'https://x.de/a')
assert.equal(bestellen.sicherLink('https://x.de'), 'https://x.de')
assert.equal(bestellen.sicherLink('/rechtliches/avv-terminplaner-v1.html'), '/rechtliches/avv-terminplaner-v1.html')
assert.equal(bestellen.sicherLink('/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.pdf'), '/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.pdf')
for (const schlecht of [
'javascript:alert(1)', 'JavaScript:alert(1)', ' javascript:alert(1)', 'data:text/html,<script>alert(1)</script>', 'vbscript:x', 'blob:https://x.de/a',
'http://x.de/a', 'ftp://x.de/a', '//evil.example', '//evil.example/rechtliches/x.html', '/\\evil.example', '\\\\evil.example',
'/vertragsunterlagen/x.html', '/rechtliches', '/rechtliches/', '/Rechtliches/x.html', 'rechtliches/x.html', '/x/rechtliches/x.html', '../rechtliches/x.html',
'/rechtliches/../app.js', '/rechtliches/%2e%2e/app.js', '/rechtliches/%2E./app.js', 'https:', 'https://', 'https:///x', 'https://user:pw@x.de/a', 'https://user@x.de/a',
' https://x.de/a', 'https://x.de/a ', '\thttps://x.de/a', 'https://x.de/a\n', 'https://x.de/ a', 'https://x.de/\u0000a', 'java\nscript:alert(1)', '/rechtliches/\nx.html', '/rechtliches/ x.html',
'', ' ', null, undefined, 42, {}, ['https://x.de/a'],
]) assert.equal(bestellen.sicherLink(schlecht), '', `muss abgewiesen werden: ${JSON.stringify(schlecht)}`)
})
test('bestellKoerper: genau { projectId, zustimmung: { version, haken } } mit den angehakten IDs der Haken-Items, nie Texte', async () => {
const bestellung = await bestellungTest()
const koerper = bestellen.bestellKoerper({ projectId: 'p1', bestellung, gewaehlt: new Set(registerHaken()) })
assert.deepEqual(Object.keys(koerper), ['projectId', 'zustimmung'])
assert.deepEqual(Object.keys(koerper.zustimmung), ['version', 'haken'])
assert.equal(koerper.projectId, 'p1')
assert.equal(koerper.zustimmung.version, bestellung.consent.version)
assert.deepEqual(koerper.zustimmung.haken, registerHaken())
const json = JSON.stringify(koerper)
for (const item of bestellung.consent.items) assert.ok(!json.includes(item.text), 'kein Text des Registers im Körper')
assert.ok(!json.includes(bestellung.consent.knopf), 'auch der Knopftext nicht')
assert.equal(koerper.consent, undefined)
})
test('bestellKoerper: nur angehakte IDs von Items mit haken: true, in Registerreihenfolge, Fremdes und Doppeltes fällt weg', () => {
const bestellung = {
consent: {
version: 'v-test',
knopf: 'Bestellen',
items: [
{ id: 'a', text: 'Hinweis ohne Haken', haken: false },
{ id: 'b', text: 'Erster Haken', haken: true },
{ id: 'c', text: 'Zweiter Haken', haken: true },
{ id: 'd', text: 'Noch ein Hinweis', haken: false },
{ id: 'e', text: 'Dritter Haken', haken: true },
],
},
}
// Array und Set, andere Reihenfolge, eine ID eines reinen Textes (a), eine unbekannte (zz) und ein Doppelter (e)
assert.deepEqual(bestellen.bestellKoerper({ projectId: 'p', bestellung, gewaehlt: ['e', 'c', 'a', 'zz', 'e', 'b'] }).zustimmung.haken, ['b', 'c', 'e'])
assert.deepEqual(bestellen.bestellKoerper({ projectId: 'p', bestellung, gewaehlt: new Set(['c']) }).zustimmung, { version: 'v-test', haken: ['c'] })
assert.deepEqual(bestellen.bestellKoerper({ projectId: 'p', bestellung, gewaehlt: [] }).zustimmung.haken, [])
assert.deepEqual(bestellen.bestellKoerper({ projectId: 'p', bestellung, gewaehlt: undefined }).zustimmung.haken, [])
})
test('bestellKoerper: die gesendeten IDs sind Teilmenge der Register-IDs (Querprüfung gegen featureConfig("termine").consent.items)', async () => {
const register = featureConfig('termine').consent
const bestellung = await bestellungTest()
const alle = bestellen.bestellKoerper({ projectId: 'p1', bestellung, gewaehlt: register.items.map((item) => item.id) })
assert.equal(alle.zustimmung.version, register.version, 'die Fassung des Registers')
const registerIds = new Set(register.items.map((item) => item.id))
assert.ok(alle.zustimmung.haken.length > 0)
assert.ok(alle.zustimmung.haken.every((id) => registerIds.has(id)))
assert.deepEqual(alle.zustimmung.haken, register.items.filter((item) => item.haken).map((item) => item.id), 'alle Haken des Registers, in Registerreihenfolge')
})
test('fortsetzenKoerper: { projectId, fortsetzen: true } ohne zustimmung', () => {
const koerper = bestellen.fortsetzenKoerper('p1')
assert.deepEqual(koerper, { projectId: 'p1', fortsetzen: true })
assert.deepEqual(Object.keys(koerper), ['projectId', 'fortsetzen'])
assert.equal(koerper.zustimmung, undefined)
})
// ------------------------------------------------------- statische Regeln
const BESTELLEN_DATEI = 'public/funktion-bestellen.js'
const quelleBestellen = () => readFileSync(new URL(`../${BESTELLEN_DATEI}`, import.meta.url), 'utf8')
const ohneKommentare = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '')
test('funktion-bestellen.js statisch: Fremdtext nur als Text, keine Dialoge, kein Browserspeicher, Du-Form, kein Knopftext-Literal, kein details', () => {
const roh = quelleBestellen()
const code = ohneKommentare(roh)
assert.ok(code.length > 2000, 'die Datei ist gefüllt')
assert.doesNotMatch(code, /innerHTML|insertAdjacentHTML|outerHTML|document\.write/, 'Daten nie als HTML')
assert.doesNotMatch(code, /\bconfirm\(|\balert\(|\bprompt\(/, 'Rückfragen stehen in der Seite')
assert.doesNotMatch(code, /localStorage|sessionStorage|document\.cookie/, 'kein Browserspeicher')
assert.doesNotMatch(code, /\bSie\b|\bIhr(e[nmrs]?)?\b/, 'Portaltexte in der Du-Form (die Rechtstexte kommen vom Server)')
assert.doesNotMatch(roh, /zahlungspflichtig/i, 'der Knopftext kommt aus bestellung.consent.knopf, nie aus dem Client')
assert.doesNotMatch(code, /details|summary/i, 'die Widerrufsbelehrung ist nie eingeklappt')
assert.match(code, /bestellung\.consent\.knopf/, 'der Knopf zeigt den Registertext')
})
test('funktion-bestellen.js statisch: jedes href nur über sicherLink(, location.href nur nach Prüfung auf https:', () => {
const code = ohneKommentare(quelleBestellen())
const hrefs = code.match(/(?:\bhref:|setAttribute\('href',)[^\n]*/g) || []
assert.ok(hrefs.length >= 2, 'Unterlagen und Rechnung sind Links')
for (const zeile of hrefs) assert.match(zeile, /sicherLink\(/, `href nur über sicherLink: ${zeile}`)
const zuweisungen = [...code.matchAll(/location\.href\s*=[^=]/g)]
assert.equal(zuweisungen.length, 1, 'genau eine Stelle navigiert')
const davor = code.slice(Math.max(0, zuweisungen[0].index - 250), zuweisungen[0].index)
assert.match(davor, /istHttps\(|'https:'/, 'unmittelbar davor wird auf https: geprüft')
assert.doesNotMatch(code, /location\.(assign|replace)\(|window\.open\(/, 'keine andere Navigation')
const neueTabs = code.split('\n').filter((zeile) => zeile.includes("'_blank'"))
assert.ok(neueTabs.length >= 2)
for (const zeile of neueTabs) assert.match(zeile, /rel: 'noopener noreferrer'/, `Links in neuem Tab tragen rel: ${zeile}`)
})
test('funktion-bestellen.js statisch: kein termine*.js, relative Importe, von keinem absoluten Import geladen', () => {
const dateien = readdirSync(new URL('../public/', import.meta.url)).filter((f) => f.endsWith('.js'))
assert.ok(dateien.includes('funktion-bestellen.js'))
assert.doesNotMatch('funktion-bestellen.js', /^termine.*\.js$/, 'nicht in der Dateiliste der termine*.js-Regeln')
const code = ohneKommentare(quelleBestellen())
assert.deepEqual(code.match(/from '[^']+'/g), ["from './termine-dom.js'", "from './termine-view.js'"], 'nur relative Importe, nur diese zwei')
assert.doesNotMatch(code, /import\s*\(/, 'kein dynamischer Import')
for (const datei of dateien) {
assert.doesNotMatch(readFileSync(new URL(`../public/${datei}`, import.meta.url), 'utf8'), /from '\/funktion-bestellen\.js'|import\('\/funktion-bestellen\.js'\)/, `${datei}: kein absoluter Import`)
}
const fn = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8')
assert.deepEqual(fn.match(/from '\/[^']+'/g), ["from '/analyse-view.js'", "from '/geraete.js'"], 'funktionen.js behält genau diese beiden absoluten Importe')
assert.doesNotMatch(fn, /funktion-bestellen/)
})
test('termine.css: Klassen der Bestellseite, des Rechtstextes (scrollbar) und der Statuskarte', () => {
const css = readFileSync(new URL('../public/termine.css', import.meta.url), 'utf8')
for (const klasse of ['tm-bestellen', 'tm-rechtstext', 'tm-statuskarte']) assert.match(css, new RegExp(`\\.${klasse}[\\s{,.:\\[]`), `.${klasse} ist gestaltet`)
const rechtstext = css.match(/\.tm-rechtstext\s*\{[^}]*\}/)?.[0] ?? ''
assert.match(rechtstext, /max-height:/, 'begrenzte Höhe')
assert.match(rechtstext, /overflow-y:\s*auto/, 'scrollbar, nie eingeklappt')
assert.match(rechtstext, /white-space:\s*pre-wrap/, 'Absätze und Zeilen der Belehrung bleiben erhalten')
})
// ------------------------------------------------------- renderBestellen: Aufbau
test('renderBestellen: Reihenfolge Zusammenfassung, offene Punkte, Preis, Belehrung, Unterlagen, Haken, Knopf', mitDom(undefined, async (fx) => {
const daten = await bestellDaten({ offen: [{ schritt: 'website', text: 'Bitte trage die Adresse deines Impressums ein.' }] })
const { wurzel } = await bestellSeite(daten)
const alle = fx.alle(wurzel, '*')
const stelle = (selektor) => {
const el = fx.finde(wurzel, selektor)
assert.ok(el, `${selektor} fehlt`)
return alle.indexOf(el)
}
const stellen = ['.tm-bestellen-zusammenfassung', '.tm-bestellen-offen', '.tm-bestellen-preis', '.tm-rechtstext', '.tm-bestellen-dokumente', '.tm-bestellen-haken input', '[data-aktion="bestellen"]'].map(stelle)
assert.deepEqual(stellen, [...stellen].sort((a, b) => a - b), 'Dokumentreihenfolge')
assert.equal(new Set(stellen).size, stellen.length)
assert.equal(fx.alle(wurzel, '[data-aktion="bestellen"]').length, 1, 'genau ein Bestell-Knopf')
assert.equal(fx.alle(wurzel, 'button').at(-1), bestellKnopf(fx, wurzel), 'der Knopf steht zuletzt')
}))
test('renderBestellen: ohne offene Punkte gibt es den Block „offen“ nicht', mitDom(undefined, async (fx) => {
const { wurzel } = await bestellSeite(await bestellDaten())
assert.equal(fx.finde(wurzel, '.tm-bestellen-offen'), null)
const { wurzel: wurzel2 } = await bestellSeite(await bestellDaten({ offen: null }))
assert.equal(fx.finde(wurzel2, '.tm-bestellen-offen'), null, 'offen: null (kein off-Zustand) zählt wie leer')
}))
test('renderBestellen: Knopftext == bestellung.consent.knopf == Registertext, kein Text im Client', mitDom(undefined, async (fx) => {
const daten = await bestellDaten()
const { wurzel } = await bestellSeite(daten)
const knopf = bestellKnopf(fx, wurzel)
assert.equal(fx.text(knopf), daten.bestellung.consent.knopf)
assert.equal(fx.text(knopf), featureConfig('termine').consent.knopf)
// ein anderer Text im Register ist sofort der Text des Knopfes
const andere = await bestellDaten({}, { consent: { ...daten.bestellung.consent, knopf: 'Jetzt kaufen · 1 €' } })
const { wurzel: wurzel2 } = await bestellSeite(andere)
assert.equal(fx.text(bestellKnopf(fx, wurzel2)), 'Jetzt kaufen · 1 €')
}))
test('renderBestellen: die vollständige Widerrufsbelehrung steht als Text in einem scrollbaren Bereich vor dem Knopf, nie eingeklappt', mitDom(undefined, async (fx) => {
const { wurzel } = await bestellSeite(await bestellDaten())
const bereich = fx.finde(wurzel, '.tm-rechtstext')
assert.equal(bereich.textContent, BELEHRUNG, 'der ganze Text, unverändert (auch Zeilenumbrüche)')
assert.ok(bereich.textContent.includes('Muster-Widerrufsformular'))
assert.equal(bereich.getAttribute('tabindex'), '0', 'mit der Tastatur scrollbar')
assert.equal(bereich.hidden, false)
assert.equal(bereich.closest('details'), null)
assert.equal(fx.alle(wurzel, 'details').length, 0)
assert.equal(fx.alle(wurzel, '[hidden]').length, 0, 'nichts auf der Seite ist versteckt')
assert.equal(fx.alle(wurzel, '[aria-expanded]').length, 0, 'und nichts klappt auf oder zu')
const alle = fx.alle(wurzel, '*')
assert.ok(alle.indexOf(bereich) < alle.indexOf(bestellKnopf(fx, wurzel)), 'der Belehrungstext steht vor dem Knopf')
const hakenStellen = hakenFelder(fx, wurzel).map((haken) => alle.indexOf(haken))
assert.ok(hakenStellen.every((stelle) => stelle > alle.indexOf(bereich)), 'und vor den Haken')
assert.equal(fx.text(fx.finde(wurzel, '.tm-bestellen-belehrung h4')), 'Widerrufsbelehrung', 'mit Überschrift')
}))
test('renderBestellen: ohne Belehrungstext (belehrung: null) gibt es den Bereich nicht', mitDom(undefined, async (fx) => {
const { wurzel } = await bestellSeite(await bestellDaten({}, { belehrung: null }))
assert.equal(fx.finde(wurzel, '.tm-rechtstext'), null)
const { wurzel: leer } = await bestellSeite(await bestellDaten({}, { belehrung: '' }))
assert.equal(fx.finde(leer, '.tm-rechtstext'), null)
}))
test('renderBestellen: Unterlagen sind Links nur über sicherLink, unsichere Adressen werden ausgelassen', mitDom(undefined, async (fx) => {
const daten = await bestellDaten({}, {
dokumente: [
{ id: 'leistungsbeschreibung', titel: 'Leistungsbeschreibung', version: 'v1', url: '/rechtliches/leistungsbeschreibung-terminplaner-v1.html' },
{ id: 'avv', titel: 'Auftragsverarbeitungsvertrag (AVV)', version: 'v1', url: 'https://webklar.example.invalid/avv.html' },
{ id: 'widerruf', titel: 'Widerrufsbelehrung', version: 'v1', url: 'javascript:alert(1)' },
{ id: 'x', titel: 'Fremd', version: 'v1', url: '//evil.example/x.html' },
{ id: 'y', titel: 'Unverschlüsselt', version: 'v1', url: 'http://webklar.example.invalid/y.html' },
],
})
const { wurzel } = await bestellSeite(daten)
const links = fx.alle(wurzel, '.tm-bestellen-dokumente a')
assert.deepEqual(links.map((a) => [fx.text(a), a.getAttribute('href')]), [
['Leistungsbeschreibung', '/rechtliches/leistungsbeschreibung-terminplaner-v1.html'],
['Auftragsverarbeitungsvertrag (AVV)', 'https://webklar.example.invalid/avv.html'],
])
for (const a of links) {
assert.equal(a.getAttribute('target'), '_blank')
assert.equal(a.getAttribute('rel'), 'noopener noreferrer')
}
for (const a of fx.alle(wurzel, 'a')) assert.notEqual(a.getAttribute('href'), 'javascript:alert(1)')
// ohne verwendbare Unterlagen entfällt der Block
const { wurzel: ohne } = await bestellSeite(await bestellDaten({}, { dokumente: [] }))
assert.equal(fx.finde(ohne, '.tm-bestellen-dokumente'), null)
}))
test('renderBestellen: Preis mit Hinweis aus dem Server und Einrichtungsdauer', mitDom(undefined, async (fx) => {
const daten = await bestellDaten()
const { wurzel } = await bestellSeite(daten)
const preis = fx.text(fx.finde(wurzel, '.tm-bestellen-preis'))
assert.ok(preis.includes(daten.bestellung.preis), 'der Preis')
assert.ok(preis.includes(daten.bestellung.preisHinweis), 'der Hinweis steht im Text vom Server')
assert.ok(preis.includes(daten.bestellung.einrichtung), 'die Einrichtungsdauer')
}))
test('renderBestellen: Zustimmungs-Haken in Registerreihenfolge mit dem Wortlaut des Registers, reine Texte ohne Haken', mitDom(undefined, async (fx) => {
const daten = await bestellDaten()
const { wurzel } = await bestellSeite(daten)
const felder = hakenFelder(fx, wurzel)
assert.deepEqual(felder.map((haken) => haken.dataset.haken), registerHaken(), 'IDs in Registerreihenfolge')
const items = featureConfig('termine').consent.items.filter((item) => item.haken)
felder.forEach((haken, i) => {
assert.equal(fx.text(haken.closest('label')), items[i].text, 'Wortlaut des Registers')
assert.equal(haken.checked, false, 'nichts ist vorab angehakt')
})
// gemischt: Items ohne haken sind reiner Text an ihrer Stelle
const consent = {
version: 'v-test',
knopf: 'Bestellen',
items: [
{ id: 'a', text: 'Hinweis vorab', haken: false },
{ id: 'b', text: 'Ich stimme dem ersten zu.', haken: true },
{ id: 'c', text: 'Hinweis in der Mitte', haken: false },
{ id: 'd', text: 'Ich stimme dem zweiten zu.', haken: true },
],
}
const { wurzel: gemischt } = await bestellSeite(await bestellDaten({}, { consent }))
const bereich = fx.finde(gemischt, '.tm-bestellen-haken')
assert.deepEqual(fx.alle(bereich, 'input[type="checkbox"]').map((haken) => haken.dataset.haken), ['b', 'd'])
const zeilen = bereich.children.filter((el) => el.matches('p, label')).map((el) => [el.tagName, fx.text(el)])
assert.deepEqual(zeilen, [['P', 'Hinweis vorab'], ['LABEL', 'Ich stimme dem ersten zu.'], ['P', 'Hinweis in der Mitte'], ['LABEL', 'Ich stimme dem zweiten zu.']])
}))
test('renderBestellen: Kundentext und Registertext landen nur als Text im Baum', mitDom(undefined, async (fx) => {
const boese = '<img src=x onerror=alert(1)>'
const daten = await bestellDaten({
offen: [{ schritt: 'website', text: boese }],
einstellung: { ...bestellbarerStand({ now: JETZT }).einstellung, betrieb: { ...BETRIEB, name: boese } },
}, { belehrung: boese, consent: { version: 'v', knopf: boese, items: [{ id: 'a', text: boese, haken: true }] }, preis: boese, einrichtung: boese })
const { wurzel } = await bestellSeite(daten)
assert.equal(fx.alle(wurzel, 'img').length, 0)
assert.ok(fx.text(wurzel).includes(boese))
}))
test('renderBestellen: fehlt die Bestellung (kein off-Zustand), steht eine Fehlerkarte statt der Seite', mitDom(undefined, async (fx) => {
const { wurzel } = await bestellSeite(await bestellDaten({ bestellung: null }))
assert.equal(fx.finde(wurzel, '[data-aktion="bestellen"]'), null)
assert.match(fx.text(wurzel), /Bestellseite/)
}))
// ------------------------------------------------------- Zusammenfassung und offene Punkte
test('renderBestellen: Zusammenfassung zeigt die acht Gruppen aus zusammenfassung(), „Ändern“ ruft zumSchritt(key)', mitDom(undefined, async (fx) => {
const daten = await bestellDaten()
const { wurzel, schritte } = await bestellSeite(daten)
const erwartet = zusammenfassung(daten.einstellung, daten.fragebogen, daten.branchen)
const gruppen = fx.alle(wurzel, '.tm-bestellen-gruppe')
assert.deepEqual(gruppen.map((g) => g.dataset.schritt), FRAGEBOGEN_SCHRITTE.map(([key]) => key), 'Reihenfolge der Schritte')
gruppen.forEach((gruppe, i) => {
assert.equal(fx.text(fx.finde(gruppe, 'h4')), erwartet[i].titel)
assert.deepEqual(fx.alle(gruppe, 'li').map((li) => fx.text(li)), erwartet[i].zeilen, 'Zeilen unverändert als Text')
})
assert.ok(erwartet.some((g) => g.zeilen.length > 0))
assert.ok(fx.text(gruppen[0]).includes(`Branche: ${daten.branchen.find((b) => b.key === 'salon').name}`), 'die Angaben des Kunden stehen da')
const aendern = fx.alle(wurzel, '[data-aktion="aendern"]')
assert.equal(aendern.length, 8, 'je Gruppe ein Knopf')
assert.ok(aendern.every((knopf) => fx.text(knopf) === 'Ändern'))
assert.match(aendern[2].getAttribute('aria-label'), /^Ändern: /)
await fx.klick(aendern[2])
await fx.klick(aendern[7])
assert.deepEqual(schritte, ['leistungen', 'website'])
}))
test('renderBestellen: eine Gruppe ohne Angaben sagt es und hat trotzdem „Ändern“; fehlendes zumSchritt ist kein Fehler', mitDom(undefined, async (fx) => {
const daten = await bestellDaten({ einstellung: null, fragebogen: null })
const wurzel = h('div')
document.body.append(wurzel)
const ctx = huelle.baueCtx({ daten, nurLesen: false }, { projekt: { projectId: 'p1' }, aktuell: () => true })
bestellen.renderBestellen(wurzel, { ctx, daten })
const gruppen = fx.alle(wurzel, '.tm-bestellen-gruppe')
assert.equal(gruppen.length, 8)
assert.match(fx.text(gruppen[0]), /Noch keine Angaben/)
await fx.klick(fx.finde(gruppen[0], '[data-aktion="aendern"]'))
}))
test('renderBestellen: offene Punkte mit „Zum Schritt“ zu ihrem Schritt, der Abo-Punkt (bestellen) ohne Knopf; Knopf gesperrt', mitDom(undefined, async (fx) => {
const offen = [
{ schritt: 'branche', text: 'Bitte trage deine Anschrift ein.' },
{ schritt: 'website', text: 'Bitte trage die Adresse deines Impressums ein.' },
{ schritt: 'bestellen', text: 'Das Abo konnte gerade nicht geprüft werden.' },
]
const { wurzel, schritte } = await bestellSeite(await bestellDaten({ offen }))
const punkte = fx.alle(wurzel, '.tm-bestellen-offen li')
assert.deepEqual(punkte.map((li) => fx.finde(li, 'span')?.textContent), offen.map((p) => p.text))
const knoepfe = fx.alle(wurzel, '[data-aktion="zum-schritt"]')
assert.deepEqual(knoepfe.map((k) => k.dataset.schritt), ['branche', 'website'], 'bestellen ist diese Seite')
assert.ok(knoepfe.every((k) => fx.text(k) === 'Zum Schritt'))
await fx.klick(knoepfe[1])
assert.deepEqual(schritte, ['website'])
await alleAnhaken(fx, wurzel)
assert.equal(bestellKnopf(fx, wurzel).disabled, true, 'mit offenen Punkten bleibt der Knopf gesperrt, auch mit allen Haken')
assert.match(fx.text(fx.finde(wurzel, '[data-hinweis="bestellen"]')), /offenen Punkte/)
}))
// ------------------------------------------------------- Knopf: gesperrt oder frei
test('renderBestellen: Knopf gesperrt, bis alle Haken gesetzt sind; ein abgenommener Haken sperrt ihn wieder', mitDom(undefined, async (fx) => {
const { wurzel } = await bestellSeite(await bestellDaten())
const knopf = bestellKnopf(fx, wurzel)
const felder = hakenFelder(fx, wurzel)
assert.ok(felder.length >= 2)
assert.equal(knopf.disabled, true, 'am Anfang gesperrt')
for (const haken of felder.slice(0, -1)) {
await fx.klick(haken)
assert.equal(knopf.disabled, true, 'solange ein Haken fehlt')
}
await fx.klick(felder.at(-1))
assert.equal(knopf.disabled, false, 'alle Haken ⇒ frei')
await fx.klick(felder[0])
assert.equal(knopf.disabled, true, 'Haken weg ⇒ wieder gesperrt')
await fx.klick(felder[0])
assert.equal(knopf.disabled, false)
assert.equal(fx.fetchLog.length, 0, 'bis hierher keine Anfrage')
}))
test('renderBestellen: in der Kundenansicht bleibt der Knopf gesperrt, auch mit allen Haken, mit Hinweis und ohne Anfrage', mitDom(undefined, async (fx) => {
const { wurzel } = await bestellSeite(await bestellDaten({ kundenansicht: true }))
await alleAnhaken(fx, wurzel)
const knopf = bestellKnopf(fx, wurzel)
assert.equal(knopf.disabled, true)
assert.equal(fx.text(fx.finde(wurzel, '[data-hinweis="bestellen"]')), 'In der Kundenansicht kann nicht bestellt werden.')
await fx.klick(knopf)
assert.equal(fx.fetchLog.length, 0)
}))
// ------------------------------------------------------- Bestellen: Klick, Fehler, 409
test('Bestellen: der Klick sperrt den Knopf, sendet bestellKoerper, navigiert nur auf eine https-Adresse; ein zweiter Klick sendet nichts', mitDom({ antworten: [{ body: { url: KNOPF_URL } }] }, async (fx) => {
const daten = await bestellDaten()
const { wurzel } = await bestellSeite(daten)
await alleAnhaken(fx, wurzel)
const knopf = bestellKnopf(fx, wurzel)
const erster = fx.klick(knopf)
assert.equal(knopf.disabled, true, 'sofort gesperrt, noch vor der Antwort')
const zweiter = fx.klick(knopf)
await Promise.all([erster, zweiter])
assert.equal(fx.fetchLog.length, 1, 'genau eine Anfrage')
assert.deepEqual(fx.fetchLog[0], {
method: 'POST',
url: '/api/funktionen/termine/checkout',
body: { projectId: 'p1', zustimmung: { version: daten.bestellung.consent.version, haken: registerHaken() } },
})
assert.equal(fx.window.location.href, KNOPF_URL)
assert.equal(knopf.disabled, true, 'die Seite wird verlassen, der Knopf bleibt gesperrt')
}))
test('Bestellen: eine Adresse, die nicht https ist, wird nie angesteuert; Meldung am Knopf, der Knopf ist wieder frei', mitDom(undefined, async (fx) => {
for (const url of ['http://checkout.stripe.test/x', 'javascript:alert(1)', '//evil.example/x', '/relativ', '', undefined, 42]) {
fx.antworten.push({ body: { url } })
const { wurzel } = await bestellSeite(await bestellDaten())
await alleAnhaken(fx, wurzel)
const knopf = bestellKnopf(fx, wurzel)
await fx.klick(knopf)
assert.equal(fx.window.location.href, 'http://localhost/', `keine Navigation für ${JSON.stringify(url)}`)
assert.match(meldungVon(fx, wurzel), /Zahlungsseite/)
assert.equal(knopf.disabled, false, 'wieder frei')
}
}))
test('Bestellen: ein Fehler (500, kein Netz) steht als Meldung am Knopf, Haken bleiben, der Knopf ist wieder frei', mitDom({
antworten: [{ status: 500, body: { error: 'Die Bestellung konnte gerade nicht gestartet werden. Bitte versuch es später noch einmal oder schreib uns im Chat.' } }, () => { throw new TypeError('Failed to fetch') }, { status: 400, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }],
}, async (fx) => {
const { wurzel } = await bestellSeite(await bestellDaten())
await alleAnhaken(fx, wurzel)
const knopf = bestellKnopf(fx, wurzel)
await fx.klick(knopf)
assert.equal(meldungVon(fx, wurzel), 'Die Bestellung konnte gerade nicht gestartet werden. Bitte versuch es später noch einmal oder schreib uns im Chat.')
assert.equal(knopf.disabled, false)
assert.ok(hakenFelder(fx, wurzel).every((haken) => haken.checked), 'die Haken bleiben')
await fx.klick(knopf)
assert.equal(meldungVon(fx, wurzel), 'Keine Verbindung. Bitte versuch es gleich noch einmal.')
await fx.klick(knopf)
assert.equal(meldungVon(fx, wurzel), 'Bitte stimme der Erklärung zu, um zu bestellen.')
assert.equal(knopf.disabled, false)
assert.equal(fx.fetchLog.length, 3, 'kein Nachladen bei Fehlern außer 409')
assert.equal(fx.window.location.href, 'http://localhost/')
}))
test('Bestellen 409 bestellung_offen: lädt die Daten neu, setzt die Haken zurück und zeigt die Punkte aus body.offen', mitDom(undefined, async (fx) => {
const daten = await bestellDaten()
const neu = await bestellDaten({ offen: [] })
const offen = [{ schritt: 'website', text: 'Bitte trage die Adresse deines Impressums ein.' }, { schritt: 'zeiten', text: 'Bitte wähle das Bundesland deines Betriebs.' }]
fx.antworten.push(
{ status: 409, body: { error: 'Die Bestellung ist noch nicht vollständig. Bitte erledige zuerst die offenen Punkte.', code: 'bestellung_offen', offen } },
{ body: neu },
)
const { wurzel, schritte } = await bestellSeite(daten)
await alleAnhaken(fx, wurzel)
await fx.klick(bestellKnopf(fx, wurzel))
assert.deepEqual(fx.fetchLog.map((a) => [a.method, a.url]), [['POST', '/api/funktionen/termine/checkout'], ['GET', '/api/termine/einstellungen?projectId=p1']])
assert.deepEqual(fx.alle(wurzel, '.tm-bestellen-offen li').map((li) => fx.finde(li, 'span').textContent), offen.map((p) => p.text))
assert.ok(hakenFelder(fx, wurzel).every((haken) => !haken.checked), 'Haken zurückgesetzt')
assert.equal(bestellKnopf(fx, wurzel).disabled, true)
assert.equal(meldungVon(fx, wurzel), 'Die Bestellung ist noch nicht vollständig. Bitte erledige zuerst die offenen Punkte.')
await fx.klick(fx.alle(wurzel, '[data-aktion="zum-schritt"]')[1])
assert.deepEqual(schritte, ['zeiten'], 'die neue Seite führt zu den Schritten')
}))
test('Bestellen 409 fassung_veraltet: zeigt die neue Fassung aus body.consent, Haken zurück, der nächste Körper trägt die neue Version', mitDom(undefined, async (fx) => {
const daten = await bestellDaten()
const neueFassung = {
version: '2026-11-01',
knopf: 'Neuer Knopf · 149 €',
items: [{ id: 'unterlagen', text: 'Neuer Wortlaut der Unterlagen.', haken: true }, { id: 'hinweis', text: 'Ein neuer Hinweis.', haken: false }, { id: 'beginn', text: 'Neuer Wortlaut des Beginns.', haken: true }],
}
fx.antworten.push(
{ status: 409, body: { error: 'Die Erklärungen wurden inzwischen geändert. Bitte lies sie noch einmal und stimme neu zu.', code: 'fassung_veraltet', consent: neueFassung } },
{ body: await bestellDaten() },
{ body: { url: KNOPF_URL } },
)
const { wurzel } = await bestellSeite(daten)
await alleAnhaken(fx, wurzel)
await fx.klick(bestellKnopf(fx, wurzel))
assert.equal(meldungVon(fx, wurzel), 'Die Erklärungen wurden inzwischen geändert. Bitte lies sie noch einmal und stimme neu zu.')
assert.equal(fx.text(bestellKnopf(fx, wurzel)), 'Neuer Knopf · 149 €')
assert.deepEqual(hakenFelder(fx, wurzel).map((haken) => [haken.dataset.haken, fx.text(haken.closest('label')), haken.checked]), [
['unterlagen', 'Neuer Wortlaut der Unterlagen.', false], ['beginn', 'Neuer Wortlaut des Beginns.', false],
])
assert.ok(fx.text(wurzel).includes('Ein neuer Hinweis.'))
assert.equal(bestellKnopf(fx, wurzel).disabled, true)
await alleAnhaken(fx, wurzel)
await fx.klick(bestellKnopf(fx, wurzel))
assert.deepEqual(fx.fetchLog.at(-1).body, { projectId: 'p1', zustimmung: { version: '2026-11-01', haken: ['unterlagen', 'beginn'] } })
assert.equal(fx.window.location.href, KNOPF_URL)
}))
test('Bestellen 409 mit Abo-Code: der Text aus der Antwort steht am Knopf, Daten neu geladen, Haken zurück', mitDom(undefined, async (fx) => {
for (const [code, text] of [
['kein_abo', 'Für den Terminplaner brauchst du ein laufendes Wartungs-Abo.'],
['gekuendigt', 'Dein Wartungs-Abo ist gekündigt. Für den Terminplaner brauchst du ein laufendes Abo.'],
['nicht_aktiv', 'Dein Wartungs-Abo ist gerade nicht aktiv (zum Beispiel wegen einer offenen Zahlung). Sobald es wieder aktiv ist, kannst du den Terminplaner bestellen.'],
]) {
fx.fetchLog.length = 0
fx.antworten.push({ status: 409, body: { error: text, code, grund: code } }, { body: await bestellDaten({ abo: { ok: false, grund: code }, offen: [{ schritt: 'bestellen', text }] }) })
const { wurzel } = await bestellSeite(await bestellDaten())
await alleAnhaken(fx, wurzel)
await fx.klick(bestellKnopf(fx, wurzel))
assert.equal(meldungVon(fx, wurzel), text, code)
assert.equal(fx.fetchLog.length, 2, 'Daten neu geladen')
assert.ok(hakenFelder(fx, wurzel).every((haken) => !haken.checked))
assert.equal(bestellKnopf(fx, wurzel).disabled, true)
}
}))
test('Bestellen 409: schlägt das Neuladen fehl, bleibt die Seite stehen und die Meldung steht am Knopf', mitDom(undefined, async (fx) => {
fx.antworten.push({ status: 409, body: { error: 'Dein Wartungs-Abo ist gekündigt.', code: 'gekuendigt' } }, { status: 500, body: { error: 'Server kaputt.' } })
const { wurzel } = await bestellSeite(await bestellDaten())
await alleAnhaken(fx, wurzel)
const knopf = bestellKnopf(fx, wurzel)
await fx.klick(knopf)
assert.equal(bestellKnopf(fx, wurzel), knopf, 'dieselbe Seite')
assert.equal(meldungVon(fx, wurzel), 'Dein Wartungs-Abo ist gekündigt.')
assert.equal(knopf.disabled, false)
}))
test('Bestellen 409: ist die Bestellung inzwischen aufgegeben (keine bestellung mehr in den Daten), steht nur noch die Meldung mit Hinweis zum Neuladen', mitDom(undefined, async (fx) => {
fx.antworten.push({ status: 409, body: { error: 'Der Terminplaner ist für dieses Projekt schon bestellt.' } }, { body: await bestellDaten({ state: 'ordered', bestellung: null, offen: null }) })
const { wurzel } = await bestellSeite(await bestellDaten())
await alleAnhaken(fx, wurzel)
await fx.klick(bestellKnopf(fx, wurzel))
assert.equal(fx.finde(wurzel, '[data-aktion="bestellen"]'), null, 'die Bestellseite ist weg')
assert.match(fx.text(wurzel), /Der Terminplaner ist für dieses Projekt schon bestellt\./)
assert.match(fx.text(wurzel), /Lade die Seite neu/)
}))
test('Bestellen 409: eine inzwischen verlassene Ansicht (aktuell() falsch) wird nicht neu gezeichnet', mitDom(undefined, async (fx) => {
fx.antworten.push({ status: 409, body: { error: 'Nicht vollständig.', code: 'bestellung_offen', offen: [{ schritt: 'website', text: 'x' }] } }, { body: await bestellDaten() })
const daten = await bestellDaten()
const wurzel = h('div')
document.body.append(wurzel)
let aktuell = true
const ctx = huelle.baueCtx({ daten, nurLesen: false }, { projekt: { projectId: 'p1' }, aktuell: () => aktuell })
bestellen.renderBestellen(wurzel, { ctx, daten, zumSchritt() {} })
await alleAnhaken(fx, wurzel)
const knopf = bestellKnopf(fx, wurzel)
const klick = fx.klick(knopf)
aktuell = false
await klick
assert.equal(bestellKnopf(fx, wurzel), knopf, 'die alte Seite bleibt unberührt')
}))
// ------------------------------------------------------- statusKarte
const statusProjekt = (aenderung = {}) => ({
projectId: 'p1',
projectName: 'Salon Muster',
features: { termine: { state: 'ordered', orderedAt: '2026-10-05T10:00:00.000Z', ticketWoid: null, invoiceUrl: null, ...aenderung } },
})
const EINRICHTUNG = 'meist innerhalb von 1–2 Wochen'
function karte(aenderung = {}, optionen = {}, rueckruf = {}) {
const projekt = statusProjekt(aenderung)
const wurzel = h('div')
document.body.append(wurzel)
wurzel.append(bestellen.statusKarte({ projekt, bestellung: projekt.features.termine, einrichtung: EINRICHTUNG, danke: false, kundenansicht: false, ...optionen }, rueckruf))
return wurzel
}
test('statusKarte ordered: Pille „Zahlung offen“, Bestelldatum, SEPA-Hinweis, „Zahlung fortsetzen“ und Chat-Knopf', mitDom(undefined, (fx) => {
const wurzel = karte()
const el = fx.finde(wurzel, '.tm-statuskarte')
assert.ok(el)
assert.equal(fx.text(fx.finde(el, '.tm-pille')), 'Zahlung offen')
assert.match(fx.text(el), /Bestellt am\s*05\.10\.2026/)
assert.match(fx.text(el), /SEPA[^.]*einige Tage/)
assert.equal(fx.text(fx.finde(el, '[data-aktion="fortsetzen"]')), 'Zahlung fortsetzen')
assert.equal(fx.text(fx.finde(el, '[data-view="chat"]')).includes('Chat'), true)
assert.doesNotMatch(fx.text(el), /Ticketnummer|In Einrichtung|Danke!/)
assert.equal(fx.alle(el, 'a').length, 0, 'in ordered gibt es keine Rechnung')
}))
test('statusKarte ordered: „Zahlung fortsetzen“ sendet fortsetzenKoerper und navigiert zur offenen Sitzung', mitDom({ antworten: [{ body: { url: KNOPF_URL } }] }, async (fx) => {
const wurzel = karte()
const knopf = fx.finde(wurzel, '[data-aktion="fortsetzen"]')
const klick = fx.klick(knopf)
assert.equal(knopf.disabled, true, 'ein Klick sperrt ihn')
await klick
assert.deepEqual(fx.fetchLog, [{ method: 'POST', url: '/api/funktionen/termine/checkout', body: { projectId: 'p1', fortsetzen: true } }])
assert.equal(fx.window.location.href, KNOPF_URL)
}))
test('statusKarte ordered: 409 zustimmung_fehlt ⇒ weiter() lädt neu und zeigt das Bestellformular, keine Navigation', mitDom({
antworten: [{ status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }],
}, async (fx) => {
let weiter = 0
const wurzel = karte({}, {}, { weiter: async () => { weiter += 1 } })
await fx.klick(fx.finde(wurzel, '[data-aktion="fortsetzen"]'))
assert.equal(weiter, 1)
assert.equal(fx.window.location.href, 'http://localhost/')
}))
test('statusKarte ordered: ohne weiter() oder bei anderem Fehler steht die Meldung am Knopf, der Knopf ist wieder frei', mitDom({
antworten: [{ status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }, { status: 409, body: { error: 'Der Terminplaner ist für dieses Projekt schon bestellt.' } }, { body: { url: 'http://unsicher.example/x' } }],
}, async (fx) => {
let weiter = 0
const ohne = karte()
const knopf = fx.finde(ohne, '[data-aktion="fortsetzen"]')
await fx.klick(knopf)
assert.equal(fx.text(fx.finde(ohne, '[data-meldung="fortsetzen"]')), 'Bitte stimme der Erklärung zu, um zu bestellen.')
assert.equal(knopf.disabled, false)
const mit = karte({}, {}, { weiter: () => { weiter += 1 } })
await fx.klick(fx.finde(mit, '[data-aktion="fortsetzen"]'))
assert.equal(weiter, 0, 'weiter() nur bei zustimmung_fehlt')
assert.equal(fx.text(fx.finde(mit, '[data-meldung="fortsetzen"]')), 'Der Terminplaner ist für dieses Projekt schon bestellt.')
const unsicher = karte()
await fx.klick(fx.finde(unsicher, '[data-aktion="fortsetzen"]'))
assert.equal(fx.window.location.href, 'http://localhost/', 'keine http-Navigation')
assert.match(fx.text(fx.finde(unsicher, '[data-meldung="fortsetzen"]')), /Zahlungsseite/)
}))
test('statusKarte ordered: in der Kundenansicht ist „Zahlung fortsetzen“ gesperrt, mit Hinweis und ohne Anfrage', mitDom(undefined, async (fx) => {
const wurzel = karte({}, { kundenansicht: true })
const knopf = fx.finde(wurzel, '[data-aktion="fortsetzen"]')
assert.equal(knopf.disabled, true)
assert.match(fx.text(wurzel), /In der Kundenansicht kann nicht bestellt werden\./)
await fx.klick(knopf)
assert.equal(fx.fetchLog.length, 0)
}))
test('statusKarte setup: „In Einrichtung“, Ticketnummer, Bestelldatum, Rechnungslink, Zeiteinschätzung, gesperrte Einstellungen, kein „Zahlung fortsetzen“', mitDom(undefined, (fx) => {
const wurzel = karte({ state: 'setup', ticketWoid: 100342, invoiceUrl: 'https://invoice.stripe.test/i/in_1' })
const el = fx.finde(wurzel, '.tm-statuskarte')
assert.equal(fx.text(fx.finde(el, '.tm-pille')), 'In Einrichtung')
assert.match(fx.text(el), /Ticketnummer\s*#100342/)
assert.match(fx.text(el), /Bestellt am\s*05\.10\.2026/)
assert.match(fx.text(el), new RegExp(`Zeiteinschätzung\\s*${EINRICHTUNG}`))
assert.ok(fx.text(el).includes('Deine Einstellungen sind jetzt gesperrt, Änderungen laufen über den Chat.'))
const rechnung = fx.alle(el, 'a')
assert.deepEqual(rechnung.map((a) => [fx.text(a), a.getAttribute('href'), a.getAttribute('rel'), a.getAttribute('target')]), [['Rechnung ansehen', 'https://invoice.stripe.test/i/in_1', 'noopener noreferrer', '_blank']])
assert.equal(fx.finde(el, '[data-aktion="fortsetzen"]'), null)
assert.ok(fx.finde(el, '[data-view="chat"]'))
assert.doesNotMatch(fx.text(el), /SEPA|Zahlung offen/)
}))
test('statusKarte setup: ohne Ticket „Wir legen dein Ticket gerade an.“, ohne sichere Rechnungsadresse kein Link', mitDom(undefined, (fx) => {
const ohneTicket = fx.text(karte({ state: 'setup', ticketWoid: null }))
assert.ok(ohneTicket.includes('Wir legen dein Ticket gerade an.'))
assert.doesNotMatch(ohneTicket, /#/)
for (const invoiceUrl of ['javascript:alert(1)', 'http://invoice.stripe.test/x', '//evil.example/i', null, '']) {
assert.equal(fx.alle(karte({ state: 'setup', invoiceUrl }), 'a').length, 0, `kein Link für ${JSON.stringify(invoiceUrl)}`)
}
}))
test('statusKarte: „Danke!“ nach der Rückkehr von Stripe, in ordered und setup, sonst nicht', mitDom(undefined, (fx) => {
const DANKE = 'Danke! Sobald die Zahlung da ist, legen wir los.'
for (const state of ['ordered', 'setup']) {
const mit = karte({ state }, { danke: true })
assert.equal(fx.text(fx.finde(mit, '[data-danke]')), DANKE, state)
assert.equal(fx.finde(mit, '[data-danke]').getAttribute('role'), 'status')
assert.ok(!fx.text(karte({ state }, { danke: false })).includes('Danke!'))
}
}))
test('statusKarte: Texte vom Server (Ticketnummer, Datum, Einrichtung) nur als Text', mitDom(undefined, (fx) => {
const boese = '<img src=x onerror=alert(1)>'
const projekt = statusProjekt({ state: 'setup', ticketWoid: boese, orderedAt: boese })
const wurzel = h('div')
document.body.append(wurzel)
wurzel.append(bestellen.statusKarte({ projekt, bestellung: projekt.features.termine, einrichtung: boese, danke: false, kundenansicht: false }, {}))
assert.equal(fx.alle(wurzel, 'img').length, 0)
assert.ok(fx.text(wurzel).includes(boese))
}))