Terminplaner: Einbauanleitung als reiner Generator (Einbau bis Abrechnung, Warnungen)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
13bbadc729
commit
90692b646b
2 files changed
+706
-33
No files matched your search
@@ -4,8 +4,9 @@ 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 {
|
||||
cssVorschlag, einbauanleitung, einbauSnippet, esc, kontrast, kuerzeListe, luminanz, textFarbeAuf, wochenplanText,
|
||||
ENTWURF_BANNER, cssVorschlag, einbauanleitung, einbauSnippet, esc, kontrast, kuerzeListe, luminanz, textFarbeAuf, wochenplanText,
|
||||
} from '../server/services/termine/einbauanleitung.js'
|
||||
|
||||
// Einbau-Snippet und Farbvorschlag des Terminplaners (TP2 Task D2). Reine Funktionen ohne Appwrite und ohne config.
|
||||
@@ -281,21 +282,22 @@ function erzeuge(zusatz = {}) {
|
||||
const abschnitt = (ergebnis, id) => ergebnis.abschnitte.find((a) => a.id === id)
|
||||
const zeilen = (text) => text.split('\n')
|
||||
|
||||
test('D3a: Ergebnisform, die sechs Abschnitte in fester Reihenfolge, keiner kopierbar', () => {
|
||||
const r = erzeuge()
|
||||
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.map((a) => a.id), ['modell', 'leistungen', 'ressourcen', 'regeln', 'gastfelder', 'benachrichtigungen'])
|
||||
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.equal(a.kopierbar, false, a.id)
|
||||
assert.ok(a.titel && typeof a.titel === 'string', a.id)
|
||||
assert.ok(a.text && typeof a.text === 'string', a.id)
|
||||
}
|
||||
assert.deepEqual(r.warnungen, [], 'Warnungen kommen mit D3b')
|
||||
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(r.kurz, '', 'die Kurzfassung kommt mit D4')
|
||||
// der Volltext fuehrt jeden Abschnitt mit Ueberschrift
|
||||
assert.deepEqual(zeilen(r.text).filter((z) => z.startsWith('## ')), r.abschnitte.map((a) => `## ${a.titel}`))
|
||||
for (const a of r.abschnitte) assert.ok(r.text.includes(a.text), a.id)
|
||||
// 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', () => {
|
||||
@@ -587,7 +589,10 @@ test('D3a Weissliste: Zusatzfelder an Betrieb, Ressource, Leistung, Regeln und K
|
||||
kunde: { name: 'Salon Muster GbR', email: GEHEIM, geheim: GEHEIM },
|
||||
fragebogen: { geheim: GEHEIM },
|
||||
}))
|
||||
for (const verboten of [GEHEIM, 'NOTIZ-INTERN', 'BESCHREIBUNG-NUR-FUER-GAESTE', BETRIEB.email]) assert.ok(!json.includes(verboten), verboten)
|
||||
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', () => {
|
||||
@@ -597,7 +602,7 @@ test('D3a Kundentexte stehen unveraendert als Text und koennen keine Abschnitts-
|
||||
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, 6, r.text)
|
||||
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)
|
||||
@@ -645,10 +650,9 @@ test('D3a ein anderes now aendert nur die Erzeugt-am-Zeile und die Filterung ver
|
||||
assert.ok(abschnitt(nachtgrenze, 'modell').text.includes('Erzeugt am 06.10.2026'))
|
||||
})
|
||||
|
||||
test('D3a ohne Einstellung wirft nichts und liefert keine Abschnitte; ein ungueltiges now wirft', () => {
|
||||
test('D3a ohne Einstellung wirft nichts; ein ungueltiges now wirft', () => {
|
||||
const r = erzeuge({ einstellung: null, version: 0, updatedAt: null })
|
||||
assert.deepEqual(r.abschnitte, [])
|
||||
assert.equal(r.text, '')
|
||||
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))
|
||||
@@ -684,3 +688,448 @@ test('D3a Kuerzen: lange Abwesenheits- und Sonderoeffnungslisten enden mit und N
|
||||
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 <b>12</b>.</p>'), snippet)
|
||||
assert.ok(snippet.includes('data-webklar-termine="a"><script>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('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('liegt die Datenschutzerklärung nicht in unserem Repo: Absatz per Chat an den Kunden senden'), 'Zwischenregel bis Block F')
|
||||
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.equal(r.kurz, '')
|
||||
|
||||
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))
|
||||
})
|
||||
Reference in new issue
Block a user