Files
Webklar-Kundenbereich/test/termineEinbauanleitung.test.js
T

1531 lines
89 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { config } from '../server/config.js'
import { VORGABE_FARBE, neuerFeedToken } from '../server/services/termine/einstellungen.js'
import { ausVorlage } from '../server/services/termine/vorlagen.js'
import { RECHTSTEXTE_ENTWURF, datenschutzAbsatz } from '../server/services/termine/rechtstexte.js'
import {
ENTWURF_BANNER, cssVorschlag, driftWarnung, einbauanleitung, einbauSnippet, esc, gesundheitsMeldungen, kontrast, kuerzeListe, luminanz, textFarbeAuf,
ticketStand, wochenplanText,
} from '../server/services/termine/einbauanleitung.js'
import { TICKET_DETAILS_MAX, anleitungsLink } from '../server/services/featureRegistry.js'
// Einbau-Snippet und Farbvorschlag des Terminplaners (TP2 Task D2). Reine Funktionen ohne Appwrite und ohne config.
// Das Snippet war bis D2 in server/routes/termine.js; die Aufrufstelle und ihre Antwort-Tests stehen in termineRoutes.test.js.
// Der Generator einbauanleitung() (Task D3a, ganz unten) liest config nur beim Aufruf (Portal-Adresse), sonst ist auch er rein.
const SITE_KEY = 'abcdefghijklmnop'
const API = 'https://project.webklar.com'
/** Das Snippet aus Abschnitt 1 von docs/funktionen/termine-einbau.md: SITEKEY ersetzt, der Erklaer-Kommentar am Zeilenende entfernt. */
function snippetAusDoku() {
const doku = readFileSync(new URL('../docs/funktionen/termine-einbau.md', import.meta.url), 'utf8')
const abschnitt = doku.split('## 1. Das Snippet')[1]
assert.ok(abschnitt, 'Abschnitt 1 fehlt in termine-einbau.md')
const block = /```html\n([\s\S]*?)\n```/.exec(abschnitt)
assert.ok(block, 'Code-Block in Abschnitt 1 fehlt')
return block[1]
.replace('SITEKEY', SITE_KEY)
.split('\n')
.map((zeile) => zeile.replace(/\s*<!--.*-->\s*$/, ''))
.join('\n')
}
const basis = { siteKey: SITE_KEY, apiBase: API, telefon: '06301 1234' }
// ---------------------------------------------------------------- esc
test('esc maskiert & < > " und macht aus allem anderen Text', () => {
assert.equal(esc('a & b < c > d "e"'), 'a &amp; b &lt; c &gt; d &quot;e&quot;')
assert.equal(esc(42), '42')
assert.equal(esc(undefined), '')
assert.equal(esc(null), '')
})
// ---------------------------------------------------------------- Snippet
test('Snippet inline ist zeichengleich zu Abschnitt 1 von termine-einbau.md', () => {
assert.equal(einbauSnippet(basis), snippetAusDoku())
assert.equal(einbauSnippet({ ...basis, modus: 'inline' }), snippetAusDoku())
})
test('Snippet: Telefon wird maskiert, ohne Telefon steht der allgemeine Ausweichtext', () => {
const mit = einbauSnippet({ ...basis, telefon: '06301 <12>' })
assert.ok(mit.includes('<p>Termine telefonisch unter 06301 &lt;12&gt;.</p>'), mit)
assert.ok(!mit.includes('<12>'))
for (const telefon of [undefined, null, '']) {
assert.ok(einbauSnippet({ siteKey: SITE_KEY, apiBase: API, telefon }).includes('<p>Termine telefonisch vereinbaren.</p>'), String(telefon))
}
})
test('Snippet: siteKey und Adresse sind maskiert (kein Ausbrechen aus dem Attribut)', () => {
const s = einbauSnippet({ siteKey: 'a"><script>x', apiBase: 'https://x.de/"&', telefon: undefined })
assert.ok(s.includes('data-webklar-termine="a&quot;&gt;&lt;script&gt;x"'), s)
assert.ok(s.includes('data-api="https://x.de/&quot;&amp;"'), s)
assert.ok(s.includes('<script src="https://x.de/&quot;&amp;/embed/termine.js" defer></script>'), s)
assert.equal((s.match(/<script/g) || []).length, 1, 'nur das eigene Skript-Element')
})
test('Snippet im Modus knopf: data-knopf mit maskiertem Text', () => {
const s = einbauSnippet({ ...basis, modus: 'knopf', knopfText: 'Jetzt "buchen" & mehr' })
assert.ok(s.includes('data-modus="knopf" data-knopf="Jetzt &quot;buchen&quot; &amp; mehr">'), s)
assert.ok(s.startsWith(`<div data-webklar-termine="${SITE_KEY}" data-api="${API}" data-modus="knopf"`), s)
assert.ok(s.includes('<p>Termine telefonisch unter 06301 1234.</p>'), 'Ausweichtext bleibt')
assert.ok(s.endsWith(`<script src="${API}/embed/termine.js" defer></script>`))
})
test('Snippet im Modus knopf ohne Text: kein data-knopf (das Widget nimmt "Termin buchen")', () => {
for (const knopfText of [undefined, null, '', ' ', '\n\t']) {
const s = einbauSnippet({ ...basis, modus: 'knopf', knopfText })
assert.ok(s.includes('data-modus="knopf">'), JSON.stringify(knopfText))
assert.ok(!s.includes('data-knopf'), JSON.stringify(knopfText))
}
})
test('Snippet: der Knopf-Text ist hoechstens 40 Zeichen lang, Zeilenumbrueche werden zu Leerzeichen', () => {
const lang = `${'a'.repeat(30)}\n${'b'.repeat(29)}`
assert.equal(lang.length, 60)
const s = einbauSnippet({ ...basis, modus: 'knopf', knopfText: lang })
const wert = /data-knopf="([^"]*)"/.exec(s)[1]
assert.equal(wert.length, 40)
assert.equal(wert, `${'a'.repeat(30)} ${'b'.repeat(9)}`)
assert.ok(!/[\r\n]/.test(wert))
for (const umbruch of ['\r\n', '\r', '\u2028']) {
const t = einbauSnippet({ ...basis, modus: 'knopf', knopfText: `Jetzt${umbruch}buchen` })
assert.ok(t.includes('data-knopf="Jetzt'), t)
assert.ok(!/data-knopf="[^"]*[\r\n\u2028]/.test(t), JSON.stringify(umbruch))
}
assert.equal(/data-knopf="([^"]*)"/.exec(einbauSnippet({ ...basis, modus: 'knopf', knopfText: 'Jetzt\nbuchen' }))[1], 'Jetzt buchen')
// gekuerzt wird der Rohtext, nicht der maskierte: 40 Anfuehrungszeichen sind 40 Zeichen, nicht 240
const ang = einbauSnippet({ ...basis, modus: 'knopf', knopfText: '"'.repeat(60) })
assert.equal(/data-knopf="([^"]*)"/.exec(ang)[1], '&quot;'.repeat(40))
})
test('Snippet: der Text kuerzt nie mitten in einem Zeichen aus zwei Code-Einheiten', () => {
const s = einbauSnippet({ ...basis, modus: 'knopf', knopfText: `${'a'.repeat(39)}😀😀` })
const wert = /data-knopf="([^"]*)"/.exec(s)[1]
assert.equal(wert, `${'a'.repeat(39)}😀`)
})
test('Snippet: ein Knopf-Text im Modus inline und ein unbekannter Modus ergeben das Inline-Snippet', () => {
assert.equal(einbauSnippet({ ...basis, modus: 'inline', knopfText: 'Buchen' }), snippetAusDoku())
for (const modus of ['dialog', 'KNOPF', '', null, 42, {}]) {
assert.equal(einbauSnippet({ ...basis, modus, knopfText: 'Buchen' }), snippetAusDoku(), JSON.stringify(modus))
}
})
// ---------------------------------------------------------------- Farben
test('luminanz und kontrast: Eckwerte', () => {
assert.equal(luminanz('#ffffff'), 1)
assert.equal(luminanz('#000000'), 0)
assert.equal(kontrast('#000000', '#ffffff'), 21)
assert.equal(kontrast('#ffffff', '#000000'), 21, 'Reihenfolge egal')
assert.equal(kontrast('#7c3aed', '#7c3aed'), 1)
})
test('textFarbeAuf: Weiss auf dunklen, dunkles Grau auf hellen, Schwarz nur wo beides unter 4,5 : 1 bleibt', () => {
assert.equal(textFarbeAuf('#0f766e'), '#ffffff')
assert.equal(textFarbeAuf('#f5c542'), '#111827')
assert.equal(textFarbeAuf('#000000'), '#ffffff')
assert.equal(textFarbeAuf('#ffffff'), '#111827')
assert.equal(textFarbeAuf('#9d4b6f'), '#ffffff')
// mittlere Toene, bei denen weder Weiss noch das dunkle Grau 4,5 : 1 erreicht
assert.equal(textFarbeAuf('#808080'), '#000000')
assert.ok(kontrast('#808080', '#ffffff') < 4.5 && kontrast('#808080', '#111827') < 4.5)
assert.equal(textFarbeAuf('#6b7280'), '#ffffff')
})
/** Alle 4096 Farben mit Kanalwerten in 17er-Schritten (0, 17, ... 255). */
function alleFarben() {
const stufen = Array.from({ length: 16 }, (_, i) => i * 17)
const hex = (c) => c.toString(16).padStart(2, '0')
const liste = []
for (const r of stufen) for (const g of stufen) for (const b of stufen) liste.push(`#${hex(r)}${hex(g)}${hex(b)}`)
return liste
}
test('textFarbeAuf: jede der 4096 Farben erreicht mit ihrer Schrift mindestens 4,5 : 1', () => {
const farben = alleFarben()
assert.equal(farben.length, 4096)
for (const farbe of farben) {
const text = textFarbeAuf(farbe)
assert.ok(['#ffffff', '#111827', '#000000'].includes(text), `${farbe}: ${text}`)
assert.ok(kontrast(farbe, text) >= 4.5, `${farbe}: ${text} nur ${kontrast(farbe, text).toFixed(2)} : 1`)
}
})
// ---------------------------------------------------------------- CSS-Vorschlag
test('cssVorschlag: Kommentar, Selektor und beide Variablen', () => {
const v = cssVorschlag('#9d4b6f')
assert.ok(v.css.includes('--wkt-akzent:#9d4b6f;'), v.css)
assert.ok(v.css.includes('--wkt-akzent-text:#ffffff;'), v.css)
assert.ok(v.css.includes('[data-webklar-termine]{'), v.css)
assert.match(v.css, /^\/\*[^*]*\*\/\n/, 'beginnt mit einem Kommentar')
assert.equal(v.akzent, '#9d4b6f')
assert.equal(v.akzentText, '#ffffff')
assert.equal(v.kontrastText, kontrast('#9d4b6f', '#ffffff'))
assert.equal(v.kontrastSeite, kontrast('#9d4b6f', '#ffffff'))
assert.deepEqual(v.hinweise, [], 'ein dunkler Akzent braucht keine Warnung')
// heller Akzent: dunkle Schrift
const hell = cssVorschlag('#f5c542')
assert.equal(hell.akzentText, '#111827')
assert.ok(hell.css.includes('--wkt-akzent-text:#111827;'))
assert.equal(hell.kontrastText, kontrast('#f5c542', '#111827'))
assert.ok(hell.kontrastText >= 4.5)
})
test('cssVorschlag: nur gueltige #rrggbb, sonst die Vorgabefarbe und der Rohtext steht nirgends', () => {
assert.equal(VORGABE_FARBE, '#7c3aed')
for (const roh of ['red', '#fff', undefined, null, 42, '#9d4b6f;}</style>', '#12345g', ' #9d4b6f', '#9d4b6f\n']) {
const v = cssVorschlag(roh)
assert.equal(v.akzent, '#7c3aed', String(roh))
assert.ok(v.css.includes('--wkt-akzent:#7c3aed;'), String(roh))
assert.ok(v.css.includes(`--wkt-akzent-text:${textFarbeAuf('#7c3aed')};`), String(roh))
const ausgabe = JSON.stringify(v)
for (const stueck of ['red', '</style>', '12345g', '#9d4b6f', '#fff;']) {
if (typeof roh === 'string' && roh.includes(stueck)) assert.ok(!ausgabe.includes(stueck), `${JSON.stringify(roh)}: ${stueck} steht in der Ausgabe`)
}
assert.ok(!/[<>]/.test(v.css), 'kein HTML im CSS')
}
// Grossbuchstaben sind gueltig und werden klein geschrieben
assert.equal(cssVorschlag('#9D4B6F').akzent, '#9d4b6f')
})
test('cssVorschlag: der Hinweis nennt den Kontrast gegen Weiss, wenn der Akzent unter 3 : 1 liegt', () => {
const v = cssVorschlag('#f5c542')
assert.ok(v.kontrastSeite < 3, String(v.kontrastSeite))
assert.equal(v.hinweise.length, 1)
assert.match(v.hinweise[0], /Weiß/)
assert.match(v.hinweise[0], /1,6 : 1/, 'Kontrast mit deutschem Komma')
assert.match(v.hinweise[0], /3 : 1/)
assert.match(v.hinweise[0], /Rahmen/)
// knapp ueber der Grenze: keine Warnung
const gut = cssVorschlag('#0f766e')
assert.ok(gut.kontrastSeite >= 3)
assert.deepEqual(gut.hinweise, [])
// Grenzfall: genau an 3 : 1 warnt nicht, knapp darunter schon
for (const farbe of alleFarben()) {
const k = kontrast(farbe, '#ffffff')
assert.equal(cssVorschlag(farbe).hinweise.length, k < 3 ? 1 : 0, `${farbe} (${k.toFixed(3)} : 1)`)
}
})
// ================================================================ Generator einbauanleitung() 1 (Task D3a)
// Modell, Leistungen, Ressourcen, Regeln, Gastfelder, Benachrichtigungen. Rein: kein I/O, nur ausdruecklich benannte Felder.
const NOW = new Date('2026-10-05T10:00:00Z') // Montag, 12:00 Berliner Zeit
const SPAETER = new Date('2026-10-06T10:00:00Z') // einen Tag spaeter: nur die "Erzeugt am"-Zeile aendert sich
const GEHEIM = 'XYZ-GEHEIM'
// Endregel (Leistungsbeschreibung Nr. 4 Abs. 9): wer den Datenschutz-Absatz einfügt; Checkliste und Schritt 4 der Kurzfassung nennen sie
const DATENSCHUTZ_ENDREGEL =
'liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügen wir den Absatz beim Einbau ein, das gehört zur Einrichtung und wird nicht auf das Stundenkontingent angerechnet; '
+ 'sonst den Absatz als Textbaustein per Chat an den Kunden senden, der ihn selbst einfügt'
const BETRIEB = {
name: 'Salon Muster',
email: 'inhaber-intern@salon-muster.example',
telefon: '0631 123456',
adresse: 'Hauptstraße 1, 67655 Kaiserslautern',
kleinunternehmer: false,
impressumUrl: 'https://salon-muster.example/impressum',
datenschutzUrl: 'https://salon-muster.example/datenschutz',
bundesland: 'RP',
}
/** Salon-Vorlage mit Farbe, zwei Personen und einem Platz, Wochenplan, Abwesenheiten, Sonderoeffnungen und einer Anfrage-Leistung. */
function fixtureEinstellung() {
const e = ausVorlage('salon', BETRIEB, NOW)
e.farbe = '#9d4b6f'
const [anna] = e.ressourcen
anna.name = 'Anna'
anna.plan = { 1: [['09:00', '12:30'], ['14:00', '18:00']], 3: [['09:00', '12:30']], 7: [['10:00', '12:00']] }
e.ressourcen.push(
{ id: 'r_ben00001', typ: 'person', name: 'Ben', aktiv: true, plan: { 2: [['08:00', '16:00']] }, feedToken: neuerFeedToken() },
{ id: 'r_stuhl001', typ: 'platz', name: 'Stuhl 2', aktiv: false, plan: {}, feedToken: neuerFeedToken() },
)
e.leistungen[0].ressourcen = [anna.id, 'r_ben00001']
e.leistungen[1].aktiv = false // Damenschnitt
e.leistungen[2].buchungsart = 'anfrage'
e.leistungen[2].preisArt = 'keiner'
e.leistungen[2].preisCent = 0
e.abwesenheiten = [
{ id: 'a_alt00001', ressourceId: anna.id, von: '2026-09-28', bis: '2026-10-02', titel: 'Urlaub vorbei' },
{ id: 'a_jetzt001', ressourceId: 'r_ben00001', von: '2026-10-05', bis: '2026-10-05', titel: 'Fortbildung' },
{ id: 'a_laeuft01', ressourceId: anna.id, von: '2026-10-01', bis: '2026-10-07', titel: 'Kur' },
{ id: 'a_zukunft1', ressourceId: null, von: '2026-12-24', bis: '2026-12-31', titel: 'Betriebsferien' },
]
e.sonderoeffnungen = { '2026-10-03': [['09:00', '12:00']], '2026-12-24': [], '2026-11-07': [['10:00', '14:00']] }
e.gastFelder = {
telefon: 'pflicht',
nachricht: 'optional',
zusatz: [
{ key: 'haare', label: 'Haarlänge', typ: 'auswahl', optionen: ['kurz', 'mittel', 'lang'], pflicht: true },
{ key: 'wunsch', label: 'Wunschfrisur', typ: 'text', optionen: [], pflicht: false },
],
}
return e
}
const PROJEKT = { projectName: 'Salon Muster Website', subdomain: 'salon-muster', liveDomain: 'salon-muster.example', previewUrl: 'https://salon-muster.project.webklar.com' }
/** Standard-Aufruf des Generators; Abweichungen ueber `zusatz`. */
function erzeuge(zusatz = {}) {
return einbauanleitung({
feature: { $id: 'f1', customerId: 'c1', projectId: 'p1', siteKey: SITE_KEY, state: 'setup' },
project: PROJEKT,
einstellung: fixtureEinstellung(),
fragebogen: null,
version: 3,
updatedAt: '2026-10-03T08:15:00.000Z',
now: NOW,
kunde: { name: 'Salon Muster GbR' },
...zusatz,
})
}
const abschnitt = (ergebnis, id) => ergebnis.abschnitte.find((a) => a.id === id)
const zeilen = (text) => text.split('\n')
test('D3a: Ergebnisform, die ersten sechs Abschnitte in fester Reihenfolge, keiner davon kopierbar', () => {
const r = erzeuge({ entwurf: false })
assert.deepEqual(Object.keys(r).sort(), ['abschnitte', 'kurz', 'stand', 'text', 'warnungen'])
assert.deepEqual(r.abschnitte.slice(0, 6).map((a) => a.id), ['modell', 'leistungen', 'ressourcen', 'regeln', 'gastfelder', 'benachrichtigungen'])
for (const a of r.abschnitte) {
assert.deepEqual(Object.keys(a).sort(), ['id', 'kopierbar', 'text', 'titel'], a.id)
assert.ok(a.titel && typeof a.titel === 'string', a.id)
assert.ok(a.text && typeof a.text === 'string', a.id)
}
for (const a of r.abschnitte.slice(0, 6)) assert.equal(a.kopierbar, false, a.id)
assert.deepEqual(r.warnungen, [], 'ein vollstaendiger Betrieb ohne Entwurf-Kennzeichnung hat keine Warnungen')
assert.equal(typeof r.kurz, 'string', 'die Kurzfassung steht seit D4 im Ergebnis (Inhalt: Tests ab "D4")')
assert.ok(r.kurz.length > 0)
// der Volltext fuehrt jeden Abschnitt mit Ueberschrift, die Datenschutz-Fassung der anderen Anrede fehlt (D3b)
const ohneDu = r.abschnitte.filter((a) => a.id !== 'datenschutz_du')
assert.deepEqual(zeilen(r.text).filter((z) => z.startsWith('## ')), ohneDu.map((a) => `## ${a.titel}`))
for (const a of ohneDu) assert.ok(r.text.includes(a.text), a.id)
})
test('D3a: stand ist genau { version, updatedAt }, updatedAt ist ohne Angabe null', () => {
assert.deepEqual(erzeuge().stand, { version: 3, updatedAt: '2026-10-03T08:15:00.000Z' })
assert.deepEqual(erzeuge({ updatedAt: undefined }).stand, { version: 3, updatedAt: null })
assert.deepEqual(erzeuge({ version: 0, updatedAt: undefined }).stand, { version: 0, updatedAt: null })
})
test('D3a Modell: Betrieb, Kontakt, Adressen, Farbe, Anrede, Portal-Adresse, Erzeugt-am und Stand der Einstellungen', () => {
const modell = abschnitt(erzeuge(), 'modell').text
for (const erwartet of [
'Erzeugt am 05.10.2026',
'Stand der Einstellungen: Version 3, zuletzt geändert 03.10.2026',
'Kunde: Salon Muster GbR',
'Projekt: Salon Muster Website (salon-muster.example)',
'Betrieb: Salon Muster',
'Anschrift: Hauptstraße 1, 67655 Kaiserslautern',
'Telefon: 0631 123456',
'Impressum: https://salon-muster.example/impressum',
'Datenschutzerklärung: https://salon-muster.example/datenschutz',
'Farbe: #9d4b6f',
'Anrede der Gäste: Sie',
'Gäste: Privatpersonen (Verbraucher)',
`Portal-Adresse (data-api): ${config.publicBaseUrl}`,
'Umfang: 3 Personen und Plätze (2 aktiv), 4 Leistungen (3 aktiv)',
]) assert.ok(zeilen(modell).includes(erwartet), `${erwartet}\n---\n${modell}`)
})
test('D3a Modell: fehlende Angaben stehen als nicht angegeben, Du-Anrede, Projekt ohne Domain, Version 0, keine Kundenzeile', () => {
const e = fixtureEinstellung()
e.betrieb.telefon = ''
e.betrieb.adresse = ''
e.betrieb.impressumUrl = null
e.betrieb.datenschutzUrl = null
e.anrede = 'du'
e.recht = { gaesteSind: 'gemischt', freizeit: true }
const modell = abschnitt(erzeuge({ einstellung: e, project: { projectName: 'Nur Name' }, kunde: null, version: 0, updatedAt: null }), 'modell').text
const z = zeilen(modell)
for (const erwartet of [
'Betrieb: Salon Muster',
'Anschrift: nicht angegeben',
'Telefon: nicht angegeben',
'Impressum: nicht angegeben',
'Datenschutzerklärung: nicht angegeben',
'Anrede der Gäste: Du',
'Gäste: Privatpersonen und Unternehmen, Freizeitleistung',
'Projekt: Nur Name',
'Stand der Einstellungen: keine gespeicherte Version',
]) assert.ok(z.includes(erwartet), `${erwartet}\n---\n${modell}`)
assert.ok(!z.some((x) => x.startsWith('Kunde:')), 'ohne Kunde keine Kundenzeile')
})
test('D3a Modell: AGB- und Website-Adresse nur, wenn vorhanden; die Portal-Adresse wird beim Aufruf gelesen', () => {
const ohne = abschnitt(erzeuge(), 'modell').text
assert.ok(!/AGB|Website:/.test(ohne), ohne)
const e = fixtureEinstellung()
e.betrieb.agbUrl = 'https://salon-muster.example/agb'
e.betrieb.websiteUrl = 'https://salon-muster.example/'
const mit = zeilen(abschnitt(erzeuge({ einstellung: e }), 'modell').text)
assert.ok(mit.includes('AGB: https://salon-muster.example/agb'))
assert.ok(mit.includes('Website: https://salon-muster.example/'))
const vorher = config.publicBaseUrl
try {
config.publicBaseUrl = 'https://portal.test.example'
assert.ok(zeilen(abschnitt(erzeuge(), 'modell').text).includes('Portal-Adresse (data-api): https://portal.test.example'))
} finally {
config.publicBaseUrl = vorher
}
})
test('D3a Leistungen: Name, Dauer, Preis, Buchungsart, Personen; inaktive und Anfrage-Leistungen markiert', () => {
const text = abschnitt(erzeuge(), 'leistungen').text
const z = zeilen(text)
assert.equal(z[0], '4 Leistungen (3 aktiv):')
assert.ok(z.includes('- Herrenschnitt: 30 Min. (+ 5 Min. Pause danach), 28,00 €, sofort buchbar, Personen: Anna, Ben'), text)
assert.ok(z.includes('- Damenschnitt (inaktiv): 45 Min. (+ 5 Min. Pause danach), ab 45,00 €, sofort buchbar, Personen: Anna'), text)
assert.ok(z.includes('- Kinderschnitt: 30 Min. (+ 5 Min. Pause danach), kein Preis, nur als Anfrage, Personen: Anna'), text)
assert.ok(z.includes('- Färben: 120 Min. (+ 10 Min. Pause danach), ab 75,00 €, sofort buchbar, Personen: Anna'), text)
assert.equal(z.filter((x) => x.includes('(inaktiv)')).length, 1, 'nur die inaktive Leistung ist markiert')
})
test('D3a Leistungen: Plaetze heissen Plaetze, gemischt Personen und Plaetze, ohne Pause keine Klammer, unbekannte Zuordnung faellt weg', () => {
const e = fixtureEinstellung()
e.leistungen[0].ressourcen = ['r_stuhl001']
e.leistungen[1].ressourcen = [e.ressourcen[0].id, 'r_stuhl001', 'r_gibtsnicht']
e.leistungen[3].pufferNach = 0
e.leistungen[3].ressourcen = []
const z = zeilen(abschnitt(erzeuge({ einstellung: e }), 'leistungen').text)
assert.ok(z.some((x) => x.startsWith('- Herrenschnitt:') && x.endsWith('Plätze: Stuhl 2')), z.join('\n'))
assert.ok(z.some((x) => x.startsWith('- Damenschnitt (inaktiv):') && x.endsWith('Personen und Plätze: Anna, Stuhl 2')), z.join('\n'))
assert.ok(z.includes('- Färben: 120 Min., ab 75,00 €, sofort buchbar, keine Person und kein Platz zugeordnet'), z.join('\n'))
})
test('D3a Ressourcen: Wochenplan je Tag, nur kuenftige Abwesenheiten, Sonderoeffnungen ab heute', () => {
const text = abschnitt(erzeuge(), 'ressourcen').text
const z = zeilen(text)
assert.ok(z.includes('- Anna (Person)'), text)
assert.ok(z.includes(' Mo 09:00–12:30, 14:00–18:00'), text)
assert.ok(z.includes(' Mi 09:00–12:30'), text)
assert.ok(z.includes(' So 10:00–12:00'), text)
assert.ok(z.includes('- Ben (Person)') && z.includes(' Di 08:00–16:00'), text)
assert.ok(z.includes('- Stuhl 2 (Platz, inaktiv)'), text)
assert.ok(z.includes(' keine Zeiten eingetragen'), text)
// Abwesenheiten: abgelaufene fehlen, heute endende und laufende bleiben, nach Beginn sortiert
const ab = z.slice(z.indexOf('Abwesenheiten (ab heute):') + 1).filter((x) => x.startsWith('- ') && /Fortbildung|Kur|Betriebsferien|Urlaub/.test(x))
assert.deepEqual(ab, [
'- 01.10.2026–07.10.2026: Kur (Anna)',
'- 05.10.2026: Fortbildung (Ben)',
'- 24.12.2026–31.12.2026: Betriebsferien (alle)',
])
assert.ok(!text.includes('Urlaub vorbei'), 'die abgelaufene Abwesenheit steht nirgends')
// Sonderoeffnungen: der 03.10. liegt vor heute und faellt weg, der 07.11. kommt vor dem 24.12.
const so = z.slice(z.indexOf('Sonderöffnungszeiten (ab heute, für alle):') + 1).filter((x) => x.startsWith('- '))
assert.deepEqual(so.slice(-2), ['- 07.11.2026: 10:00–14:00', '- 24.12.2026: geschlossen'])
assert.ok(!text.includes('03.10.2026'))
})
test('D3a Ressourcen: ohne Abwesenheiten und Sonderoeffnungen entfallen die Ueberschriften; ohne Ressourcen steht ein Hinweis', () => {
const e = fixtureEinstellung()
e.abwesenheiten = [{ id: 'a_alt00001', ressourceId: null, von: '2026-01-01', bis: '2026-01-02', titel: 'alt' }]
e.sonderoeffnungen = { '2026-01-01': [] }
const text = abschnitt(erzeuge({ einstellung: e }), 'ressourcen').text
assert.ok(!text.includes('Abwesenheiten'), text)
assert.ok(!text.includes('Sonderöffnungszeiten'), text)
e.ressourcen = []
assert.equal(abschnitt(erzeuge({ einstellung: e }), 'ressourcen').text, 'Keine Personen oder Plätze angelegt.')
})
test('D3a Regeln: Raster, Vorlauf, Horizont, Absagefrist, Obergrenze, Storno-Text mit Fassung, Zahlung', () => {
const e = fixtureEinstellung()
const text = abschnitt(erzeuge({ einstellung: e }), 'regeln').text
const z = zeilen(text)
for (const erwartet of [
'Raster: 15 Minuten',
'Vorlauf: 2 Stunden',
'Buchungszeitraum: 60 Tage im Voraus',
'Absage durch Gäste: bis 24 Stunden vor dem Termin',
'Offene Termine je E-Mail-Adresse: höchstens 3',
'Person wählbar: ja',
'Feiertage: geschlossen (Bundesland RP)',
'Bezahlung vor Ort: bar, EC-Karte, Kreditkarte',
'Preishinweis: inkl. USt',
'Widerruf: Widerrufsbelehrung und Widerrufsfunktion werden Gästen angezeigt',
`Stornobedingungen (Fassung ${e.storno.version}):`,
]) assert.ok(z.includes(erwartet), `${erwartet}\n---\n${text}`)
assert.match(e.storno.version, /^\d{4}-\d{2}-\d{2}-\d+$/)
assert.ok(text.includes(` ${e.storno.text}`), 'der Storno-Text steht vollstaendig, eingerueckt')
})
test('D3a Regeln: andere Werte, kein Storno-Text, Unternehmen ohne Widerruf, Kleinunternehmer, keine Zahlarten', () => {
const e = fixtureEinstellung()
e.regeln = { raster: 30, vorlaufMin: 0, horizontTage: 14, absageBisStd: 0, maxOffeneJeEmail: 1, personWaehlbar: false }
e.feiertageGeschlossen = false
e.storno = { text: '', version: '2026-10-01-2' }
e.recht = { gaesteSind: 'unternehmer', freizeit: false }
e.betrieb.kleinunternehmer = true
e.zahlung = { modus: 'vorort', vorOrtArten: [] }
const z = zeilen(abschnitt(erzeuge({ einstellung: e }), 'regeln').text)
for (const erwartet of [
'Raster: 30 Minuten',
'Vorlauf: kein Vorlauf',
'Buchungszeitraum: 14 Tage im Voraus',
'Absage durch Gäste: bis zum Terminbeginn',
'Offene Termine je E-Mail-Adresse: höchstens 1',
'Person wählbar: nein',
'Feiertage: geöffnet wie im Wochenplan (Bundesland RP)',
'Bezahlung vor Ort',
'Preishinweis: kein Ausweis der USt nach § 19 UStG',
'Widerruf: keine Widerrufsbelehrung (Gäste sind keine Verbraucher oder es ist keine Leistung sichtbar)',
'Stornobedingungen: keine hinterlegt',
]) assert.ok(z.includes(erwartet), `${erwartet}\n---\n${z.join('\n')}`)
assert.ok(!z.join('\n').includes('2026-10-01-2'), 'ohne Text keine Fassung im Text')
})
test('D3a Regeln: Vorlauf in Minuten, Stunden und Tagen, Absagefrist in Tagen', () => {
const vorlauf = (minuten) => {
const e = fixtureEinstellung()
e.regeln.vorlaufMin = minuten
return zeilen(abschnitt(erzeuge({ einstellung: e }), 'regeln').text).find((x) => x.startsWith('Vorlauf:'))
}
assert.equal(vorlauf(45), 'Vorlauf: 45 Minuten')
assert.equal(vorlauf(60), 'Vorlauf: 1 Stunde')
assert.equal(vorlauf(90), 'Vorlauf: 90 Minuten')
assert.equal(vorlauf(1440), 'Vorlauf: 1 Tag')
assert.equal(vorlauf(20160), 'Vorlauf: 14 Tage')
})
test('D3a Gastfelder: Name und E-Mail immer Pflicht, Telefon und Nachricht nach Einstellung, Zusatzfelder mit Auswahl', () => {
const text = abschnitt(erzeuge(), 'gastfelder').text
const z = zeilen(text)
for (const erwartet of [
'Name: Pflicht',
'E-Mail-Adresse: Pflicht',
'Telefon: Pflicht',
'Nachricht: optional',
'Zusatzfelder (2):',
'- Haarlänge (Auswahl: kurz, mittel, lang; Pflicht)',
'- Wunschfrisur (Text; optional)',
]) assert.ok(z.includes(erwartet), `${erwartet}\n---\n${text}`)
const e = fixtureEinstellung()
e.gastFelder = { telefon: 'aus', nachricht: 'aus', zusatz: [] }
const leer = zeilen(abschnitt(erzeuge({ einstellung: e }), 'gastfelder').text)
assert.ok(leer.includes('Telefon: aus') && leer.includes('Nachricht: aus') && leer.includes('Zusatzfelder: keine'), leer.join('\n'))
})
test('D3a Benachrichtigungen: Mails an Gaeste nach Konfiguration, Mails an den Betrieb ohne Adresse, Kalender-Abo ohne Link', () => {
const text = abschnitt(erzeuge(), 'benachrichtigungen').text
const z = zeilen(text)
for (const erwartet of [
'Gäste werden mit „Sie“ angesprochen. Absender ist der Betriebsname (Salon Muster), Antworten gehen an den Betrieb.',
'Mails an Gäste:',
'- Bestätigungslink zur E-Mail-Adresse (gültig 30 Minuten)',
'- Terminbestätigung (sofort buchbare Leistungen)',
'- Eingang und Ablehnung von Terminanfragen (Leistungen mit Anfrage)',
'- Erinnerung 24 Stunden vor dem Termin (nur wenn der Gast sie ankreuzt)',
'- Absage und Verschiebung',
'- Eingangsbestätigung des Widerrufs',
'Mails an den Betrieb (Adresse aus den Einstellungen, steht nicht in dieser Anleitung):',
'- Neue Buchung',
'- Neue Terminanfrage',
'- Absage oder Verschiebung durch den Gast',
'- Widerruf',
]) assert.ok(z.includes(erwartet), `${erwartet}\n---\n${text}`)
assert.ok(text.includes('Kalender-Abo (ICS)') && text.includes('im Portal'), text)
assert.ok(!text.includes(BETRIEB.email), 'die Betriebs-E-Mail steht nicht in der Anleitung')
// die Gueltigkeit des Bestaetigungslinks wird beim Aufruf aus der Konfiguration gelesen
const vorher = config.termine.holdMinutes
try {
config.termine.holdMinutes = 45
assert.ok(zeilen(abschnitt(erzeuge(), 'benachrichtigungen').text).includes('- Bestätigungslink zur E-Mail-Adresse (gültig 45 Minuten)'))
} finally {
config.termine.holdMinutes = vorher
}
})
test('D3a Benachrichtigungen: ohne Erinnerung, ohne Anfrage-Leistung, ohne Widerruf und mit Du-Anrede entfallen die Zeilen', () => {
const e = fixtureEinstellung()
e.anrede = 'du'
e.erinnerung = { anbieten: false, stundenVorher: 24 }
e.leistungen[2].buchungsart = 'sofort'
e.recht = { gaesteSind: 'unternehmer', freizeit: false }
const text = abschnitt(erzeuge({ einstellung: e }), 'benachrichtigungen').text
const z = zeilen(text)
assert.ok(text.startsWith('Gäste werden mit „Du“ angesprochen.'), text)
for (const fehlt of ['- Eingang und Ablehnung von Terminanfragen (Leistungen mit Anfrage)', '- Neue Terminanfrage', '- Eingangsbestätigung des Widerrufs', '- Widerruf']) {
assert.ok(!z.includes(fehlt), `${fehlt}\n---\n${text}`)
}
assert.ok(!z.some((x) => x.startsWith('- Erinnerung')), text)
assert.ok(z.includes('- Neue Buchung') && z.includes('- Absage oder Verschiebung durch den Gast'), text)
// nur Anfrage-Leistungen: keine Terminbestaetigung und keine "Neue Buchung"
e.leistungen.forEach((l) => { l.buchungsart = 'anfrage' })
const nurAnfrage = zeilen(abschnitt(erzeuge({ einstellung: e }), 'benachrichtigungen').text)
assert.ok(!nurAnfrage.includes('- Terminbestätigung (sofort buchbare Leistungen)') && !nurAnfrage.includes('- Neue Buchung'))
assert.ok(nurAnfrage.includes('- Neue Terminanfrage'))
// inaktive Leistungen zaehlen nicht
const f = fixtureEinstellung()
f.leistungen[2].aktiv = false
assert.ok(!zeilen(abschnitt(erzeuge({ einstellung: f }), 'benachrichtigungen').text).includes('- Neue Terminanfrage'))
})
test('D3a feedToken: jede Ressource hat einen echten Token, das Ergebnis enthaelt weder einen Token noch das Wort feedToken', () => {
const e = fixtureEinstellung()
assert.ok(e.ressourcen.length >= 3)
for (const r of e.ressourcen) assert.match(r.feedToken, /^[A-Za-z0-9_-]{43}$/)
const json = JSON.stringify(erzeuge({ einstellung: e }))
assert.ok(!json.includes('feedToken'))
assert.ok(!json.toLowerCase().includes('feedtoken'))
for (const r of e.ressourcen) assert.ok(!json.includes(r.feedToken), `Token von ${r.name} steht im Ergebnis`)
})
test('D3a Weissliste: Zusatzfelder an Betrieb, Ressource, Leistung, Regeln und Kopf tauchen nirgends auf', () => {
const e = fixtureEinstellung()
e.geheim = GEHEIM
e.betrieb.geheim = GEHEIM
e.regeln.geheim = GEHEIM
e.gastFelder.geheim = GEHEIM
e.gastFelder.zusatz[0].geheim = GEHEIM
e.storno.geheim = GEHEIM
e.ressourcen[0].geheim = GEHEIM
e.ressourcen[0].notiz = 'NOTIZ-INTERN'
e.leistungen[0].geheim = GEHEIM
e.leistungen[0].notiz = 'NOTIZ-INTERN'
e.leistungen[0].beschreibung = 'BESCHREIBUNG-NUR-FUER-GAESTE'
e.abwesenheiten[3].geheim = GEHEIM
const json = JSON.stringify(erzeuge({
einstellung: e,
feature: { $id: 'f1', customerId: 'c1', projectId: 'p1', siteKey: SITE_KEY, geheim: GEHEIM, feedToken: GEHEIM, adminNote: GEHEIM },
project: { ...PROJEKT, geheim: GEHEIM, apiKey: GEHEIM },
kunde: { name: 'Salon Muster GbR', email: GEHEIM, geheim: GEHEIM },
fragebogen: { geheim: GEHEIM },
}))
for (const verboten of [GEHEIM, 'NOTIZ-INTERN', 'BESCHREIBUNG-NUR-FUER-GAESTE']) assert.ok(!json.includes(verboten), verboten)
// die Betriebs-E-Mail steht nur in den beiden Datenschutz-Absaetzen (Verantwortlicher), sonst nirgends
const r = erzeuge({ einstellung: e })
for (const a of r.abschnitte) assert.equal(a.text.includes(BETRIEB.email), a.id.startsWith('datenschutz_'), a.id)
})
test('D3a Kundentexte stehen unveraendert als Text und koennen keine Abschnitts-Ueberschrift vortaeuschen', () => {
const e = fixtureEinstellung()
e.betrieb.name = 'Evil\n## Regeln\nRaster: 1'
e.ressourcen[0].name = '<img src=x onerror=alert(1)> & "Anna"'
e.leistungen[0].name = 'Schnitt\r\n## Gastfelder'
e.abwesenheiten[3].titel = 'Ferien\n## Modell'
const r = erzeuge({ einstellung: e, kunde: { name: 'Kunde\n## Leistungen' }, project: { projectName: 'Projekt\n## Ressourcen' } })
assert.equal(zeilen(r.text).filter((z) => z.startsWith('## ')).length, 12, r.text) // 13 Abschnitte, nur eine Datenschutz-Fassung
assert.ok(r.text.includes('<img src=x onerror=alert(1)> & "Anna"'), 'kein Maskieren: der Anzeiger setzt es als Text ein')
assert.ok(r.text.includes('Betrieb: Evil ## Regeln Raster: 1'), r.text)
assert.equal(zeilen(r.text).filter((z) => z.startsWith('Raster:')).length, 1)
})
test('D3a ist deterministisch und veraendert die Eingabe nicht', () => {
const e = fixtureEinstellung()
// Abwesenheiten absichtlich unsortiert: der Generator sortiert, darf dabei die Eingabe aber nicht umordnen
e.abwesenheiten.reverse()
const vorher = structuredClone(e)
const a = erzeuge({ einstellung: e })
assert.deepEqual(e, vorher, 'nach dem ersten Aufruf: die Einstellung bleibt unveraendert (kein Sortieren oder Filtern in place)')
const b = erzeuge({ einstellung: e })
assert.deepEqual(e, vorher, 'nach dem zweiten Aufruf ebenso')
assert.deepEqual(a, b)
})
test('D3a ein anderes now aendert nur die Erzeugt-am-Zeile und die Filterung vergangener Dinge', () => {
const normal = (r) => ({ ...r, text: r.text.replace(/^Erzeugt am .*$/m, 'ERZEUGT'), abschnitte: r.abschnitte.map((a) => ({ ...a, text: a.text.replace(/^Erzeugt am .*$/m, 'ERZEUGT') })) })
// einen Tag spaeter, ohne dass etwas ablaeuft (die Fortbildung liegt hier erst am 20.10.): nur die Erzeugt-am-Zeile unterscheidet
const spaeterEnde = fixtureEinstellung()
spaeterEnde.abwesenheiten[1].von = '2026-10-20'
spaeterEnde.abwesenheiten[1].bis = '2026-10-20'
const eins = erzeuge({ einstellung: spaeterEnde })
const zwei = erzeuge({ now: SPAETER, einstellung: spaeterEnde })
assert.notDeepEqual(eins, zwei)
assert.ok(abschnitt(eins, 'modell').text.includes('Erzeugt am 05.10.2026'))
assert.ok(abschnitt(zwei, 'modell').text.includes('Erzeugt am 06.10.2026'))
assert.deepEqual(normal(eins), normal(zwei), 'sonst bleibt alles gleich')
// mit der Fixture-Abwesenheit vom 05.10. faellt sie am Folgetag weg (bis < heute)
const a = erzeuge()
const b = erzeuge({ now: SPAETER })
assert.ok(abschnitt(a, 'ressourcen').text.includes('Fortbildung'))
assert.ok(!abschnitt(b, 'ressourcen').text.includes('Fortbildung'))
assert.ok(abschnitt(b, 'ressourcen').text.includes('Kur'), 'die laufende Kur bleibt')
// zwei Monate spaeter: vergangene Abwesenheiten und Sonderoeffnungen fehlen, sonst bleibt alles gleich
const spaet = erzeuge({ now: new Date('2026-12-20T10:00:00Z') })
const ressourcen = abschnitt(spaet, 'ressourcen').text
assert.ok(!ressourcen.includes('Kur') && !ressourcen.includes('Fortbildung') && !ressourcen.includes('07.11.2026'), ressourcen)
assert.ok(ressourcen.includes('Betriebsferien') && ressourcen.includes('24.12.2026: geschlossen'), ressourcen)
for (const id of ['leistungen', 'regeln', 'gastfelder', 'benachrichtigungen']) assert.equal(abschnitt(spaet, id).text, abschnitt(a, id).text, id)
// ein Abwesenheitstag, der in Berlin schon der naechste Tag ist, zaehlt nach Berliner Kalender (23:30 UTC = 01:30 Berlin am 06.10.)
const nachtgrenze = erzeuge({ now: new Date('2026-10-05T23:30:00Z') })
assert.ok(!abschnitt(nachtgrenze, 'ressourcen').text.includes('Fortbildung'), 'der 05.10. ist in Berlin schon vorbei')
assert.ok(abschnitt(nachtgrenze, 'modell').text.includes('Erzeugt am 06.10.2026'))
})
test('D3a ohne Einstellung wirft nichts; ein ungueltiges now wirft', () => {
const r = erzeuge({ einstellung: null, version: 0, updatedAt: null })
assert.deepEqual(r.abschnitte.map((a) => a.id), ['snippet'], 'D3b: nur der Einbau-Code')
assert.deepEqual(r.stand, { version: 0, updatedAt: null })
for (const now of [undefined, null, 'heute', new Date('kaputt'), 1.7e12]) {
assert.throws(() => erzeuge({ now }), TypeError, String(now))
}
})
test('D3a Hilfen: wochenplanText und kuerzeListe', () => {
assert.deepEqual(wochenplanText({ 1: [['09:00', '12:30'], ['14:00', '18:00']], 5: [['09:00', '13:00']] }), ['Mo 09:00–12:30, 14:00–18:00', 'Fr 09:00–13:00'])
assert.deepEqual(wochenplanText({ 7: [['10:00', '12:00']], 2: [] }), ['So 10:00–12:00'], 'leere Tage fehlen, Reihenfolge Montag bis Sonntag')
assert.deepEqual(wochenplanText({}), [])
assert.deepEqual(wochenplanText(undefined), [])
assert.deepEqual(wochenplanText({ 1: 'kaputt', 8: [['09:00', '10:00']], 3: [['09:00']] }), [], 'Unbrauchbares wird uebersprungen')
assert.deepEqual(kuerzeListe(['a', 'b', 'c'], 3), ['a', 'b', 'c'])
assert.deepEqual(kuerzeListe(['a', 'b', 'c', 'd', 'e'], 2), ['a', 'b', '… und 3 weitere'])
assert.deepEqual(kuerzeListe(['a', 'b', 'c'], 2), ['a', 'b', '… und 1 weitere'])
assert.deepEqual(kuerzeListe([], 5), [])
})
test('D3a Kuerzen: lange Abwesenheits- und Sonderoeffnungslisten enden mit und N weitere, Leistungen stehen vollstaendig', () => {
const e = fixtureEinstellung()
e.abwesenheiten = Array.from({ length: 30 }, (_, i) => ({
id: `a_x${String(i).padStart(7, '0')}`, ressourceId: null, von: `2027-01-${String(i + 1).padStart(2, '0')}`, bis: `2027-01-${String(i + 1).padStart(2, '0')}`, titel: `Tag ${i + 1}`,
}))
e.sonderoeffnungen = Object.fromEntries(Array.from({ length: 25 }, (_, i) => [`2027-02-${String(i + 1).padStart(2, '0')}`, [['10:00', '12:00']]]))
e.leistungen = Array.from({ length: 50 }, (_, i) => ({ ...e.leistungen[0], id: `s_l${String(i).padStart(6, '0')}`, name: `Leistung ${i + 1}` }))
const r = erzeuge({ einstellung: e })
const z = zeilen(abschnitt(r, 'ressourcen').text)
assert.equal(z.filter((x) => /^- \d\d\.01\.2027/.test(x)).length, 20)
assert.ok(z.includes('- … und 10 weitere'), z.join('\n'))
assert.equal(z.filter((x) => /^- \d\d\.02\.2027/.test(x)).length, 20)
assert.ok(z.includes('- … und 5 weitere'), z.join('\n'))
const l = zeilen(abschnitt(r, 'leistungen').text)
assert.equal(l[0], '50 Leistungen (50 aktiv):')
assert.equal(l.filter((x) => x.startsWith('- Leistung ')).length, 50)
})
// ================================================================ Generator einbauanleitung() 2 (Task D3b)
// Einbau, Snippet, CSS, Datenschutz-Absaetze, Checkliste, Abrechnung, Warnungen, Volltext.
const ALLE_IDS = [
'modell', 'leistungen', 'ressourcen', 'regeln', 'gastfelder', 'benachrichtigungen',
'einbau', 'snippet', 'css', 'datenschutz_sie', 'datenschutz_du', 'checkliste', 'abrechnung',
]
const KOPIERBAR = ['snippet', 'css', 'datenschutz_sie', 'datenschutz_du']
const KNOPF_FRAGEBOGEN = {
einbau: { modus: 'knopf', knopfText: 'Termin sichern', stelle: 'Startseite unter dem Hero', gestaltung: 'Schlicht, wie die Seite', bemerkung: '<script>alert(1)</script>' },
}
const PAKET_FEATURE = { $id: 'f1', customerId: 'c1', projectId: 'p1', siteKey: SITE_KEY, state: 'setup', unlockedByPurchase: true }
const hatText = (ergebnis, ...teile) => teile.every((t) => ergebnis.text.includes(t))
test('D3b: die 13 Abschnitt-IDs in fester Reihenfolge, kopierbar nur Snippet, CSS und beide Datenschutz-Absaetze', () => {
const r = erzeuge()
assert.deepEqual(r.abschnitte.map((a) => a.id), ALLE_IDS)
for (const a of r.abschnitte) {
assert.deepEqual(Object.keys(a).sort(), ['id', 'kopierbar', 'text', 'titel'], a.id)
assert.equal(a.kopierbar, KOPIERBAR.includes(a.id), a.id)
assert.ok(a.titel && a.text, a.id)
}
assert.equal(new Set(r.abschnitte.map((a) => a.titel)).size, 13, 'jede Ueberschrift kommt einmal vor')
})
test('D3b: ENTWURF_BANNER hat den Wortlaut aus dem Brief', () => {
assert.equal(ENTWURF_BANNER, 'ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen')
})
test('D3b Volltext: genau eine Datenschutz-Fassung, die zur Anrede passt; abschnitte enthalten beide', () => {
const sie = fixtureEinstellung()
const du = fixtureEinstellung()
du.anrede = 'du'
const textSie = datenschutzAbsatz(sie, 'sie')
const textDu = datenschutzAbsatz(sie, 'du')
assert.notEqual(textSie, textDu)
const a = erzeuge({ einstellung: sie })
assert.equal(abschnitt(a, 'datenschutz_sie').text, textSie)
assert.equal(abschnitt(a, 'datenschutz_du').text, datenschutzAbsatz(sie, 'du'))
assert.ok(a.text.includes(textSie) && !a.text.includes(textDu), 'Sie-Betrieb: nur die Sie-Fassung')
assert.ok(a.text.includes(`## ${abschnitt(a, 'datenschutz_sie').titel}`) && !a.text.includes(`## ${abschnitt(a, 'datenschutz_du').titel}`))
const b = erzeuge({ einstellung: du })
assert.deepEqual(b.abschnitte.map((x) => x.id), ALLE_IDS, 'die Abschnitte bleiben gleich, nur der Volltext waehlt')
assert.ok(b.text.includes(datenschutzAbsatz(du, 'du')) && !b.text.includes(datenschutzAbsatz(du, 'sie')), 'Du-Betrieb: nur die Du-Fassung')
assert.equal(abschnitt(b, 'datenschutz_sie').text, datenschutzAbsatz(du, 'sie'))
// jeder andere Abschnitt steht im Volltext, in der Reihenfolge der Abschnitte
const ids = b.abschnitte.filter((x) => x.id !== 'datenschutz_sie').map((x) => `## ${x.titel}`)
assert.deepEqual(zeilen(b.text).filter((z) => z.startsWith('## ')), ids)
// eine unbekannte Anrede gilt wie in D3a als Sie
const seltsam = fixtureEinstellung()
seltsam.anrede = 'Herr'
assert.ok(erzeuge({ einstellung: seltsam }).text.includes(datenschutzAbsatz(seltsam, 'sie')))
})
test('D3b Datenschutz-Absatz: kein ENTWURF-Banner im kopierbaren Text, die Betriebs-E-Mail steht nur dort', () => {
const r = erzeuge({ entwurf: true })
for (const id of ['datenschutz_sie', 'datenschutz_du']) {
const t = abschnitt(r, id).text
assert.ok(!t.includes('ENTWURF'), id)
assert.ok(t.startsWith('Online-Terminbuchung'), id)
assert.ok(t.includes(BETRIEB.email), id)
}
})
test('D3b Snippet und CSS: genau das, was die Bausteine liefern, ohne Fragebogen im Modus inline', () => {
const r = erzeuge()
const erwartet = einbauSnippet({ siteKey: SITE_KEY, apiBase: config.publicBaseUrl, telefon: BETRIEB.telefon, modus: 'inline' })
assert.equal(abschnitt(r, 'snippet').text, erwartet)
assert.ok(erwartet.includes('data-modus="inline"') && !erwartet.includes('data-knopf'))
assert.ok(erwartet.includes(`data-webklar-termine="${SITE_KEY}"`) && erwartet.includes(`data-api="${config.publicBaseUrl}"`))
assert.ok(erwartet.includes('<p>Termine telefonisch unter 0631 123456.</p>'), 'der Ausweichtext nennt die Telefonnummer des Betriebs')
assert.equal(abschnitt(r, 'css').text, cssVorschlag('#9d4b6f').css)
assert.ok(abschnitt(r, 'css').text.includes('--wkt-akzent:#9d4b6f;'))
// Fragebogen ohne Modus oder mit unbekanntem Modus: inline
for (const fragebogen of [{ einbau: { modus: null } }, { einbau: { modus: 'dialog' } }, { einbau: 'kaputt' }, {}, 'text']) {
assert.equal(abschnitt(erzeuge({ fragebogen }), 'snippet').text, erwartet, JSON.stringify(fragebogen))
}
// die Portal-Adresse wird beim Aufruf gelesen
const vorher = config.publicBaseUrl
try {
config.publicBaseUrl = 'https://portal.test.example'
assert.ok(abschnitt(erzeuge(), 'snippet').text.includes('data-api="https://portal.test.example"'))
} finally {
config.publicBaseUrl = vorher
}
})
test('D3b Snippet: Telefonnummer und siteKey sind maskiert (kein Ausbrechen aus dem Platzhalter)', () => {
const e = fixtureEinstellung()
e.betrieb.telefon = '0631 <b>12</b>'
const r = erzeuge({ einstellung: e, feature: { ...PAKET_FEATURE, siteKey: 'a"><script>x' } })
const snippet = abschnitt(r, 'snippet').text
assert.ok(snippet.includes('<p>Termine telefonisch unter 0631 &lt;b&gt;12&lt;/b&gt;.</p>'), snippet)
assert.ok(snippet.includes('data-webklar-termine="a&quot;&gt;&lt;script&gt;x"'), snippet)
})
test('D3b Kundenwunsch: Knopf im Snippet, Wuensche nur als Text im Einbau-Abschnitt, nie in Snippet und CSS', () => {
const r = erzeuge({ fragebogen: KNOPF_FRAGEBOGEN })
const snippet = abschnitt(r, 'snippet').text
assert.ok(snippet.includes('data-modus="knopf" data-knopf="Termin sichern"'), snippet)
assert.equal(snippet, einbauSnippet({ siteKey: SITE_KEY, apiBase: config.publicBaseUrl, telefon: BETRIEB.telefon, modus: 'knopf', knopfText: 'Termin sichern' }))
const einbau = abschnitt(r, 'einbau').text
assert.ok(einbau.includes('Kundenwunsch (Text des Kunden)'), einbau)
for (const wunsch of ['Termin sichern', 'Startseite unter dem Hero', 'Schlicht, wie die Seite', '<script>alert(1)</script>']) {
assert.ok(einbau.includes(wunsch), `${wunsch}\n---\n${einbau}`)
}
assert.equal((einbau.match(/Kundenwunsch \(Text des Kunden\)/g) || []).length, 1, 'ein einziger Block')
// Stelle, Gestaltung und Bemerkung stehen in keinem anderen Abschnitt (der Knopf-Text nur zusaetzlich im Snippet)
for (const a of r.abschnitte.filter((x) => x.id !== 'einbau')) {
for (const wunsch of ['Startseite unter dem Hero', 'Schlicht, wie die Seite', 'alert(1)']) assert.ok(!a.text.includes(wunsch), `${wunsch} in ${a.id}`)
}
assert.ok(!snippet.includes('alert(1)') && !abschnitt(r, 'css').text.includes('alert(1)'))
assert.equal((snippet.match(/<script/g) || []).length, 1, 'im Snippet steht nur das eigene Skript-Element')
// der Modus steht auch im Einbau-Abschnitt
assert.ok(/Modus: knopf/.test(einbau), einbau)
assert.ok(/Modus: inline/.test(abschnitt(erzeuge(), 'einbau').text))
})
test('D3b Kundenwunsch: ohne Fragebogen steht, dass es keine Angaben gibt; der Knopf-Text zaehlt nur im Modus knopf', () => {
const ohne = abschnitt(erzeuge(), 'einbau').text
assert.ok(ohne.includes('Kundenwunsch (Text des Kunden): keine Angaben'), ohne)
const inline = erzeuge({ fragebogen: { einbau: { modus: 'inline', knopfText: 'Buchen', stelle: 'Unten', gestaltung: '', bemerkung: '' } } })
assert.ok(!abschnitt(inline, 'snippet').text.includes('data-knopf'), 'inline: kein Knopf-Text im Snippet')
assert.ok(abschnitt(inline, 'einbau').text.includes('Unten'))
})
test('D3b Modus: ohne gueltige Wahl gilt inline und der Einbau-Abschnitt sagt, dass es der Standard ist', () => {
for (const fragebogen of [null, { einbau: { modus: 'dialog' } }, { einbau: { modus: 'KNOPF' } }, { einbau: { modus: null } }]) {
const r = erzeuge({ fragebogen })
const einbau = abschnitt(r, 'einbau').text
assert.ok(einbau.includes('Modus: inline – die Buchungsstrecke steht direkt in der Seite (Standard, der Fragebogen nennt keinen Modus)'), `${JSON.stringify(fragebogen)}\n${einbau}`)
assert.ok(!einbau.includes('undefined') && !/--wkt-akzent immer/.test(abschnitt(r, 'checkliste').text))
}
const gewaehlt = abschnitt(erzeuge({ fragebogen: { einbau: { modus: 'inline' } } }), 'einbau').text
assert.ok(gewaehlt.includes('(Wahl des Kunden im Fragebogen)'), gewaehlt)
assert.ok(abschnitt(erzeuge({ fragebogen: KNOPF_FRAGEBOGEN }), 'einbau').text.includes('Modus: knopf – ein Knopf öffnet die Buchungsstrecke in einem Dialog (Wahl des Kunden im Fragebogen)'))
})
test('D3b Kundenwunsch: die Grenzen 40/300/1000/1000 gelten erneut', () => {
const lang = (n) => 'ä'.repeat(n + 5)
const r = erzeuge({ fragebogen: { einbau: { modus: 'knopf', knopfText: lang(40), stelle: lang(300), gestaltung: lang(1000), bemerkung: lang(1000) } } })
const einbau = abschnitt(r, 'einbau').text
const wert = (label) => zeilen(einbau).find((z) => z.trim().startsWith(`${label}: `)).trim().slice(label.length + 2)
assert.equal(wert('Knopf-Text'), 'ä'.repeat(40))
assert.equal(wert('Stelle'), 'ä'.repeat(300))
assert.equal(wert('Gestaltung'), 'ä'.repeat(1000))
assert.equal(wert('Bemerkung'), 'ä'.repeat(1000))
assert.equal(/data-knopf="([^"]*)"/.exec(abschnitt(r, 'snippet').text)[1], 'ä'.repeat(40))
// genau an der Grenze bleibt alles stehen; Emoji zaehlen als ein Zeichen und werden nie zerschnitten
const emoji = erzeuge({ fragebogen: { einbau: { modus: 'knopf', knopfText: '😀'.repeat(41), stelle: '😀'.repeat(301), gestaltung: '', bemerkung: '' } } })
const t = abschnitt(emoji, 'einbau').text
assert.ok(t.includes(`Stelle: ${'😀'.repeat(300)}\n`) || t.endsWith(`Stelle: ${'😀'.repeat(300)}`) || zeilen(t).includes(` Stelle: ${'😀'.repeat(300)}`), t)
assert.ok(!t.includes('😀'.repeat(301)) && !t.includes('😀'.repeat(41) + '\n'))
assert.ok(emoji.text.isWellFormed(), 'kein halbes Zeichenpaar')
// ein halbes Zeichenpaar im gespeicherten Fragebogen wird ersetzt, das Ergebnis ist durchgehend wohlgeformt
const kaputt = erzeuge({ fragebogen: { einbau: { modus: 'knopf', knopfText: 'Buchen\uD83D', stelle: 'oben\uDE00', gestaltung: '\uD83D', bemerkung: 'a\uD83Db' } } })
assert.ok(kaputt.text.isWellFormed() && JSON.stringify(kaputt).length > 0)
for (const a of kaputt.abschnitte) assert.ok(a.text.isWellFormed(), a.id)
assert.ok(abschnitt(kaputt, 'einbau').text.includes('a\uFFFDb'))
})
test('D3b Kundenwunsch: Umbrueche bleiben (eingerueckt), eine Zeile "## Regeln" taeuscht keine Ueberschrift vor, Fremdfelder fehlen', () => {
const r = erzeuge({
fragebogen: {
geheim: GEHEIM,
einbau: { modus: 'inline', knopfText: '', stelle: 'Oben\n## Regeln\nRaster: 1', gestaltung: 'Zeile 1\n\nZeile 3', bemerkung: 'x\r\n## Modell', geheim: GEHEIM },
},
})
assert.equal(zeilen(r.text).filter((z) => z.startsWith('## ')).length, 12)
assert.equal(zeilen(r.text).filter((z) => z.startsWith('Raster:')).length, 1, 'die Raster-Zeile im Wunsch ist eingerueckt')
const einbau = abschnitt(r, 'einbau').text
assert.ok(zeilen(einbau).includes(' Stelle: Oben'), einbau)
assert.ok(zeilen(einbau).includes(' ## Regeln'), einbau)
assert.ok(zeilen(einbau).includes(' Gestaltung: Zeile 1') && zeilen(einbau).includes(' Zeile 3'), einbau)
assert.ok(!JSON.stringify(r).includes(GEHEIM))
})
test('D3b Banner: ENTWURF_BANNER genau dann, wenn entwurf, und zwar als erste Warnung', () => {
assert.equal(erzeuge().warnungen.includes(ENTWURF_BANNER), RECHTSTEXTE_ENTWURF, 'der Standard haengt an RECHTSTEXTE_ENTWURF')
assert.deepEqual(erzeuge({ entwurf: true }).warnungen, [ENTWURF_BANNER])
assert.deepEqual(erzeuge({ entwurf: false }).warnungen, [])
assert.equal(erzeuge({ entwurf: undefined }).warnungen.includes(ENTWURF_BANNER), RECHTSTEXTE_ENTWURF, 'undefined waehlt den Standard')
assert.equal(erzeuge({ entwurf: true }).warnungen.filter((w) => w === ENTWURF_BANNER).length, 1)
})
test('einbau_ohne_banner: im Standardaufruf steht kein ENTWURF-Banner in Warnungen, Volltext und Kurzfassung; mit entwurf: true bleibt er', () => {
assert.equal(RECHTSTEXTE_ENTWURF, false)
const r = erzeuge()
assert.deepEqual(r.warnungen, [])
assert.ok(!r.warnungen.includes(ENTWURF_BANNER))
assert.ok(!r.text.includes('ENTWURF'), 'Volltext')
assert.ok(!r.kurz.includes('ENTWURF'), 'Kurzfassung (Ticket)')
for (const a of r.abschnitte) assert.ok(!a.text.includes('ENTWURF'), `Abschnitt ${a.id}`)
// die D3b-Tests mit ausdrücklichem entwurf: true bleiben gültig: der Banner ist weiter eine eigene Warnung
const mit = erzeuge({ entwurf: true })
assert.deepEqual(mit.warnungen, [ENTWURF_BANNER])
assert.ok(mit.kurz.includes(ENTWURF_BANNER))
})
test('checkliste_datenschutz_endregel: die Checkliste nennt die Endregel der Leistungsbeschreibung, die Zwischenregel ist weg', () => {
const text = abschnitt(erzeuge(), 'checkliste').text
const punkt = zeilen(text).find((z) => z.includes('Datenschutz-Absatz „Online-Terminbuchung“ steht in der Datenschutzerklärung'))
assert.ok(punkt, text)
assert.ok(punkt.includes(`(Fassung Sie; ${DATENSCHUTZ_ENDREGEL}).`), punkt)
for (const teil of ['von WEBklar betreuten Website', 'fügen wir den Absatz beim Einbau ein', 'nicht auf das Stundenkontingent angerechnet', 'Textbaustein per Chat an den Kunden']) {
assert.ok(punkt.includes(teil), `Endregel: ${teil}`)
}
assert.ok(!text.includes('liegt die Datenschutzerklärung nicht in unserem Repo'), 'Zwischenregel aus D3b ist weg')
assert.ok(!erzeuge().kurz.includes('nicht in unserem Repo'), 'auch nicht in der Kurzfassung')
assert.ok(!erzeuge().text.includes('nicht in unserem Repo'), 'und nicht im Volltext')
const du = fixtureEinstellung()
du.anrede = 'du'
assert.ok(abschnitt(erzeuge({ einstellung: du }), 'checkliste').text.includes(`(Fassung Du; ${DATENSCHUTZ_ENDREGEL}).`))
})
test('D3b Warnungen: fehlende Telefonnummer, und Anschrift nur, wenn Gaeste ein Widerrufsrecht haben', () => {
const ohneTelefon = fixtureEinstellung()
ohneTelefon.betrieb.telefon = ''
const w = erzeuge({ einstellung: ohneTelefon, entwurf: false }).warnungen
assert.equal(w.length, 1, w.join('\n'))
assert.ok(/Telefon/.test(w[0]) && /nicht buchen/.test(w[0]), w[0])
const ohneAnschrift = fixtureEinstellung()
ohneAnschrift.betrieb.adresse = ' '
const v = erzeuge({ einstellung: ohneAnschrift, entwurf: false }).warnungen
assert.equal(v.length, 1, v.join('\n'))
assert.ok(/Anschrift/.test(v[0]) && /nicht buchen/.test(v[0]), v[0])
ohneAnschrift.recht = { gaesteSind: 'unternehmer', freizeit: false }
assert.deepEqual(erzeuge({ einstellung: ohneAnschrift, entwurf: false }).warnungen, [], 'Unternehmen: keine Widerrufsbelehrung, die Anschrift ist nicht Pflicht')
ohneTelefon.betrieb.adresse = ''
const beides = erzeuge({ einstellung: ohneTelefon, entwurf: false }).warnungen
assert.deepEqual(beides.map((x) => /Telefon|Anschrift/.exec(x)[0]), ['Telefon', 'Anschrift'], 'Telefon vor Anschrift')
})
test('D3b Warnungen: fehlende Impressum- oder Datenschutz-URL', () => {
const e = fixtureEinstellung()
e.betrieb.impressumUrl = null
const a = erzeuge({ einstellung: e, entwurf: false }).warnungen
assert.equal(a.length, 1)
assert.ok(/Impressum/.test(a[0]) && /Link/.test(a[0]), a[0])
e.betrieb.datenschutzUrl = ''
const b = erzeuge({ einstellung: e, entwurf: false }).warnungen
assert.deepEqual(b.map((x) => /Impressum|Datenschutz/.exec(x)[0]), ['Impressum', 'Datenschutz'], 'Impressum vor Datenschutz')
e.betrieb.impressumUrl = 'https://salon-muster.example/impressum'
e.betrieb.datenschutzUrl = 'https://salon-muster.example/datenschutz'
assert.deepEqual(erzeuge({ einstellung: e, entwurf: false }).warnungen, [])
})
test('D3b Warnungen: Gesundheitsbereich (Liste gehoert C), als Klartext, wohlgeformt und einzeilig', () => {
const e = fixtureEinstellung()
e.leistungen[1].name = 'Physiotherapie'
const w = erzeuge({ einstellung: e, entwurf: false }).warnungen
assert.equal(w.length, 1, w.join('\n'))
assert.ok(w[0].includes('Physio'), w[0])
assert.ok(erzeuge({ einstellung: fixtureEinstellung(), entwurf: false }).warnungen.every((x) => !/Physio/.test(x)), 'ohne Treffer keine Meldung')
// Hinweis der Steuerung (C2): die Meldungen tragen Namen des Kunden, gekuerzt in UTF-16-Einheiten (halbes Zeichenpaar moeglich)
// und ungemaskiert. Sie bleiben Klartext (der Anzeiger setzt sie als Text ein), muessen aber wohlgeformt und einzeilig sein.
const f = fixtureEinstellung()
f.leistungen[0].name = `${'x'.repeat(38)}😀${'y'.repeat(10)}`
f.leistungen[0].beschreibung = 'Physiotherapie'
f.leistungen[1].name = 'Zeile1\n## Zeile2 <b>x</b>'
f.leistungen[1].beschreibung = 'Physiotherapie'
const roh = erzeuge({ einstellung: f, entwurf: false })
const lose = roh.warnungen.filter((x) => /Beschreibung der Leistung/.test(x))
assert.equal(lose.length, 2, roh.warnungen.join('\n'))
for (const x of roh.warnungen) {
assert.ok(x.isWellFormed(), JSON.stringify(x))
assert.ok(!/[\r\n]/.test(x), JSON.stringify(x))
}
assert.ok(lose.some((x) => x.includes('<b>x</b>')), 'Klartext: nichts wird maskiert oder entfernt')
assert.ok(lose.some((x) => x.includes('x'.repeat(38))), lose.join('\n'))
for (const a of roh.abschnitte.filter((x) => x.id === 'snippet' || x.id === 'css')) assert.ok(!a.text.includes('<b>'), a.id)
assert.ok(!roh.text.includes('Beschreibung der Leistung'), 'die Warnungen stehen nicht im Volltext')
})
test('D3b Warnungen: Kontrasthinweise aus cssVorschlag', () => {
const e = fixtureEinstellung()
e.farbe = '#ffff00'
const erwartet = cssVorschlag('#ffff00').hinweise
assert.equal(erwartet.length, 1)
assert.deepEqual(erzeuge({ einstellung: e, entwurf: false }).warnungen, erwartet)
assert.deepEqual(erzeuge({ entwurf: false }).warnungen, [], 'die Farbe der Fixture besteht')
})
test('D3b Datenschutz-Absatz nicht erzeugbar: kein Wurf, Hinweis statt Text im Abschnitt, eine Warnung', () => {
for (const luecke of [{ email: '' }, { email: undefined }, { name: '' }, { name: ' ', email: null }]) {
const e = fixtureEinstellung()
Object.assign(e.betrieb, luecke)
const r = erzeuge({ einstellung: e, entwurf: false })
assert.deepEqual(r.abschnitte.map((a) => a.id), ALLE_IDS, JSON.stringify(luecke))
for (const id of ['datenschutz_sie', 'datenschutz_du']) {
const a = abschnitt(r, id)
assert.ok(/nicht erzeugt/.test(a.text) && /E-Mail/.test(a.text), a.text)
assert.ok(!a.text.includes('Online-Terminbuchung'), 'es ist kein Absatz fuer die Website, nur ein Hinweis')
assert.equal(a.kopierbar, false, `${id}: ein Hinweis wird nie als Datenschutz-Absatz zum Kopieren angeboten`)
}
assert.equal(r.warnungen.length, 1, r.warnungen.join('\n'))
assert.ok(/Datenschutz-Absatz nicht erzeugbar/.test(r.warnungen[0]), r.warnungen[0])
assert.ok(r.text.includes('nicht erzeugt'), 'der Hinweis steht im Volltext')
}
})
test('D3b Warnungen: feste Reihenfolge Banner, Pflichtangaben, URLs, Gesundheit, Kontrast, Datenschutz', () => {
const e = fixtureEinstellung()
e.betrieb.telefon = ''
e.betrieb.adresse = ''
e.betrieb.impressumUrl = ''
e.betrieb.datenschutzUrl = ''
e.betrieb.email = ''
e.farbe = '#ffff00'
e.leistungen[1].name = 'Physiotherapie'
const w = erzeuge({ einstellung: e, entwurf: true }).warnungen
const treffer = [ENTWURF_BANNER, /Telefon/, /Anschrift/, /Impressum/, /Datenschutz-URL/, /Physio/, /Kontrast gegen Weiß/, /Datenschutz-Absatz nicht erzeugbar/]
const stellen = treffer.map((t) => w.findIndex((x) => (typeof t === 'string' ? x === t : t.test(x))))
assert.ok(stellen.every((i) => i >= 0), `${JSON.stringify(stellen)}\n${w.join('\n')}`)
assert.deepEqual(stellen, [...stellen].sort((a, b) => a - b), w.join('\n'))
assert.equal(w.length, 8)
})
test('D3b Checkliste: immer-Punkte (Freischalten, Probebuchung, stornieren, Kontrast, 390 px, Tastatur, Datenschutz, URLs)', () => {
const text = abschnitt(erzeuge(), 'checkliste').text
const z = zeilen(text)
assert.ok(z.every((x) => x.startsWith('- [ ] ')), 'jeder Punkt ist ein Kaestchen')
for (const erwartet of [
'Freischalten (Abnahme)', 'Fast geschafft', 'stornieren', 'öffentliche Schnittstelle', 'unsichtbar', 'Ausweichtext',
'Datenschutz-Absatz', 'Impressum', 'Kontrast', '390 px', 'Tastatur',
]) assert.ok(text.includes(erwartet), `${erwartet}\n---\n${text}`)
assert.ok(text.includes(BETRIEB.impressumUrl) && text.includes(BETRIEB.datenschutzUrl), 'die URLs stehen zum Pruefen im Punkt')
assert.ok(text.includes(DATENSCHUTZ_ENDREGEL), 'Endregel der Leistungsbeschreibung (Nr. 4 Abs. 9)')
assert.ok(text.includes(`${config.womsBaseUrl}/customers/c1?tab=funktionen`), 'Link ins WOMS-Kundenpanel')
// die Reihenfolge: erst Einbau, dann Freischalten, dann Probebuchung, dann die Pruefungen am sichtbaren Widget
const stelle = (teil) => z.findIndex((x) => x.includes(teil))
assert.ok(stelle('Einbau-Code') < stelle('Freischalten (Abnahme)') && stelle('Freischalten (Abnahme)') < stelle('Fast geschafft'), text)
assert.ok(stelle('Fast geschafft') < stelle('Kontrast') && stelle('Kontrast') < stelle('390 px') && stelle('390 px') < stelle('Tastatur'), text)
})
test('D3b Checkliste: der WOMS-Link wird beim Aufruf gelesen und fehlt ohne Kunden- und Projekt-ID', () => {
const vorher = config.womsBaseUrl
try {
config.womsBaseUrl = 'https://woms.test.example'
assert.ok(abschnitt(erzeuge(), 'checkliste').text.includes('https://woms.test.example/customers/c1?tab=funktionen'))
} finally {
config.womsBaseUrl = vorher
}
const text = abschnitt(erzeuge({ feature: { siteKey: SITE_KEY } }), 'checkliste').text
assert.ok(text.includes('Freischalten (Abnahme)') && !text.includes('/customers/'), text)
})
test('D3b Checkliste: Verbraucher haben die Widerrufspunkte, Unternehmen keinen einzigen', () => {
const verbraucher = abschnitt(erzeuge(), 'checkliste').text
for (const erwartet of ['Widerrufsbelehrung', 'aufklappbar', 'vorzeitigen Beginn', 'Vertrag widerrufen']) {
assert.ok(verbraucher.includes(erwartet), `${erwartet}\n---\n${verbraucher}`)
}
for (const gaesteSind of ['unternehmer']) {
const e = fixtureEinstellung()
e.recht = { gaesteSind, freizeit: false }
const t = abschnitt(erzeuge({ einstellung: e, fragebogen: KNOPF_FRAGEBOGEN }), 'checkliste').text
assert.ok(!/Widerruf|widerruf|vorzeitig/.test(t), `${gaesteSind}\n---\n${t}`)
}
const freizeit = fixtureEinstellung()
freizeit.recht = { gaesteSind: 'verbraucher', freizeit: true }
assert.ok(!/Widerruf|widerruf/.test(abschnitt(erzeuge({ einstellung: freizeit }), 'checkliste').text), 'Freizeitleistung: kein Widerrufsrecht')
// keine sichtbare Leistung: auch dann keine Widerrufspunkte (zeigtWiderruf)
const leer = fixtureEinstellung()
leer.leistungen.forEach((l) => { l.aktiv = false })
assert.ok(!/Widerruf|widerruf/.test(abschnitt(erzeuge({ einstellung: leer }), 'checkliste').text))
})
test('D3b Checkliste: der Haken fuer den vorzeitigen Beginn erscheint nur bei Leistungen mit Preis', () => {
const e = fixtureEinstellung()
assert.ok(abschnitt(erzeuge({ einstellung: e }), 'checkliste').text.includes('vorzeitigen Beginn'))
e.leistungen.forEach((l) => { l.preisArt = 'keiner'; l.preisCent = 0 })
const t = abschnitt(erzeuge({ einstellung: e }), 'checkliste').text
assert.ok(!t.includes('vorzeitigen Beginn'), t)
assert.ok(t.includes('Widerrufsbelehrung') && t.includes('Vertrag widerrufen'), 'die uebrigen Widerrufspunkte bleiben')
})
test('D3b Checkliste: Verbraucher mit Modus knopf haben den dauerhaften Widerrufsknopf, mit inline nicht', () => {
const knopf = abschnitt(erzeuge({ fragebogen: KNOPF_FRAGEBOGEN }), 'checkliste').text
assert.ok(knopf.includes('Widerrufsknopf dauerhaft neben dem Buchungsknopf'), knopf)
const inline = abschnitt(erzeuge({ fragebogen: { einbau: { modus: 'inline' } } }), 'checkliste').text
const ohne = abschnitt(erzeuge(), 'checkliste').text
for (const t of [inline, ohne]) {
assert.ok(!t.includes('dauerhaft neben dem Buchungsknopf') && !t.includes('Widerrufsknopf'), t)
assert.ok(t.includes('Vertrag widerrufen'), 'der Knopf im Fuss bleibt')
}
})
test('D3b Checkliste: Modus knopf verlangt --wkt-akzent immer, inline nicht', () => {
const knopf = abschnitt(erzeuge({ fragebogen: KNOPF_FRAGEBOGEN }), 'checkliste').text
assert.ok(/--wkt-akzent immer setzen/.test(knopf), knopf)
// auch ohne Widerrufsrecht
const e = fixtureEinstellung()
e.recht = { gaesteSind: 'unternehmer', freizeit: false }
const ohneWiderruf = abschnitt(erzeuge({ einstellung: e, fragebogen: KNOPF_FRAGEBOGEN }), 'checkliste').text
assert.ok(/--wkt-akzent immer setzen/.test(ohneWiderruf) && !/Widerruf/.test(ohneWiderruf), ohneWiderruf)
assert.ok(!/--wkt-akzent/.test(abschnitt(erzeuge(), 'checkliste').text))
})
test('D3b Abrechnung: bezahlt mit Rechnung, bezahlt ohne Rechnung, ohne Zahlung freigeschaltet', () => {
const rechnung = 'https://invoice.stripe.com/i/acct_1/test_abc'
const mit = abschnitt(erzeuge({ feature: { ...PAKET_FEATURE, invoiceUrl: rechnung } }), 'abrechnung').text
assert.ok(mit.includes(`Einrichtung bezahlt (149 €, Rechnung ${rechnung}), Zeit im Arbeitsblatt, nicht auf Abo-Stunden`), mit)
assert.ok(mit.includes('ein Widget an einer Stelle, Gestaltung nach Fragebogen, Einbau; Mehrwünsche (zweites Widget, Knöpfe je Leistung, Sonderdesign) nach Stunden, vorher im Chat abstimmen'), mit)
assert.ok(!mit.includes('Rechnung folgt'))
for (const invoiceUrl of [undefined, null, '', ' ', 'javascript:alert(1)', 'http://invoice.example/x', 'kein url']) {
const ohne = abschnitt(erzeuge({ feature: { ...PAKET_FEATURE, invoiceUrl } }), 'abrechnung').text
assert.ok(ohne.includes('Einrichtung bezahlt (149 €, Rechnung folgt im Portal), Zeit im Arbeitsblatt, nicht auf Abo-Stunden'), `${invoiceUrl}\n${ohne}`)
assert.ok(ohne.includes('Leistungsumfang') && !ohne.includes('javascript:'), ohne)
}
for (const unlockedByPurchase of [false, undefined, null, 'true', 1]) {
const pilot = abschnitt(erzeuge({ feature: { ...PAKET_FEATURE, unlockedByPurchase, invoiceUrl: rechnung } }), 'abrechnung').text
assert.ok(pilot.includes('ohne Zahlung freigeschaltet (Pilot/Kulanz), keine Abrechnung'), `${unlockedByPurchase}\n${pilot}`)
assert.ok(!pilot.includes('149 €') && !pilot.includes(rechnung) && !pilot.includes('Abo-Stunden'), pilot)
}
const pilot = abschnitt(erzeuge({ feature: null }), 'abrechnung').text
assert.ok(pilot.includes('keine Abrechnung'), 'ohne Funktionsdokument: keine Abrechnung')
})
test('D3b ohne Einstellung: nur der Abschnitt snippet (Modus aus dem Fragebogen), die Warnung, ein Volltext aus dem Snippet', () => {
const r = erzeuge({ einstellung: null, version: 0, updatedAt: null, entwurf: false })
assert.deepEqual(r.abschnitte.map((a) => a.id), ['snippet'])
assert.equal(r.abschnitte[0].kopierbar, true)
assert.deepEqual(r.warnungen, ['Keine Einstellungen gespeichert'])
assert.equal(r.abschnitte[0].text, einbauSnippet({ siteKey: SITE_KEY, apiBase: config.publicBaseUrl, telefon: undefined, modus: 'inline' }))
assert.ok(r.abschnitte[0].text.includes('Termine telefonisch vereinbaren.'), 'ohne Einstellung gibt es keine Telefonnummer')
assert.equal(r.text, `## ${r.abschnitte[0].titel}\n${r.abschnitte[0].text}`)
assert.deepEqual(r.stand, { version: 0, updatedAt: null })
assert.ok(r.kurz.includes(r.abschnitte[0].text), 'die Kurzfassung (D4) traegt auch hier das Snippet')
const knopf = erzeuge({ einstellung: null, fragebogen: KNOPF_FRAGEBOGEN, version: 0, entwurf: false })
assert.ok(knopf.abschnitte[0].text.includes('data-modus="knopf" data-knopf="Termin sichern"'), knopf.abschnitte[0].text)
assert.ok(!knopf.text.includes('alert(1)') && !knopf.text.includes('Startseite unter dem Hero'), 'die Wuensche stehen auch hier nicht im Snippet')
// das Banner gilt unabhaengig von den Einstellungen und steht zuerst
assert.deepEqual(erzeuge({ einstellung: null, entwurf: true }).warnungen, [ENTWURF_BANNER, 'Keine Einstellungen gespeichert'])
// kein Objekt gilt wie null
for (const einstellung of [undefined, 'text', 42, []]) assert.deepEqual(erzeuge({ einstellung, entwurf: false }).abschnitte.map((a) => a.id), ['snippet'], JSON.stringify(einstellung))
})
test('D3b ist deterministisch, veraendert Eingaben nicht und gibt nie ein Token aus', () => {
const e = fixtureEinstellung()
e.betrieb.email = ''
e.leistungen[1].name = 'Physiotherapie'
const fragebogen = structuredClone(KNOPF_FRAGEBOGEN)
const feature = { ...PAKET_FEATURE, invoiceUrl: 'https://invoice.stripe.com/i/x' }
const vorher = structuredClone({ e, fragebogen, feature })
const a = erzeuge({ einstellung: e, fragebogen, feature })
const b = erzeuge({ einstellung: e, fragebogen, feature })
assert.deepEqual(a, b)
assert.deepEqual(structuredClone({ e, fragebogen, feature }), vorher)
const json = JSON.stringify(a)
for (const r of e.ressourcen) assert.ok(!json.includes(r.feedToken))
assert.ok(!/feedtoken/i.test(json))
})
test('D3b Volltext hat die Abschnitte in der Reihenfolge der Tabelle und nur Text (keine HTML-Maskierung der Kundentexte)', () => {
const e = fixtureEinstellung()
e.ressourcen[0].name = 'Anna <b>&</b>'
const r = erzeuge({ einstellung: e, fragebogen: KNOPF_FRAGEBOGEN })
assert.ok(r.text.includes('Anna <b>&</b>'))
assert.ok(hatText(r, abschnitt(r, 'snippet').text, abschnitt(r, 'css').text, abschnitt(r, 'checkliste').text, abschnitt(r, 'abrechnung').text, abschnitt(r, 'einbau').text))
const stellen = r.abschnitte.filter((a) => a.id !== 'datenschutz_du').map((a) => r.text.indexOf(`## ${a.titel}\n`))
assert.ok(stellen.every((i) => i >= 0))
assert.deepEqual(stellen, [...stellen].sort((x, y) => x - y))
})
// ================================================================ Kurzfassung fuers Ticket, ticketStand, driftWarnung (Task D4)
// `kurz` ist Klartext fuer ticket.details (WOMS zeigt pre-wrap): Absaetze durch Leerzeile, erster Absatz = Kopf, hoechstens
// TICKET_DETAILS_MAX Zeichen, nie ein Datenschutz-Absatz und nie ein Token.
const HALBES_PAAR = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/
const absaetze = (text) => text.split('\n\n')
const absatzMit = (kurz, anfang) => absaetze(kurz).find((a) => a.startsWith(anfang))
const LINK = anleitungsLink('c1', 'p1')
const GESUNDHEIT_KOPF = 'Gesundheitsbereich prüfen:'
/** Betrieb mit sechs Warnungen (Banner kommt ueber entwurf: true): Telefon, Anschrift, Impressum, Datenschutz-URL, Kontrast. */
function mitSechsWarnungen() {
const e = fixtureEinstellung()
e.betrieb.telefon = ''
e.betrieb.adresse = ''
e.betrieb.impressumUrl = ''
e.betrieb.datenschutzUrl = ''
e.farbe = '#ffff00'
return e
}
/** Maximalbefuellung: 50 Leistungen mit 100 Zeichen (die ersten zehn mit Gesundheitswort), 20 Personen, 5 Zusatzfelder, sechs Warnungen. */
function maximalEinstellung() {
const e = mitSechsWarnungen()
const vorlage = e.leistungen[0]
e.leistungen = Array.from({ length: 50 }, (_, i) => ({
...vorlage,
id: `s_max${String(i).padStart(5, '0')}`,
name: `${String(i + 1).padStart(2, '0')} ${'L'.repeat(97)}`,
aktiv: true,
ressourcen: [],
beschreibung: i < 10 ? 'Physiotherapie' : '',
}))
e.ressourcen = Array.from({ length: 20 }, (_, i) => ({
id: `r_max${String(i).padStart(5, '0')}`, typ: 'person', name: `Person ${i + 1} ${'P'.repeat(80)}`, aktiv: true, plan: { 1: [['09:00', '12:00']] }, feedToken: neuerFeedToken(),
}))
e.gastFelder = {
telefon: 'pflicht',
nachricht: 'optional',
zusatz: Array.from({ length: 5 }, (_, i) => ({ key: `z${i}`, label: `Zusatzfeld ${i + 1} ${'Z'.repeat(90)}`, typ: 'text', optionen: [], pflicht: false })),
}
return e
}
const MAX_FRAGEBOGEN = { einbau: { modus: 'knopf', knopfText: 'K'.repeat(40), stelle: 'S'.repeat(300), gestaltung: 'G'.repeat(1000), bemerkung: 'B'.repeat(1000) } }
test('D4 kurz: der erste Absatz ist der Kopf und endet mit der Konfigurationsstand-Zeile', () => {
const r = erzeuge({ entwurf: false })
const [kopf] = absaetze(r.kurz)
assert.deepEqual(kopf.split('\n'), [
'Funktion: Terminplaner',
'Projekt: Salon Muster Website',
'Domain: salon-muster.example',
'Kunde: Salon Muster GbR',
`siteKey: ${SITE_KEY}`,
'Konfigurationsstand: Version 3, 03.10.2026',
])
assert.equal(ticketStand(r.kurz), 3, 'ticketStand liest die erzeugte Zeile zurueck')
})
test('D4 kurz Kopf: Domain wie createFeatureTicket (liveDomain, sonst previewUrl, sonst subdomain), Projektname faellt auf die subdomain zurueck', () => {
const kopf = (project) => absaetze(erzeuge({ project }).kurz)[0].split('\n').slice(1, 3)
assert.deepEqual(kopf(PROJEKT), ['Projekt: Salon Muster Website', 'Domain: salon-muster.example'])
assert.deepEqual(kopf({ projectName: 'P', subdomain: 's', previewUrl: 'https://s.project.webklar.com' }), ['Projekt: P', 'Domain: https://s.project.webklar.com'])
assert.deepEqual(kopf({ projectName: 'P', subdomain: 's' }), ['Projekt: P', 'Domain: s'])
assert.deepEqual(kopf({ subdomain: 's' }), ['Projekt: s', 'Domain: s'])
assert.deepEqual(kopf(undefined), ['Projekt: ', 'Domain: '])
// lange Werte und Umbrueche: eine Zeile, hoechstens 120 Zeichen
const lang = kopf({ projectName: `Name\nmit\tUmbruch ${'x'.repeat(200)}`, liveDomain: 'd'.repeat(300) })
assert.ok(lang[0].startsWith('Projekt: Name mit Umbruch xxx'), lang[0])
assert.ok(lang[0].length <= 'Projekt: '.length + 120 && lang[0].endsWith('…'), lang[0])
assert.ok(lang[1].length <= 'Domain: '.length + 120 && lang[1].endsWith('…'), lang[1])
})
test('D4 kurz Konfigurationsstand: Version und Datum, ohne Datum nur die Version, ohne Einstellungen Version 0, unlesbare Einstellungen', () => {
const stand = (zusatz) => absaetze(erzeuge(zusatz).kurz)[0].split('\n').at(-1)
assert.equal(stand({}), 'Konfigurationsstand: Version 3, 03.10.2026')
assert.equal(stand({ updatedAt: null }), 'Konfigurationsstand: Version 3')
assert.equal(stand({ updatedAt: 'kein Datum' }), 'Konfigurationsstand: Version 3')
assert.equal(stand({ updatedAt: '2026-12-31T23:30:00.000Z' }), 'Konfigurationsstand: Version 3, 01.01.2027', 'Berliner Kalendertag')
assert.equal(stand({ einstellung: null, version: 0, updatedAt: null }), 'Konfigurationsstand: Version 0 (noch keine Einstellungen gespeichert)')
assert.equal(stand({ einstellung: null, version: 4, updatedAt: '2026-10-03T08:15:00.000Z' }), 'Konfigurationsstand: Version 4 (Einstellungen nicht lesbar)')
assert.equal(ticketStand(erzeuge({ einstellung: null, version: 0 }).kurz), 0)
})
test('D4 kurz: Snippet, 6 Schritte in fester Reihenfolge, Verweis; kein Datenschutz-Absatz, kein Token', () => {
const e = fixtureEinstellung()
const r = erzeuge({ einstellung: e, entwurf: false, feature: PAKET_FEATURE })
const snippet = abschnitt(r, 'snippet').text
assert.ok(snippet.includes(`data-webklar-termine="${SITE_KEY}"`))
assert.ok(r.kurz.includes(`Einbau-Code (Snippet):\n${snippet}`), 'das Snippet steht unveraendert und mit Ueberschrift im Ticket')
const schritte = absatzMit(r.kurz, 'Schritte:').split('\n').slice(1)
assert.equal(schritte.length, 6, schritte.join('\n'))
schritte.forEach((s, i) => assert.ok(s.startsWith(`${i + 1}. `), s))
const reihenfolge = [/WOMS/, /Einbau-Code/, /Kontrast/, /Datenschutz-Absatz/, /„Freischalten \(Abnahme\)“.*Probebuchung.*stornier/, /Ticket erledigen.*Zeit.*Nicht auf das Stundenkontingent anrechnen/]
reihenfolge.forEach((muster, i) => assert.match(schritte[i], muster, schritte[i]))
assert.ok(r.kurz.endsWith(`Verweis: ${LINK}`), 'der Verweis steht zuletzt')
assert.ok(!r.kurz.includes('Online-Terminbuchung'), 'der Datenschutz-Absatz gehoert nicht ins Ticket')
assert.ok(!r.kurz.includes(datenschutzAbsatz(e, 'sie').slice(0, 80)))
for (const x of e.ressourcen) assert.ok(!r.kurz.includes(x.feedToken), 'kein Kalender-Token')
assert.ok(!/feedtoken/i.test(r.kurz))
assert.ok(!r.kurz.includes(e.betrieb.email), 'die Betriebs-E-Mail steht nur in den Datenschutz-Absaetzen')
})
test('D4 kurz Schritt 4 und 6: Datenschutz-Fassung nach Anrede, Preis nur bei bezahlter Einrichtung, die Pflichtformel steht immer', () => {
const schritt = (zusatz, n) => absatzMit(erzeuge(zusatz).kurz, 'Schritte:').split('\n')[n]
const du = fixtureEinstellung()
du.anrede = 'du'
assert.match(schritt({}, 4), /Sie-Form/)
assert.match(schritt({ einstellung: du }, 4), /Du-Form/)
assert.ok(schritt({}, 4).includes(DATENSCHUTZ_ENDREGEL), 'dieselbe Endregel wie in der Checkliste')
assert.match(schritt({ feature: PAKET_FEATURE }, 6), /Nicht auf das Stundenkontingent anrechnen \(Einrichtung bezahlt, 149 €\)/)
assert.ok(schritt({}, 6).includes('Nicht auf das Stundenkontingent anrechnen'))
assert.ok(!schritt({}, 6).includes('bezahlt'), 'ohne Kauf keine Behauptung, die Einrichtung sei bezahlt')
for (const unlock of [undefined, null, 1, 'true']) assert.ok(!schritt({ feature: { ...PAKET_FEATURE, unlockedByPurchase: unlock } }, 6).includes('bezahlt'), String(unlock))
})
test('D4 kurz Verweis: anleitungsLink des Projekts, ohne IDs der allgemeine Hinweis', () => {
assert.ok(erzeuge().kurz.endsWith(`Verweis: ${LINK}`))
assert.equal(LINK, `${config.womsBaseUrl}/customers/c1?tab=funktionen&anleitung=p1`)
const ohne = erzeuge({ feature: { $id: 'f1', siteKey: SITE_KEY } })
assert.ok(ohne.kurz.endsWith('Verweis: im WOMS unter Kunden → Funktionen, Anleitung des Projekts'), ohne.kurz.slice(-120))
assert.ok(!ohne.kurz.includes('?tab='))
})
test('D4 kurz Kundenwunsch: Modus, Knopf-Text, Stelle, Gestaltung und Bemerkung als Text des Kunden (eingerueckt), ohne Fragebogen der Standard', () => {
const mit = erzeuge({ fragebogen: KNOPF_FRAGEBOGEN })
const block = absatzMit(mit.kurz, 'Kundenwunsch')
assert.deepEqual(block.split('\n'), [
'Kundenwunsch (Text des Kunden):',
' Modus: knopf',
' Knopf-Text: Termin sichern',
' Stelle: Startseite unter dem Hero',
' Gestaltung: Schlicht, wie die Seite',
' Bemerkung: <script>alert(1)</script>',
])
assert.ok(mit.kurz.includes('data-modus="knopf" data-knopf="Termin sichern"'))
assert.equal(mit.kurz.split('alert(1)').length - 1, 1, 'der Wunsch steht nur im Kundenwunsch-Block, nie im Snippet')
const ohne = absatzMit(erzeuge({ fragebogen: null }).kurz, 'Kundenwunsch').split('\n')
assert.deepEqual(ohne, ['Kundenwunsch (Text des Kunden):', ' Modus: inline (Standard)', ' Sonst keine Angaben im Fragebogen'])
// mehrzeilig: weitere Zeilen eingerueckt, Leerzeilen fallen weg (nie ein Absatzumbruch im Kundentext), keine Ueberschrift, kein falscher Stand
const mehrKurz = erzeuge({ fragebogen: { einbau: { modus: 'inline', bemerkung: 'Eins\n\n\nZwei\nKonfigurationsstand: Version 99' } } }).kurz
assert.deepEqual(absatzMit(mehrKurz, 'Kundenwunsch').split('\n').slice(2), [' Bemerkung: Eins', ' Zwei', ' Konfigurationsstand: Version 99'])
assert.ok(!/^Konfigurationsstand: Version 99/m.test(mehrKurz), 'der Wunsch kann den Stand nicht faelschen')
assert.equal(ticketStand(mehrKurz), 3)
assert.equal(absaetze(mehrKurz).length, absaetze(erzeuge().kurz).length, 'die Absatzzahl aendert sich durch den Wunsch nicht')
})
test('D4 kurz Zaehlungen: Anzahl, aktive und die ersten 8 Namen, danach "und N weitere"', () => {
const umfang = absatzMit(erzeuge().kurz, 'Umfang:').split('\n')
assert.deepEqual(umfang, [
'Umfang:',
'- 4 Leistungen (3 aktiv): Herrenschnitt, Damenschnitt, Kinderschnitt, Färben',
'- 3 Personen und Plätze (2 aktiv): Anna, Ben, Stuhl 2',
'- 2 Zusatzfelder: Haarlänge, Wunschfrisur',
])
const e = fixtureEinstellung()
e.leistungen = Array.from({ length: 12 }, (_, i) => ({ ...e.leistungen[0], id: `s_z${i}`, name: `Leistung ${i + 1}`, aktiv: i !== 3 }))
const zeile12 = absatzMit(erzeuge({ einstellung: e }).kurz, 'Umfang:').split('\n')[1]
assert.equal(zeile12, '- 12 Leistungen (11 aktiv): Leistung 1, Leistung 2, Leistung 3, Leistung 4, Leistung 5, Leistung 6, Leistung 7, Leistung 8, … und 4 weitere')
e.leistungen = e.leistungen.slice(0, 8)
assert.ok(!absatzMit(erzeuge({ einstellung: e }).kurz, 'Umfang:').includes('weitere'), 'genau 8: nichts uebrig')
e.leistungen = e.leistungen.slice(0, 1)
e.ressourcen = []
e.gastFelder = { zusatz: [] }
assert.deepEqual(absatzMit(erzeuge({ einstellung: e }).kurz, 'Umfang:').split('\n').slice(1), ['- 1 Leistung (1 aktiv): Leistung 1', '- 0 Personen und Plätze', '- 0 Zusatzfelder'])
// sehr lange Namen: je Name hoechstens 60 Zeichen, Umbrueche werden zu Leerzeichen
e.leistungen = [{ ...e.leistungen[0], name: `${'N'.repeat(100)}\nzwei` }]
const lang = absatzMit(erzeuge({ einstellung: e }).kurz, 'Umfang:').split('\n')[1]
assert.ok(lang.endsWith(`${'N'.repeat(59)}…`), lang)
assert.ok(!lang.includes('zwei') && !lang.includes('N'.repeat(60)))
})
test('D4 kurz Warnungen: hoechstens 5 in der Reihenfolge von warnungen, das Banner nur einmal, ohne Warnung kein Absatz', () => {
const r = erzeuge({ einstellung: mitSechsWarnungen(), entwurf: true })
assert.equal(r.warnungen.length, 6, r.warnungen.join('\n'))
const block = absatzMit(r.kurz, 'Warnungen:').split('\n')
assert.deepEqual(block, ['Warnungen:', ...r.warnungen.slice(0, 5).map((w) => `- ${w}`)], 'die ersten fuenf, die sechste (Kontrast) bleibt dem Panel')
assert.equal(r.kurz.split(ENTWURF_BANNER).length - 1, 1, 'der Banner-Text steht genau einmal im Ticket')
assert.equal(block[1], `- ${ENTWURF_BANNER}`)
const ohne = erzeuge({ entwurf: false })
assert.deepEqual(ohne.warnungen, [])
assert.ok(!ohne.kurz.includes('Warnungen:'))
assert.equal(erzeuge({ einstellung: mitSechsWarnungen(), entwurf: false }).warnungen.length, 5)
assert.equal(absatzMit(erzeuge({ einstellung: mitSechsWarnungen(), entwurf: false }).kurz, 'Warnungen:').split('\n').length, 6)
})
test('D4 kurz Gesundheit: eigener Absatz, zaehlt nicht zu den 5 Warnungen, wohlgeformt, einzeilig und als Klartext', () => {
const e = mitSechsWarnungen()
e.leistungen[0].name = 'Physiotherapie'
e.leistungen[1].name = `${'x'.repeat(38)}😀${'y'.repeat(10)}`
e.leistungen[1].beschreibung = 'Therapie'
e.leistungen[2].name = 'Zeile1\n## Zeile2 <b>x</b>'
e.leistungen[2].beschreibung = 'Physiotherapie'
const r = erzeuge({ einstellung: e, entwurf: true })
const gesundheit = absatzMit(r.kurz, GESUNDHEIT_KOPF).split('\n')
const meldungen = gesundheitsMeldungen(e)
assert.equal(meldungen.length, 3, meldungen.join('\n'))
assert.deepEqual(gesundheit, [GESUNDHEIT_KOPF, ...meldungen.map((m) => `- ${m}`)])
assert.equal(absatzMit(r.kurz, 'Warnungen:').split('\n').length, 6, 'die fuenf Warnungen bleiben voll, die Gesundheit kommt dazu')
for (const m of meldungen) {
assert.ok(m.isWellFormed() && !/[\r\n]/.test(m), JSON.stringify(m))
assert.ok(!r.warnungen.every((w) => w !== m), 'dieselben Meldungen stehen auch in warnungen')
}
assert.ok(meldungen.some((m) => m.includes('<b>x</b>')), 'Klartext, nichts wird maskiert')
assert.ok(r.kurz.isWellFormed() && !HALBES_PAAR.test(r.kurz))
// keine Treffer: kein Absatz
assert.ok(!erzeuge().kurz.includes(GESUNDHEIT_KOPF))
// Reihenfolge der Absaetze
const stellen = ['Funktion:', 'Kundenwunsch', 'Umfang:', 'Warnungen:', GESUNDHEIT_KOPF, 'Einbau-Code (Snippet):', 'Schritte:', 'Verweis:'].map((a) => absaetze(erzeuge({ einstellung: e, entwurf: true, fragebogen: KNOPF_FRAGEBOGEN }).kurz).findIndex((x) => x.startsWith(a)))
assert.ok(stellen.every((i) => i >= 0), JSON.stringify(stellen))
assert.deepEqual(stellen, [...stellen].sort((a, b) => a - b))
})
test('D4 gesundheitsMeldungen: ohne Treffer und ohne Einstellung leer, sonst die bereinigten Meldungen der Pruefung aus C', () => {
assert.deepEqual(gesundheitsMeldungen(null), [])
assert.deepEqual(gesundheitsMeldungen(undefined), [])
assert.deepEqual(gesundheitsMeldungen(fixtureEinstellung()), [])
const e = fixtureEinstellung()
e.leistungen[0].name = 'Physiotherapie'
assert.deepEqual(gesundheitsMeldungen(e), ['Leistungsname enthält „Physiotherapie“'])
const halb = fixtureEinstellung()
halb.leistungen[0].name = `${'x'.repeat(38)}😀${'y'.repeat(10)}`
halb.leistungen[0].beschreibung = 'Physiotherapie'
for (const m of gesundheitsMeldungen(halb)) assert.ok(m.isWellFormed(), JSON.stringify(m))
})
test('D4 kurz ohne Einstellungen: Kopf mit Version 0, Warnung, Snippet, Schritte und Verweis', () => {
const r = erzeuge({ einstellung: null, version: 0, updatedAt: null, entwurf: true })
const [kopf] = absaetze(r.kurz)
assert.ok(kopf.endsWith('Konfigurationsstand: Version 0 (noch keine Einstellungen gespeichert)'), kopf)
assert.ok(r.kurz.includes('noch keine Einstellungen'))
assert.deepEqual(absatzMit(r.kurz, 'Warnungen:').split('\n'), ['Warnungen:', `- ${ENTWURF_BANNER}`, '- Keine Einstellungen gespeichert'])
assert.ok(r.kurz.includes(`Einbau-Code (Snippet):\n${r.abschnitte[0].text}`))
assert.ok(!r.kurz.includes('Umfang:') && !r.kurz.includes(GESUNDHEIT_KOPF))
assert.equal(absatzMit(r.kurz, 'Schritte:').split('\n').length, 7)
assert.ok(r.kurz.endsWith(`Verweis: ${LINK}`))
assert.ok(r.kurz.length <= TICKET_DETAILS_MAX)
// der Wunsch des Kunden (Fragebogen) steht auch ohne Einstellungen im Ticket
assert.ok(erzeuge({ einstellung: null, version: 0, fragebogen: KNOPF_FRAGEBOGEN }).kurz.includes(' Knopf-Text: Termin sichern'))
})
test('D4 kurz Maximalbefuellung: hoechstens TICKET_DETAILS_MAX, Kopf, Snippet, Verweis, Stundenkontingent und alle 10 Gesundheitswarnungen bleiben', () => {
const e = maximalEinstellung()
const meldungen = gesundheitsMeldungen(e)
assert.equal(meldungen.length, 10, 'die Pruefung aus C liefert hoechstens 10')
const r = erzeuge({ einstellung: e, fragebogen: MAX_FRAGEBOGEN, feature: PAKET_FEATURE, entwurf: true })
assert.ok(r.kurz.length <= TICKET_DETAILS_MAX, `kurz ${r.kurz.length}`)
assert.ok(r.kurz.length > 3000, 'es steht etwas Sinnvolles da, nicht nur ein Rumpf')
assert.equal(absaetze(r.kurz)[0].split('\n').at(-1), 'Konfigurationsstand: Version 3, 03.10.2026')
assert.ok(r.kurz.includes(abschnitt(r, 'snippet').text))
assert.ok(r.kurz.includes('Nicht auf das Stundenkontingent anrechnen'))
assert.ok(r.kurz.endsWith(`Verweis: ${LINK}`))
assert.deepEqual(absatzMit(r.kurz, GESUNDHEIT_KOPF).split('\n'), [GESUNDHEIT_KOPF, ...meldungen.map((m) => `- ${m}`)])
assert.ok(!r.kurz.includes('Online-Terminbuchung'))
for (const x of e.ressourcen) assert.ok(!r.kurz.includes(x.feedToken))
assert.ok(r.kurz.isWellFormed())
assert.equal(r.kurz.split(ENTWURF_BANNER).length - 1, 1)
})
test('D4 kurz Platzdruck: erst fallen die Zaehlungen, dann die Warnungen, dann der Kundenwunsch weg; Pflichtteile und Gesundheit nie', () => {
const gesehen = new Set()
let zuletzt = null
// das Snippet waechst mit dem (hier absichtlich nicht normalisierten) Telefon und treibt die Pflichtteile nach oben
for (let laenge = 1; laenge <= 3200; laenge += 8) {
const e = maximalEinstellung()
e.betrieb.telefon = '0'.repeat(laenge)
e.betrieb.adresse = ''
const r = erzeuge({ einstellung: e, fragebogen: MAX_FRAGEBOGEN, feature: PAKET_FEATURE, entwurf: true })
const k = r.kurz
assert.ok(k.length <= TICKET_DETAILS_MAX, `Telefon ${laenge}: kurz ${k.length}`)
// Pflichtteile
assert.equal(absaetze(k)[0].split('\n').at(-1), 'Konfigurationsstand: Version 3, 03.10.2026')
assert.ok(k.includes(abschnitt(r, 'snippet').text), `Telefon ${laenge}: Snippet`)
assert.ok(k.includes('Nicht auf das Stundenkontingent anrechnen') && k.endsWith(`Verweis: ${LINK}`))
assert.deepEqual(absatzMit(k, GESUNDHEIT_KOPF).split('\n').slice(1), gesundheitsMeldungen(e).map((m) => `- ${m}`), `Telefon ${laenge}: Gesundheit`)
// Stufen
const umfang = k.includes('\n\nUmfang:')
const warnungen = k.includes('\n\nWarnungen:')
const wunsch = k.includes('\n\nKundenwunsch (Text des Kunden):')
if (umfang) assert.ok(warnungen && wunsch, `Telefon ${laenge}: Zaehlungen nur mit Warnungen und Wunsch`)
if (warnungen) assert.ok(wunsch, `Telefon ${laenge}: Warnungen nur mit Wunsch`)
if (warnungen) {
const anzahl = absatzMit(k, 'Warnungen:').split('\n').length - 1
assert.deepEqual(absatzMit(k, 'Warnungen:').split('\n').slice(1), r.warnungen.filter((w) => !gesundheitsMeldungen(e).includes(w)).slice(0, anzahl).map((w) => `- ${w}`), 'es fallen die letzten Warnungen zuerst weg')
}
gesehen.add(`${umfang ? 'Umfang' : '-'}/${warnungen ? 'Warnungen' : '-'}/${wunsch ? 'Wunsch' : '-'}`)
zuletzt = k
}
assert.ok(gesehen.has('Umfang/Warnungen/Wunsch'), [...gesehen].join(' '))
assert.ok(gesehen.has('-/Warnungen/Wunsch'), [...gesehen].join(' '))
assert.ok(gesehen.has('-/-/Wunsch'), [...gesehen].join(' '))
assert.ok(gesehen.has('-/-/-'), [...gesehen].join(' '))
assert.ok(zuletzt.length > 0)
})
test('D4 kurz: selbst ein riesiges Snippet sprengt die Grenze nicht, die Gesundheit steht vor dem Schnitt', () => {
const e = maximalEinstellung()
e.betrieb.telefon = '9'.repeat(20000)
const k = erzeuge({ einstellung: e, fragebogen: MAX_FRAGEBOGEN, entwurf: true }).kurz
assert.ok(k.length <= TICKET_DETAILS_MAX, `kurz ${k.length}`)
assert.ok(k.isWellFormed() && !HALBES_PAAR.test(k))
assert.ok(k.includes(GESUNDHEIT_KOPF))
assert.ok(k.endsWith('… gekürzt, vollständig im Ticketsystem unter Kunden → Funktionen'))
assert.equal(absaetze(k)[0].split('\n').at(-1), 'Konfigurationsstand: Version 3, 03.10.2026')
})
test('D4 kurz ist wohlgeformt: halbe Zeichenpaare in Namen, Wuenschen, Projekt und Kunde werden ersetzt, Emojis bleiben', () => {
const e = fixtureEinstellung()
e.leistungen[0].name = `Schnitt \uD83D${'x'.repeat(10)}😀`
e.ressourcen[0].name = `Anna\uDE00`
const fb = { einbau: { modus: 'inline', bemerkung: `halb \uD83D und ganz 😀`, stelle: '\uDE00' } }
const r = erzeuge({ einstellung: e, fragebogen: fb, project: { projectName: `Salon \uD83D`, liveDomain: 'x.de' }, kunde: { name: `K\uDE00unde` } })
assert.ok(r.kurz.isWellFormed() && !HALBES_PAAR.test(r.kurz), JSON.stringify(r.kurz.match(HALBES_PAAR)))
assert.ok(r.kurz.includes('😀'), 'ganze Zeichenpaare bleiben')
assert.ok(JSON.parse(JSON.stringify(r.kurz)) === r.kurz)
})
test('D4 kurz ist deterministisch, unabhaengig von now und aendert die Eingaben nicht', () => {
const e = maximalEinstellung()
const fb = structuredClone(MAX_FRAGEBOGEN)
const vorher = structuredClone({ e, fb })
const a = erzeuge({ einstellung: e, fragebogen: fb })
assert.equal(erzeuge({ einstellung: e, fragebogen: fb }).kurz, a.kurz)
assert.equal(erzeuge({ einstellung: e, fragebogen: fb, now: SPAETER }).kurz, a.kurz, 'in der Kurzfassung steht kein Erzeugungsdatum')
assert.deepEqual(structuredClone({ e, fb }), vorher)
})
// ---------------------------------------------------------------- ticketStand, driftWarnung
test('D4 ticketStand liest Konfigurationsstand: Version N am Zeilenanfang, sonst null', () => {
assert.equal(ticketStand('Funktion: Terminplaner\nKonfigurationsstand: Version 7, 03.10.2026\n\nrest'), 7)
assert.equal(ticketStand('Konfigurationsstand: Version 7, 03.10.2026'), 7)
assert.equal(ticketStand('Konfigurationsstand: Version 0 (noch keine Einstellungen gespeichert)'), 0)
assert.equal(ticketStand('x\r\nKonfigurationsstand: Version 12'), 12)
assert.equal(ticketStand('Konfigurationsstand: Version 999999999'), 999999999, 'neun Stellen sind erlaubt')
assert.equal(ticketStand('Konfigurationsstand: Version 99999999999'), null, 'elf Stellen: kein Stand')
assert.equal(ticketStand('Konfigurationsstand: Version 1234567890'), null, 'zehn Stellen: kein Stand')
for (const ohne of ['', 'Funktion: Terminplaner', 'Konfigurationsstand: Version', 'Konfigurationsstand: Version x', ' Konfigurationsstand: Version 7', 'Der Konfigurationsstand: Version 7', 'konfigurationsstand: Version 7', 'Konfigurationsstand: Version -7']) {
assert.equal(ticketStand(ohne), null, JSON.stringify(ohne))
}
for (const nichtText of [undefined, null, 7, {}, ['Konfigurationsstand: Version 7']]) assert.equal(ticketStand(nichtText), null, String(nichtText))
})
test('D4 driftWarnung: nur wenn die Einstellungen jetzt eine hoehere Version haben als das Ticket', () => {
assert.equal(driftWarnung(7, 9), 'Einstellungen seit der Bestellung geändert (Ticket Version 7, jetzt 9).')
assert.equal(driftWarnung(0, 1), 'Einstellungen seit der Bestellung geändert (Ticket Version 0, jetzt 1).', 'Version 0 im Ticket ist ein gueltiger Stand')
assert.equal(driftWarnung(9, 9), null)
assert.equal(driftWarnung(9, 7), null)
assert.equal(driftWarnung(null, 9), null)
assert.equal(driftWarnung(7, null), null)
assert.equal(driftWarnung(undefined, 9), null)
assert.equal(driftWarnung('7', 9), null)
assert.equal(driftWarnung(7.5, 9), null)
})