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:
1 parent
747be432c5
commit
20583424a3
4 files changed
+1051
-2
No files matched your search
@@ -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))
|
||||
}))
|
||||
Reference in new issue
Block a user