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