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 & b < c > d "e"') 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('

Termine telefonisch unter 06301 <12>.

'), mit) assert.ok(!mit.includes('<12>')) for (const telefon of [undefined, null, '']) { assert.ok(einbauSnippet({ siteKey: SITE_KEY, apiBase: API, telefon }).includes('

Termine telefonisch vereinbaren.

'), String(telefon)) } }) test('Snippet: siteKey und Adresse sind maskiert (kein Ausbrechen aus dem Attribut)', () => { const s = einbauSnippet({ siteKey: 'a">'), s) assert.equal((s.match(/`)) }) 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], '"'.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;}', '#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', '', '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 = ' & "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(' & "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: '' }, } 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('

Termine telefonisch unter 0631 123456.

'), '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 12' const r = erzeuge({ einstellung: e, feature: { ...PAKET_FEATURE, siteKey: 'a">']) { 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(/', ]) 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 x' 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('x')), '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) })