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

754 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { existsSync, readFileSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { config } from '../server/config.js'
import {
RECHTSTEXTE_VERSION,
RECHTSTEXTE_ENTWURF,
WEBKLAR_KONTAKT,
widerrufsbelehrung,
widerrufsbelehrungFunktion,
formularHinweis,
datenschutzAbsatz,
ZUSTIMMUNG_VORZEITIG,
ZUSTIMMUNG_STORNO,
ZUSTIMMUNG_ERINNERUNG,
GESUNDHEIT_HINWEIS,
} from '../server/services/termine/rechtstexte.js'
// Die Belehrung wird gegen den Wortlaut der Anlagen 1 und 2 zu Art. 246a EGBGB geprueft (docs/rechtliches/quellen/,
// abgerufen 2026-10-02): jeder Absatz des Musters muss unveraendert im Ergebnis stehen, nur die Luecken sind gefuellt.
const KOPF = 'ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen'
const lies = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8')
const zeilen = (text) => text.split('\n').map((z) => z.trim())
// Wortlisten fuer die Anredepruefung: Du-Woerter duerfen in der Sie-Fassung nicht stehen und umgekehrt
const DU_WOERTER = /\bdein|\bdu\b|\bdir\b|\bdich\b/i
const SIE_WOERTER = /\bIhr|\bIhnen\b|\bSie (haben|können|erhalten|wünschen|machen|buchen|klicken|sind|wollen)\b/
const BETRIEB = { name: 'Salon Müller & Söhne', adresse: 'Hauptstraße 1, 67722 Winnweiler', telefon: '06301 1234', email: 'info@salon-mueller.de' }
const KONTAKT = 'Salon Müller & Söhne, Hauptstraße 1, 67722 Winnweiler, Telefon: 06301 1234, E-Mail: info@salon-mueller.de'
// Platzhalter-Betrieb: so steht die Belehrung in docs/rechtliches/termine-widerrufsbelehrung-muster.md
const PLATZHALTER = { name: '{{Name des Betriebs}}', adresse: '{{Anschrift}}', telefon: '{{Telefon}}', email: '{{E-Mail}}' }
const ANLAGE_1 = lies('docs/rechtliches/quellen/anlage-1.txt')
const ANLAGE_2 = lies('docs/rechtliches/quellen/anlage-2.txt')
/** Muster-Absaetze der Anlage 1 (zwischen Titel und Gestaltungshinweisen) mit den Luecken des Gesetzestexts. */
function musterZeilen() {
const z = zeilen(ANLAGE_1)
const von = z.indexOf('Widerrufsbelehrung')
const bis = z.findIndex((s) => s.startsWith('Gestaltungshinweise:'))
assert.ok(von >= 0 && bis > von, 'Aufbau von anlage-1.txt hat sich geaendert')
return z.slice(von, bis).filter(Boolean)
}
/** Textbaustein a) fuer Dienstleistungsvertraege: „des Vertragsabschlusses.“ */
function fristBaustein() {
const m = /Dienstleistungsvertrags[^„]*„([^“]+)“/.exec(ANLAGE_1)
assert.ok(m, 'Gestaltungshinweis a) fehlt in anlage-1.txt')
return m[1]
}
const belehrung = (betrieb = BETRIEB) => widerrufsbelehrung(betrieb)
test('version_gleich_register: RECHTSTEXTE_VERSION ist das Fassungsdatum des Registers und nicht mehr die Altfassung 2026-10-02', () => {
assert.equal(RECHTSTEXTE_VERSION, config.termine.avvVersion)
assert.notEqual(RECHTSTEXTE_VERSION, '2026-10-02')
assert.match(RECHTSTEXTE_VERSION, /^\d{4}-\d{2}-\d{2}$/)
assert.ok(RECHTSTEXTE_VERSION > '2026-10-02', 'die Fassung liegt nach der eingefrorenen Altfassung')
})
// ---------------------------------------------------------------- Widerrufsbelehrung
test('Belehrung: alle Muster-Absaetze der Anlage 1 stehen wörtlich darin, nur die Lücken sind gefüllt', () => {
const text = belehrung()
const muster = musterZeilen()
assert.equal(muster.length, 8, 'Anlage 1: erwartet Titel, Widerrufsrecht, 3 Absätze, Folgen des Widerrufs und 1 Absatz')
assert.equal(fristBaustein(), 'des Vertragsabschlusses.')
for (const zeile of muster) {
const erwartet = zeile
.replace('ab dem Tag .', `ab dem Tag ${fristBaustein()}`)
.replace('uns ( )', `uns (${KONTAKT})`)
assert.ok(text.includes(erwartet), `Absatz fehlt oder ist verändert: ${erwartet}`)
}
})
test('Belehrung: Satzrahmen der Widerrufsfunktion wörtlich aus der Anlage, nur die Lücke „…“ ist mit einem geeigneten Hinweis gefüllt', () => {
const text = belehrung()
// Rahmen und Gesetzesteil kommen aus anlage-1.txt, nichts davon ist hier abgeschrieben:
// „Sie können Ihr Widerrufsrecht auch online unter … [Internetadresse oder anderen geeigneten Hinweis …] ausüben. Wenn Sie diese Online-Funktion nutzen, …“
const m = /„(Sie können Ihr Widerrufsrecht auch online unter) … \[[^\]]+\] (ausüben\.) (Wenn Sie diese Online-Funktion nutzen, [^“]+?)\s*“/.exec(ANLAGE_1)
assert.ok(m, 'Gestaltungshinweis zur Widerrufsfunktion fehlt oder hat sich geändert in anlage-1.txt')
assert.ok(m[3].includes('Eingangsbestätigung') && m[3].includes('Uhrzeit ihres Eingangs'))
const hinweis = '„Vertrag widerrufen“ (Schaltfläche im Terminbereich auf unserer Website sowie Link „Termin verwalten“ in Ihrer Bestätigungs-E-Mail)'
const absatz = `${m[1]} ${hinweis} ${m[2]} ${m[3]}`
assert.ok(text.includes(absatz), 'Absatz zur Widerrufsfunktion fehlt oder weicht vom Satzrahmen der Anlage ab')
assert.ok(text.includes('Vertrag widerrufen') && text.includes('Termin verwalten'))
assert.ok(!/https?:\/\/|www\./.test(text), 'keine Internetadresse, kein Parameter')
assert.ok(!text.includes('über die Schaltfläche'), 'kein umformulierter Satzrahmen')
})
test('Belehrung: Wertersatz für Dienstleistungen, Unzutreffendes (Wasser/Gas/Strom/Fernwärme) gestrichen', () => {
const text = belehrung()
const m = /Haben Sie verlangt, dass die Dienstleistungen oder Lieferung von Wasser\/Gas\/Strom\/Fernwärme \[Unzutreffendes streichen\] (während[^“]+?)\s*“/.exec(ANLAGE_1)
assert.ok(m, 'Wertersatz-Absatz fehlt in anlage-1.txt')
assert.ok(text.includes(`Haben Sie verlangt, dass die Dienstleistungen ${m[1]}`), 'Wertersatz-Absatz fehlt oder weicht ab')
assert.ok(!/Wasser|Strom|Fernwärme|\[Unzutreffendes/.test(text))
})
test('Belehrung: Muster-Widerrufsformular der Anlage 2 steht wörtlich darin, Betrieb in der „An“-Zeile', () => {
const text = belehrung()
const z = zeilen(ANLAGE_2)
const start = z.indexOf('Muster-Widerrufsformular')
assert.ok(start >= 0, 'Aufbau von anlage-2.txt hat sich geaendert')
const inhalt = z.slice(start).filter((s) => s && s !== '–')
assert.ok(inhalt.length >= 9)
let anZeile = 0
for (const zeile of inhalt) {
if (zeile.startsWith('An [hier ist')) {
anZeile += 1
assert.ok(text.includes('An Salon Müller & Söhne, Hauptstraße 1, 67722 Winnweiler, info@salon-mueller.de:'), '„An“-Zeile mit Betrieb fehlt')
} else {
assert.ok(text.includes(zeile), `Formularzeile fehlt oder ist verändert: ${zeile}`)
}
}
assert.equal(anZeile, 1)
// die (*)-Alternativen bleiben unverändert stehen
assert.ok(text.includes('Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*)'))
assert.ok(text.includes('(*) Unzutreffendes streichen.'))
})
test('Belehrung: Betriebsdaten, kein offener Platzhalter, nichts zu Waren-Rücksendung oder Webformular', () => {
const text = belehrung()
for (const wert of Object.values(BETRIEB)) assert.ok(text.includes(wert), `Betriebsangabe fehlt: ${wert}`)
assert.ok(text.includes('Vertrag widerrufen'))
assert.ok(text.includes('Termin verwalten'))
assert.ok(text.includes('vierzehn Tagen'))
assert.ok(!/\{\{|\}\}|\[|\]|…|\.\.\.|\(\s*\)/.test(text), 'offener Platzhalter')
assert.ok(!/Rücksendung|zurückzusenden|Besitz genommen|Wir holen die Waren|Sie haben die Waren|elektronisch ausfüllen|Wir können die Rückzahlung verweigern/.test(text))
assert.ok(!/undefined|null|NaN/.test(text))
})
test('Belehrung: reiner Text mit Zeilenumbrüchen, nicht HTML-maskiert (die Mail maskiert beim Einsetzen)', () => {
const text = belehrung()
assert.equal(typeof text, 'string')
assert.ok(text.includes('\n'))
assert.ok(text.startsWith('Widerrufsbelehrung\n'))
assert.ok(text.includes('Salon Müller & Söhne'))
assert.ok(!text.includes('&amp;') && !/<[a-z/]/i.test(text))
assert.ok(!text.includes('\r'))
assert.equal(belehrung(), belehrung(), 'deterministisch')
})
test('Belehrung: Name, Anschrift, Telefon und E-Mail sind Pflicht, eine unvollständige Belehrung entsteht nie (TypeError)', () => {
for (const feld of ['name', 'adresse', 'telefon', 'email']) {
for (const leer of [undefined, null, '', ' ', ' \n\t ', 42, {}]) {
assert.throws(() => widerrufsbelehrung({ ...BETRIEB, [feld]: leer }), TypeError, `${feld}=${JSON.stringify(leer)}`)
}
const ohne = { ...BETRIEB }
delete ohne[feld]
assert.throws(() => widerrufsbelehrung(ohne), TypeError, `${feld} fehlt`)
}
assert.throws(() => widerrufsbelehrung({ name: 'Salon Müller', email: 'info@salon.de' }), TypeError)
assert.throws(() => widerrufsbelehrung(undefined), TypeError)
assert.throws(() => widerrufsbelehrung(null), TypeError)
assert.throws(() => widerrufsbelehrung('Salon'), TypeError)
})
test('Belehrung: Leerraum in den Angaben wird zu einem Leerzeichen, vollständige Angaben stehen in Klammer und „An“-Zeile', () => {
const text = belehrung({ ...BETRIEB, name: ' Salon \n Müller ', adresse: 'Hauptstraße 1,\n67722 Winnweiler' })
assert.ok(text.includes('uns (Salon Müller, Hauptstraße 1, 67722 Winnweiler, Telefon: 06301 1234, E-Mail: info@salon-mueller.de)'))
assert.ok(text.includes('An Salon Müller, Hauptstraße 1, 67722 Winnweiler, info@salon-mueller.de:'))
})
test('Belehrung ist die Quelle von docs/rechtliches/termine-widerrufsbelehrung-muster.md (Platzhalter-Fassung steht dort wörtlich)', () => {
const md = lies('docs/rechtliches/termine-widerrufsbelehrung-muster.md')
assert.ok(md.replace(/\r/g, '').includes(widerrufsbelehrung(PLATZHALTER)), 'Textblock im Muster-Dokument weicht von widerrufsbelehrung() ab')
})
// ---------------------------------------------------------------- Zustimmungen und Hinweise
test('Zustimmungstexte wortgleich zu den Vorgaben (Sie-Form)', () => {
assert.equal(
ZUSTIMMUNG_VORZEITIG('Salon Müller'),
'Ich verlange ausdrücklich, dass Salon Müller mit der Leistung vor Ablauf der Widerrufsfrist beginnt. '
+ 'Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Leistung erlischt (§ 356 Abs. 5 Nr. 2 BGB). '
+ 'Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).',
)
assert.equal(ZUSTIMMUNG_STORNO, 'Ich habe die Stornobedingungen gelesen.')
assert.equal(ZUSTIMMUNG_ERINNERUNG(24), 'Ich möchte 24 Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen.')
assert.equal(ZUSTIMMUNG_ERINNERUNG(2), 'Ich möchte 2 Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen.')
assert.equal(GESUNDHEIT_HINWEIS, 'Bitte keine Gesundheitsangaben.')
})
test('Zustimmung zum vorzeitigen Beginn setzt den Betriebsnamen unverändert ein (kein Ersetzungsmuster)', () => {
const text = ZUSTIMMUNG_VORZEITIG("Frisör $& $1 'Anna'")
assert.ok(text.startsWith("Ich verlange ausdrücklich, dass Frisör $& $1 'Anna' mit der Leistung vor Ablauf"))
})
test('erinnerung_widerrufssatz: Haken-Text, Hinweise-Dokument wörtlich gleich; der Haken trägt den Widerrufssatz (Art. 7 Abs. 3 DSGVO)', () => {
const satz = (n) => `Ich möchte ${n} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen.`
for (const n of [2, 12, 24, 48]) assert.equal(ZUSTIMMUNG_ERINNERUNG(n), satz(n))
const md = lies('docs/rechtliches/termine-hinweise.md').replace(/\r/g, '')
assert.ok(md.includes(satz('{{n}}')), 'Hinweise-Dokument: Haken Erinnerung weicht ab')
assert.ok(md.includes(ZUSTIMMUNG_ERINNERUNG('{{n}}')))
assert.ok(!md.includes('Erinnerung per E-Mail erhalten. |'), 'der alte Haken ohne Widerrufssatz steht nicht mehr im Dokument')
assert.equal(ZUSTIMMUNG_ERINNERUNG(24).split('\n').length, 1, 'ein Satz im Haken, keine Umbrüche')
})
test('dokumente_nennen_fassung: die drei Gäste-Dokumente tragen die Fassung der Gästetexte', () => {
for (const rel of ['docs/rechtliches/termine-hinweise.md', 'docs/rechtliches/termine-datenschutz-absatz.md', 'docs/rechtliches/termine-widerrufsbelehrung-muster.md']) {
assert.ok(lies(rel).includes(`Fassung ${RECHTSTEXTE_VERSION}.`), `${rel} nennt nicht „Fassung ${RECHTSTEXTE_VERSION}.“`)
assert.ok(!lies(rel).includes('Fassung 2026-10-02.'), `${rel} trägt noch die Altfassung`)
}
})
const EINSTELLUNG = { anrede: 'sie', aufbewahrungTage: 90, betrieb: BETRIEB }
test('Formular-Hinweis (Sie): Zweck, Auftragsverarbeiter, Löschfristen, keine Cookies, Gesundheit, Verantwortlicher', () => {
const text = formularHinweis(EINSTELLUNG)
assert.ok(text.startsWith('Datenschutz. Wir verwenden Ihre Angaben, um Ihren Termin zu verwalten und zu bestätigen (Art. 6 Abs. 1 lit. b DSGVO).'))
assert.ok(text.includes('in unserem Auftrag von der WEBklar GbR (Server: Hetzner, Deutschland)'))
assert.ok(text.includes('Wir speichern Ihre Daten bis 90 Tage nach dem Termin, abgesagte Termine 30 Tage, nicht bestätigte Buchungen 48 Stunden nach Ablauf der Bestätigungsfrist.'))
assert.ok(text.includes('Es werden keine Cookies gesetzt.'))
assert.ok(text.includes('Bitte machen Sie keine Angaben zu Ihrer Gesundheit.'))
assert.ok(text.includes('Mehr erfahren Sie in unserer Datenschutzerklärung.'))
assert.ok(text.endsWith('Verantwortlich: Salon Müller & Söhne, Hauptstraße 1, 67722 Winnweiler.'))
assert.ok(!/\{\{|\}\}|\[|\]|undefined|null/.test(text))
assert.ok(!text.includes('info@salon-mueller.de'), 'Betriebs-E-Mail gehört nicht in die öffentliche Konfiguration')
assert.ok(!/\bdein|\bdu\b/i.test(text))
})
test('Formular-Hinweis (Du): gleiche Aussagen in Du-Form', () => {
const text = formularHinweis({ ...EINSTELLUNG, anrede: 'du', aufbewahrungTage: 30 })
assert.ok(text.startsWith('Datenschutz. Wir verwenden deine Angaben, um deinen Termin zu verwalten und zu bestätigen (Art. 6 Abs. 1 lit. b DSGVO).'))
assert.ok(text.includes('Wir speichern deine Daten bis 30 Tage nach dem Termin, abgesagte Termine 30 Tage, nicht bestätigte Buchungen 48 Stunden nach Ablauf der Bestätigungsfrist.'))
assert.ok(text.includes('Bitte mach keine Angaben zu deiner Gesundheit.'))
assert.ok(text.includes('Mehr erfährst du in unserer Datenschutzerklärung.'))
assert.ok(text.includes('Es werden keine Cookies gesetzt.'))
assert.ok(!/\bSie\b|\bIhre|\bIhren/.test(text))
})
test('Formular-Hinweis: Sie ist die Vorgabe, Aufbewahrung 90 Tage ohne gültigen Wert, Adresse optional', () => {
const text = formularHinweis({ betrieb: { name: 'Salon Müller', email: 'info@salon.de' } })
assert.ok(text.includes('Wir speichern Ihre Daten bis 90 Tage nach dem Termin'))
assert.ok(text.endsWith('Verantwortlich: Salon Müller.'))
assert.ok(formularHinweis({ ...EINSTELLUNG, aufbewahrungTage: 'viel' }).includes('bis 90 Tage'))
assert.ok(formularHinweis({ ...EINSTELLUNG, aufbewahrungTage: 365 }).includes('bis 365 Tage'))
assert.ok(formularHinweis({ ...EINSTELLUNG, anrede: 'unbekannt' }).includes('Ihre Angaben'))
assert.throws(() => formularHinweis({}), TypeError)
assert.throws(() => formularHinweis(undefined), TypeError)
})
// ---------------------------------------------------------------- Datenschutz-Absatz „Online-Terminbuchung“ (Quelle der Einbauanleitung und der Doku)
test('D1 (f): die Rechtstexte gelten nicht mehr als Entwurf (RECHTSTEXTE_ENTWURF === false)', () => {
assert.equal(RECHTSTEXTE_ENTWURF, false)
})
const DS_SCHALTER = ['telefonFeld', 'freitext', 'erinnerung', 'widerruf', 'telefon']
// Bedingungstext der Quelle ([[wenn …: …]]) -> Schalter; eine unbekannte Bedingung in der Quelle lässt den Test scheitern
const DS_BEDINGUNGEN = [
[/^Telefon-Feld aktiv$/, 'telefonFeld'],
[/^Nachricht oder Zusatzfelder aktiv$/, 'freitext'],
[/^Erinnerung angeboten$/, 'erinnerung'],
[/^Gäste Verbraucher sein können$/, 'widerruf'],
[/^Telefon hinterlegt$/, 'telefon'],
]
// Satz bzw. Satzteil, den jeder Schalter einschaltet (Sie- und Du-Fassung)
const DS_MARKER = {
telefonFeld: /Telefonnummer/,
freitext: /im Formular mitteil/,
erinnerung: /Erinnerung/,
widerruf: /Vertrag widerrufen/,
telefon: /telefonisch erreichen/,
}
const DS_ALLE_AN = Object.fromEntries(DS_SCHALTER.map((k) => [k, true]))
const DS_ALLE_AUS = Object.fromEntries(DS_SCHALTER.map((k) => [k, false]))
/** Echte, normalisierte Einstellung; jeder Schalter steuert genau die Einstellung, aus der die Funktion ihn ableitet. */
function dsEinstellung(zustand, extra = {}) {
return {
anrede: 'sie',
aufbewahrungTage: 90,
betrieb: { ...BETRIEB, telefon: zustand.telefon ? BETRIEB.telefon : '' },
gastFelder: { telefon: zustand.telefonFeld ? 'optional' : 'aus', nachricht: zustand.freitext ? 'optional' : 'aus', zusatz: [] },
erinnerung: { anbieten: zustand.erinnerung, stundenVorher: 24 },
recht: { gaesteSind: zustand.widerruf ? 'verbraucher' : 'unternehmer', freizeit: false },
ressourcen: [{ id: 'r1', aktiv: true }],
leistungen: [{ id: 'l1', aktiv: true, ressourcen: ['r1'] }],
...extra,
}
}
/** Absätze durch Leerzeile, innerhalb eines Absatzes jeder Leerraum ein Leerzeichen. */
const normalisiere = (text) => text.split(/\n[ \t]*\n/).map((a) => a.replace(/\s+/g, ' ').trim()).filter(Boolean).join('\n\n')
/**
* Datenschutz-Absatz aus der Markdown-Quelle: Blockquote der Fassung („Sie“ | „Du“), `> `, `**` und `###` entfernt,
* [[wenn …: X]] durch X (Schalter an) oder nichts (Schalter aus) ersetzt, Platzhalter mit den Testwerten gefüllt.
* Ein Satzteil, der mit einem Wort beginnt („und die Angaben …“), trägt sein Leerzeichen selbst in der Klammer (zwei Leerzeichen
* nach dem Doppelpunkt); die Ersetzung fügt nichts hinzu.
*/
function datenschutzQuelle(fassung, zustand, { tage = 90 } = {}) {
const md = lies('docs/rechtliches/termine-datenschutz-absatz.md').replace(/\r/g, '')
const teil = md.split(/^## Fassung /m).slice(1).find((t) => t.startsWith(fassung))
assert.ok(teil, `Fassung ${fassung} fehlt in der Quelle`)
const zitat = teil.split('\n').filter((z) => z.startsWith('>')).map((z) => z.replace(/^>\s?/, '').replace(/^### /, ''))
assert.ok(zitat.length > 10, 'Blockquote der Quelle nicht gefunden')
const roh = zitat.join('\n').replace(/\*\*/g, '')
const bedingt = roh.replace(/\[\[wenn ([^:]+): ([\s\S]*?)\]\]/g, (_, bedingung, satz) => {
const eintrag = DS_BEDINGUNGEN.find(([muster]) => muster.test(bedingung))
assert.ok(eintrag, `unbekannte Bedingung in der Quelle: ${bedingung}`)
return zustand[eintrag[1]] ? satz : ''
})
const werte = { Firma: BETRIEB.name, Anschrift: BETRIEB.adresse, 'E-Mail': BETRIEB.email, Telefon: BETRIEB.telefon, Tage: String(tage) }
const gefuellt = bedingt.replace(/\{\{([^}]+)\}\}/g, (_, name) => {
assert.ok(Object.hasOwn(werte, name), `unbekannter Platzhalter in der Quelle: ${name}`)
return werte[name]
})
assert.ok(!/\[\[|\]\]|\{\{|\}\}/.test(gefuellt), 'offene Klammer nach dem Ersetzen')
return normalisiere(gefuellt)
}
const alleZustaende = () =>
Array.from({ length: 2 ** DS_SCHALTER.length }, (_, maske) => Object.fromEntries(DS_SCHALTER.map((k, i) => [k, Boolean(maske & (1 << i))])))
test('Datenschutz-Absatz: Wortlaut entspricht der Markdown-Quelle, beide Anreden, alle 32 Bedingungskombinationen', () => {
for (const [fassung, anrede] of [['Sie', 'sie'], ['Du', 'du']]) {
for (const zustand of alleZustaende()) {
assert.equal(
normalisiere(datenschutzAbsatz(dsEinstellung(zustand), anrede)),
datenschutzQuelle(fassung, zustand),
`${fassung} ${JSON.stringify(zustand)}`,
)
}
}
})
test('Datenschutz-Absatz: Ausgabe ist schon normalisiert (Absätze durch Leerzeile, keine doppelten Leerzeichen, kein Platzhalterrest)', () => {
for (const anrede of ['sie', 'du']) {
for (const zustand of alleZustaende()) {
const text = datenschutzAbsatz(dsEinstellung(zustand), anrede)
assert.equal(text, normalisiere(text), `${anrede} ${JSON.stringify(zustand)}`)
assert.ok(!/ {2}| [,.;:]|\[\[|\]\]|\{\{|\}\}|undefined|null|NaN/.test(text), `${anrede} ${JSON.stringify(zustand)}`)
assert.ok(text.startsWith('Online-Terminbuchung\n\n'))
assert.equal(text.split('\n\n')[0], 'Online-Terminbuchung')
}
}
})
test('Datenschutz-Absatz: gleiche Quelle auch mit anderer Aufbewahrung (Pin mit 365 Tagen)', () => {
const einstellung = dsEinstellung(DS_ALLE_AN, { aufbewahrungTage: 365 })
assert.equal(normalisiere(datenschutzAbsatz(einstellung, 'sie')), datenschutzQuelle('Sie', DS_ALLE_AN, { tage: 365 }))
assert.equal(normalisiere(datenschutzAbsatz(einstellung, 'du')), datenschutzQuelle('Du', DS_ALLE_AN, { tage: 365 }))
})
test('Datenschutz-Absatz: jede Bedingung schaltet genau ihren Satz', () => {
for (const anrede of ['sie', 'du']) {
for (const schalter of DS_SCHALTER) {
const aus = datenschutzAbsatz(dsEinstellung({ ...DS_ALLE_AN, [schalter]: false }), anrede)
const an = datenschutzAbsatz(dsEinstellung({ ...DS_ALLE_AUS, [schalter]: true }), anrede)
for (const [name, muster] of Object.entries(DS_MARKER)) {
assert.equal(muster.test(aus), name !== schalter, `${anrede}: ${schalter} aus, Marker ${name}`)
assert.equal(muster.test(an), name === schalter, `${anrede}: nur ${schalter} an, Marker ${name}`)
}
}
}
})
test('Datenschutz-Absatz: die Bedingungen lesen die Einstellung wie das Widget', () => {
const alleAn = dsEinstellung(DS_ALLE_AN)
const text = (e, anrede = 'sie') => datenschutzAbsatz(e, anrede)
// Telefon-Feld: nur „aus“ blendet aus, optional und Pflicht zählen
assert.ok(DS_MARKER.telefonFeld.test(text({ ...alleAn, gastFelder: { telefon: 'pflicht', nachricht: 'aus', zusatz: [] } })))
assert.ok(!DS_MARKER.telefonFeld.test(text({ ...alleAn, gastFelder: { telefon: 'aus', nachricht: 'aus', zusatz: [] } })))
// Nachricht oder Zusatzfelder: eines von beiden genügt
const aus = { telefon: 'aus', nachricht: 'aus' }
assert.ok(!DS_MARKER.freitext.test(text({ ...alleAn, gastFelder: { ...aus, zusatz: [] } })))
assert.ok(DS_MARKER.freitext.test(text({ ...alleAn, gastFelder: { ...aus, nachricht: 'pflicht', zusatz: [] } })))
assert.ok(DS_MARKER.freitext.test(text({ ...alleAn, gastFelder: { ...aus, zusatz: [{ key: 'wunsch', label: 'Wunsch', typ: 'text', optionen: [], pflicht: false }] } })))
// Widerruf: genau die Bedingung des Widerrufsknopfs (Verbraucher-Gäste und mindestens eine sichtbare Leistung)
assert.ok(DS_MARKER.widerruf.test(text({ ...alleAn, recht: { gaesteSind: 'gemischt', freizeit: false } })))
assert.ok(!DS_MARKER.widerruf.test(text({ ...alleAn, recht: { gaesteSind: 'unternehmer', freizeit: false } })))
assert.ok(!DS_MARKER.widerruf.test(text({ ...alleAn, recht: { gaesteSind: 'verbraucher', freizeit: true } })))
assert.ok(!DS_MARKER.widerruf.test(text({ ...alleAn, leistungen: [{ id: 'l1', aktiv: false, ressourcen: ['r1'] }] })))
assert.ok(!DS_MARKER.widerruf.test(text({ ...alleAn, ressourcen: [{ id: 'r1', aktiv: false }] })))
// Telefon des Betriebs: Leerraum zählt als nicht hinterlegt
assert.ok(!DS_MARKER.telefon.test(text({ ...alleAn, betrieb: { ...BETRIEB, telefon: ' ' } })))
assert.ok(text({ ...alleAn, betrieb: { ...BETRIEB, telefon: ' 06301\n1234 ' } }).includes('telefonisch erreichen: 06301 1234.'))
})
const ABO_ENDE_SATZ = 'Wird die Online-Terminbuchung abgeschaltet, zum Beispiel weil unser Vertrag mit WEBklar endet, werden alle Termindaten 30 Tage nach dem Abschalten gelöscht.'
test('absatz_abo_ende: Funktion und Markdown (Sie und Du) nennen den Abo-Ende-Satz mit „30 Tage nach dem Abschalten“; der alte Satz ist weg', () => {
const md = lies('docs/rechtliches/termine-datenschutz-absatz.md').replace(/\r/g, '')
const fassungen = md.split(/^## Fassung /m).slice(1)
assert.equal(fassungen.length, 2)
for (const f of fassungen) {
assert.ok(normalisiere(f.replace(/^> ?/gm, '')).includes(ABO_ENDE_SATZ), 'Markdown: Abo-Ende-Satz fehlt')
assert.ok(f.includes('30 Tage nach dem Abschalten'))
assert.ok(!f.includes('löschen wir alle Termindaten 30 Tage'), 'Markdown: alter Satz steht noch')
}
for (const anrede of ['sie', 'du']) {
for (const zustand of [DS_ALLE_AN, DS_ALLE_AUS]) {
const text = datenschutzAbsatz(dsEinstellung(zustand), anrede)
assert.equal(text.split(ABO_ENDE_SATZ).length, 2, `${anrede}: Satz genau einmal`)
assert.ok(text.includes('30 Tage nach dem Abschalten'))
assert.ok(!text.includes('löschen wir alle Termindaten'), `${anrede}: alter Satz steht noch`)
}
}
})
test('haltezeit_aus_config: „der Bestätigungslink gilt N Minuten“ folgt config.termine.holdMinutes; die Markdown-Quelle nennt denselben Wert', () => {
const md = lies('docs/rechtliches/termine-datenschutz-absatz.md')
for (const anrede of ['sie', 'du']) {
assert.ok(datenschutzAbsatz(dsEinstellung(DS_ALLE_AN), anrede).includes(`(der Bestätigungslink gilt ${config.termine.holdMinutes} Minuten)`))
}
assert.equal((md.match(new RegExp(`Bestätigungslink gilt ${config.termine.holdMinutes} Minuten`, 'g')) || []).length, 2, 'die Quelle nennt beide Fassungen mit dem Wert aus config')
const vorher = config.termine.holdMinutes
try {
config.termine.holdMinutes = 45
for (const anrede of ['sie', 'du']) {
const text = datenschutzAbsatz(dsEinstellung(DS_ALLE_AN), anrede)
assert.ok(text.includes('(der Bestätigungslink gilt 45 Minuten)'), anrede)
assert.ok(!text.includes('30 Minuten'), `${anrede}: kein fester Wert mehr im Text`)
}
} finally {
config.termine.holdMinutes = vorher
}
})
test('quelle_satzteile_mit_leerzeichen: ein Satzteil der Quelle, der mit einem Wort beginnt, trägt sein Leerzeichen in der Klammer', () => {
const md = lies('docs/rechtliches/termine-datenschutz-absatz.md').replace(/\r/g, '')
let mitLeerzeichen = 0
for (const m of md.matchAll(/\[\[wenn ([^:]+): ([\s\S]*?)\]\]/g)) {
if (m[1] === '…') continue // die Erklärung der Schreibweise im Kopf der Quelle
const davor = md[m.index - 1]
if (/^[A-Za-zÄÖÜäöüß]/.test(m[2])) {
// Satzteil beginnt mit einem Wort: nur nach Leerzeichen erlaubt (Satzanfang), sonst klebte er am Vordertext („Platzund“)
assert.ok(/\s/.test(davor), `Satzteil „${m[2].slice(0, 30)}…“ hängt ohne Leerzeichen am Vordertext`)
} else if (m[2].startsWith(' ')) {
mitLeerzeichen += 1
assert.ok(/^ [A-Za-zÄÖÜäöüß]/.test(m[2]) && !/\s/.test(davor), `Leerzeichen in der Klammer nur am Anschluss an ein Wort: „${m[2].slice(0, 30)}…“`)
}
}
assert.equal(mitLeerzeichen, 4, 'vier Satzteile mit „und“ tragen ihr Leerzeichen selbst (Sie und Du, je zwei)')
})
test('Datenschutz-Absatz: Aufbewahrung nach dem Termin, ungültiger Wert wird zu 90 Tagen (wie der Formular-Hinweis)', () => {
for (const anrede of ['sie', 'du']) {
const mit = (aufbewahrungTage) => datenschutzAbsatz(dsEinstellung(DS_ALLE_AN, { aufbewahrungTage }), anrede)
assert.ok(mit(365).includes('Bestätigte Termine löschen wir 365 Tage nach dem Termin,'))
assert.ok(mit(14).includes('löschen wir 14 Tage nach dem Termin,'))
for (const ungueltig of ['viel', undefined, null, 14.5, NaN]) {
assert.ok(mit(ungueltig).includes('löschen wir 90 Tage nach dem Termin,'), `${anrede}: ${String(ungueltig)}`)
}
}
})
test('Datenschutz-Absatz: Sie-Fassung ohne Du-Wörter, Du-Fassung ohne Sie-Wörter', () => {
for (const zustand of [DS_ALLE_AN, DS_ALLE_AUS]) {
const sie = datenschutzAbsatz(dsEinstellung(zustand), 'sie')
const du = datenschutzAbsatz(dsEinstellung(zustand), 'du')
assert.ok(!DU_WOERTER.test(sie), 'Du-Form in der Sie-Fassung')
assert.ok(!SIE_WOERTER.test(du), 'Sie-Form in der Du-Fassung')
assert.ok(/Sie|Ihr/.test(sie) && /\bdu\b|\bdein/.test(du), 'Fassungen sind nicht zu unterscheiden')
assert.ok(sie.includes('Auf unserer Website können Sie Termine online buchen.'))
assert.ok(du.includes('Auf unserer Website kannst du Termine online buchen.'))
}
})
test('Datenschutz-Absatz: die Anrede kommt aus dem Parameter, alles außer „du“ ist die Sie-Fassung', () => {
const einstellung = dsEinstellung(DS_ALLE_AN, { anrede: 'du' })
assert.ok(datenschutzAbsatz(einstellung, 'sie').includes('können Sie Termine online buchen'))
assert.ok(datenschutzAbsatz(einstellung, 'du').includes('kannst du Termine online buchen'))
for (const anrede of [undefined, 'unbekannt', 'Du', null]) {
assert.equal(datenschutzAbsatz(einstellung, anrede), datenschutzAbsatz(einstellung, 'sie'), String(anrede))
}
})
test('Datenschutz-Absatz: Name und E-Mail sind Pflicht (TypeError), eine leere Anschrift lässt kein leeres Komma stehen', () => {
const ohne = (feld) => ({ ...dsEinstellung(DS_ALLE_AN), betrieb: { ...BETRIEB, [feld]: undefined } })
assert.throws(() => datenschutzAbsatz(ohne('name'), 'sie'), TypeError)
assert.throws(() => datenschutzAbsatz(ohne('email'), 'du'), TypeError)
assert.throws(() => datenschutzAbsatz({ ...dsEinstellung(DS_ALLE_AN), betrieb: { ...BETRIEB, name: ' \n ' } }, 'sie'), TypeError)
assert.throws(() => datenschutzAbsatz({ ...dsEinstellung(DS_ALLE_AN), betrieb: { ...BETRIEB, email: '' } }, 'sie'), TypeError)
assert.throws(() => datenschutzAbsatz({ ...dsEinstellung(DS_ALLE_AN), betrieb: undefined }, 'sie'), TypeError)
assert.throws(() => datenschutzAbsatz({}, 'sie'), TypeError)
assert.throws(() => datenschutzAbsatz(undefined, 'sie'), TypeError)
for (const adresse of ['', ' ', undefined]) {
for (const anrede of ['sie', 'du']) {
const text = datenschutzAbsatz({ ...dsEinstellung(DS_ALLE_AN), betrieb: { ...BETRIEB, adresse } }, anrede)
assert.ok(text.endsWith('Verantwortlich ist Salon Müller & Söhne, E-Mail info@salon-mueller.de.'), `${anrede}: ${String(adresse)}`)
assert.ok(!/, ,|,,|, E-Mail , /.test(text))
}
}
const voll = datenschutzAbsatz(dsEinstellung(DS_ALLE_AN), 'sie')
assert.ok(voll.endsWith('Verantwortlich ist Salon Müller & Söhne, Hauptstraße 1, 67722 Winnweiler, E-Mail info@salon-mueller.de.'))
})
test('Datenschutz-Absatz: reiner Text, nicht HTML-maskiert, Betriebsangaben unverändert eingesetzt (kein Ersetzungsmuster)', () => {
const betrieb = { name: "Frisör $& $1 <b>'Anna'</b> & Co", adresse: 'Weg 1,\n67722 Winnweiler', telefon: '06301 $` 1234', email: 'a&b@salon.de' }
const text = datenschutzAbsatz({ ...dsEinstellung(DS_ALLE_AN), betrieb }, 'sie')
assert.ok(text.includes("Absender ist Frisör $& $1 <b>'Anna'</b> & Co, Ihre Antworten"))
assert.ok(text.includes("Verantwortlich ist Frisör $& $1 <b>'Anna'</b> & Co, Weg 1, 67722 Winnweiler, E-Mail a&b@salon.de."))
assert.ok(text.includes('telefonisch erreichen: 06301 $` 1234.'))
assert.ok(!text.includes('&amp;') && !text.includes('&lt;'))
})
// ---------------------------------------------------------------- Dokumente
const DOKUMENTE = [
'docs/rechtliches/termine-widerrufsbelehrung-muster.md',
'docs/rechtliches/termine-datenschutz-absatz.md',
'docs/rechtliches/termine-hinweise.md',
'docs/rechtliches/termine-datenschutz-6c.md',
]
// Das Wort ENTWURF als Kennzeichnung; ENTWURF_BANNER und RECHTSTEXTE_ENTWURF (Bezeichner im Code) zählen nicht
const ENTWURF_WORT = /ENTWURF(?![_A-Z])/
test('kein_entwurf_mehr: kein ENTWURF-Kopf in den vier Dokumenten, keiner in den Texten von rechtstexte.js und in der Einbau-Doku; die Konstante steht auf false', () => {
for (const rel of DOKUMENTE) {
const md = lies(rel)
assert.ok(!ENTWURF_WORT.test(md), `${rel}: ENTWURF steht noch im Dokument`)
assert.notEqual(md.split('\n')[0].trim(), KOPF, `${rel}: der Entwurfs-Kopf steht noch in der ersten Zeile`)
assert.ok(md.startsWith('# '), `${rel}: das Dokument beginnt mit der Überschrift`)
}
assert.ok(!existsSync(new URL('../docs/rechtliches/termine-datenschutz-6c-entwurf.md', import.meta.url)), 'die 6c-Datei heißt nicht mehr -entwurf')
const quelle = lies('server/services/termine/rechtstexte.js')
assert.ok(!ENTWURF_WORT.test(quelle.replace(/RECHTSTEXTE_ENTWURF/g, '')), 'rechtstexte.js: ENTWURF in Kopf oder Kommentar (nur der Bezeichner RECHTSTEXTE_ENTWURF ist erlaubt)')
assert.ok(/export const RECHTSTEXTE_ENTWURF = false\b/.test(quelle))
assert.equal(RECHTSTEXTE_ENTWURF, false)
const einbau = lies('docs/funktionen/termine-einbau.md')
assert.ok(!ENTWURF_WORT.test(einbau), 'termine-einbau.md: ENTWURF')
assert.ok(!/nur beim Pilotkunden einsetzen|Solange der Kopf dort steht/.test(einbau), 'termine-einbau.md Abschnitt 6 ohne die Pilot-Einschränkung')
// die Gäste-Dokumente haben keine offene Rechtsprüfungs-Liste mehr (die Fragen stehen im Kanzlei-Paket)
for (const rel of DOKUMENTE.slice(0, 3)) assert.ok(!/^## Für die Rechtsprüfung/m.test(lies(rel)), `${rel}: Liste „Für die Rechtsprüfung offen“ steht noch`)
})
test('Hinweise-Dokument: Formular-Hinweis (Sie/Du) und alle Zustimmungstexte stehen wörtlich darin', () => {
const md = lies('docs/rechtliches/termine-hinweise.md').replace(/\r/g, '')
const platzhalter = { betrieb: { name: '{{Firma}}', adresse: '{{Anschrift}}', email: '{{E-Mail}}' }, aufbewahrungTage: 90 }
assert.ok(md.includes(formularHinweis({ ...platzhalter, anrede: 'sie' })), 'Formular-Hinweis (Sie) weicht ab')
assert.ok(md.includes(formularHinweis({ ...platzhalter, anrede: 'du' })), 'Formular-Hinweis (Du) weicht ab')
assert.ok(md.includes(ZUSTIMMUNG_VORZEITIG('{{Firma}}')))
assert.ok(md.includes(ZUSTIMMUNG_STORNO))
assert.ok(md.includes(ZUSTIMMUNG_ERINNERUNG('{{n}}')))
assert.ok(md.includes(GESUNDHEIT_HINWEIS))
for (const knopf of ['Zahlungspflichtig buchen', 'Termin buchen', 'Termin anfragen', 'Vertrag widerrufen', 'Widerruf bestätigen']) {
assert.ok(md.includes(knopf), `Knopftext fehlt: ${knopf}`)
}
})
test('Datenschutz-Absatz: Löschfristen der Vorgaben, Sie- und Du-Fassung, Auftragsverarbeiter und Gesundheitshinweis', () => {
const md = lies('docs/rechtliches/termine-datenschutz-absatz.md').replace(/\r/g, '')
assert.ok(/Fassung Sie/i.test(md) && /Fassung Du/i.test(md))
assert.ok(md.includes('Online-Terminbuchung'))
// jede Fassung nennt die vier Fristen: nicht bestätigt 48 Stunden, abgesagt 30 Tage, vergangen {{Tage}} Tage, Abschaltung 30 Tage
const fassungen = md.split(/^## Fassung /m).slice(1)
assert.equal(fassungen.length, 2)
for (const f of fassungen) {
assert.ok(f.includes('48 Stunden'), 'Frist nicht bestätigter Buchungen fehlt')
assert.ok(f.includes('{{Tage}} Tage nach dem Termin'), 'Aufbewahrung vergangener Termine fehlt')
assert.ok(/abgesagte[^.]*30 Tage/.test(f), 'Frist abgesagter Termine fehlt')
assert.ok(/abgeschaltet[^.]*30 Tage|30 Tage nach dem Abschalten/.test(f), 'Frist nach Abschaltung fehlt')
assert.ok(f.includes('WEBklar GbR') && f.includes('Art. 28 DSGVO') && f.includes('Hetzner'))
assert.ok(f.includes('Gesundheit'))
assert.ok(f.includes('Art. 6 Abs. 1 lit. b DSGVO') && f.includes('Art. 6 Abs. 1 lit. a DSGVO'))
}
assert.ok(fassungen[0].startsWith('Sie') && fassungen[1].startsWith('Du'))
assert.ok(!DU_WOERTER.test(fassungen[0]), 'Du-Form in der Sie-Fassung')
assert.ok(!SIE_WOERTER.test(fassungen[1]), 'Sie-Form in der Du-Fassung')
})
test('6c-Entwurf: nur als Entwurfsdatei, public/datenschutz.html bleibt unverändert (ohne Abschnitt 6c)', () => {
const md = lies('docs/rechtliches/termine-datenschutz-6c.md')
assert.ok(md.includes('6c. Online-Terminbuchung'))
assert.ok(md.includes('Auftragsverarbeiter'))
assert.ok(/nicht (in|auf) .*(einbauen|veröffentlich)|nicht eingebaut|nicht veröffentlicht/i.test(md))
// TP2 baut 6c ein und entfernt diese Absicherung
const html = lies('public/datenschutz.html')
assert.ok(!/6c\./.test(html) && !html.includes('id="termine"'), '6c darf vor TP2 nicht in public/datenschutz.html stehen')
})
// ---------------------------------------------------------------- Widerrufsbelehrung der Funktionen (WEBklar als Unternehmer, Task F3)
const KONTAKT_WEBKLAR = 'WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, Telefon: 0176 23726355, E-Mail: support@webklar.com'
const AN_ZEILE_WEBKLAR = 'An WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, E-Mail: support@webklar.com:'
const WIDERRUFEN_URL = 'https://project.webklar.com/widerrufen.html'
const funktion = () => widerrufsbelehrungFunktion()
test('Gäste-Belehrung bleibt byte-gleich (Hash der Ausgabe vor Task F3)', () => {
const text = widerrufsbelehrung(BETRIEB)
assert.equal(createHash('sha256').update(text).digest('hex'), 'a4b3549b7b4ba0bdeeca438bc823d07eede5e4aa35e21e335fb5304ff36a1ff6')
assert.equal(text.length, 2983)
})
test('WEBKLAR_KONTAKT: WEBklar GbR als Unternehmer', () => {
assert.deepEqual(WEBKLAR_KONTAKT, {
name: 'WEBklar GbR',
adresse: 'Am Schwimmbad 10, 67722 Winnweiler',
telefon: '0176 23726355',
email: 'support@webklar.com',
})
})
test('belehrung_funktion_muster_woertlich: jeder Absatz der Anlage 1 für Dienstleistungen steht wörtlich darin, nur die Lücken sind gefüllt', () => {
const text = funktion()
const muster = musterZeilen()
assert.equal(muster.length, 8)
assert.equal(fristBaustein(), 'des Vertragsabschlusses.')
for (const zeile of muster) {
const erwartet = zeile
.replace('ab dem Tag .', `ab dem Tag ${fristBaustein()}`)
.replace('uns ( )', `uns (${KONTAKT_WEBKLAR})`)
assert.ok(text.includes(erwartet), `Absatz fehlt oder ist verändert: ${erwartet}`)
}
// Fristbeginn und Kontakt stehen genau einmal
assert.equal(text.split('ab dem Tag des Vertragsabschlusses.').length - 1, 1)
assert.equal(text.split(KONTAKT_WEBKLAR).length - 1, 1)
})
test('belehrung_funktion_widerrufsfunktion: Satzrahmen der Anlage 1 wörtlich, die Lücke ist die Adresse der Widerrufsfunktion', () => {
const text = funktion()
const m = /„(Sie können Ihr Widerrufsrecht auch online unter) … \[[^\]]+\] (ausüben\.) (Wenn Sie diese Online-Funktion nutzen, [^“]+?)\s*“/.exec(ANLAGE_1)
assert.ok(m, 'Gestaltungshinweis zur Widerrufsfunktion fehlt oder hat sich geändert in anlage-1.txt')
const absatz = `${m[1]} ${WIDERRUFEN_URL} ${m[2]} ${m[3]}`
assert.ok(text.includes(absatz), 'Absatz zur Widerrufsfunktion fehlt oder weicht vom Satzrahmen der Anlage ab')
assert.equal(text.split(WIDERRUFEN_URL).length - 1, 1)
// kein Hinweis auf eine Schaltfläche der Gäste-Fassung
assert.ok(!text.includes('Termin verwalten') && !text.includes('Schaltfläche'))
// der Wertersatz für Dienstleistungen steht wie in der Gäste-Fassung
const w = /Haben Sie verlangt, dass die Dienstleistungen oder Lieferung von Wasser\/Gas\/Strom\/Fernwärme \[Unzutreffendes streichen\] (während[^“]+?)\s*“/.exec(ANLAGE_1)
assert.ok(w)
assert.ok(text.includes(`Haben Sie verlangt, dass die Dienstleistungen ${w[1]}`))
})
test('belehrung_funktion_formular: Muster-Widerrufsformular der Anlage 2 wörtlich, „An“-Zeile mit WEBklar', () => {
const text = funktion()
const z = zeilen(ANLAGE_2)
const start = z.indexOf('Muster-Widerrufsformular')
assert.ok(start >= 0)
const inhalt = z.slice(start).filter((s) => s && s !== '–')
let anZeile = 0
for (const zeile of inhalt) {
if (zeile.startsWith('An [hier ist')) {
anZeile += 1
assert.ok(text.includes(`– ${AN_ZEILE_WEBKLAR}`), '„An“-Zeile mit WEBklar fehlt')
} else {
assert.ok(text.includes(zeile), `Formularzeile fehlt oder ist verändert: ${zeile}`)
}
}
assert.equal(anZeile, 1)
assert.equal(text.split(AN_ZEILE_WEBKLAR).length - 1, 1)
})
test('Belehrung der Funktionen: reiner Text ohne Platzhalter, deterministisch, Sie-Form, nichts zu Waren', () => {
const text = funktion()
assert.equal(typeof text, 'string')
assert.ok(text.startsWith('Widerrufsbelehrung\n\nWiderrufsrecht\n'))
assert.ok(text.includes('\n') && !text.includes('\r'))
assert.ok(!/\{\{|\}\}|\[|\]|…|\.\.\.|\(\s*\)/.test(text), 'offener Platzhalter')
assert.ok(!/undefined|null|NaN/.test(text))
assert.ok(!/Rücksendung|zurückzusenden|Besitz genommen|Wir holen die Waren|Sie haben die Waren|elektronisch ausfüllen|Wir können die Rückzahlung verweigern/.test(text))
assert.ok(!DU_WOERTER.test(text))
assert.ok(!/&amp;|<[a-z/]/i.test(text))
assert.equal(funktion(), funktion())
// Gäste-Fassung und Funktionen-Fassung unterscheiden sich nur in Kontakt, Widerrufsfunktion und „An“-Zeile
const gast = widerrufsbelehrung(WEBKLAR_KONTAKT)
assert.notEqual(gast, text)
const ohneUnterschied = (t) => t.replace(/uns \([^)]*\)/, 'uns (K)').replace(/Sie können Ihr Widerrufsrecht auch online unter .*? ausüben\./, 'WF').replace(/– An [^\n]*/, '– An A')
assert.equal(ohneUnterschied(gast), ohneUnterschied(text))
})
/** Text eines HTML-Ausschnitts: Tags weg, Entitäten der Seite aufgelöst, Leerraum zu einem Leerzeichen. */
const klartext = (html) =>
html
.replace(/<[^>]+>/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&nbsp;/g, ' ')
.replace(/\s+/g, ' ')
.trim()
test('belehrung_gleich_abo: Belehrung der Funktionen = Belehrung in den Abo-Bedingungen (Drift-Wächter), Muster-Formular ebenso', () => {
const html = lies('public/abo-bedingungen.html')
const von = html.indexOf('<h3>Widerrufsrecht</h3>')
const bis = html.indexOf('Ende der Widerrufsbelehrung')
assert.ok(von > 0 && bis > von, 'Aufbau der Abo-Bedingungen hat sich geändert')
// Die Kontaktklammer wird vorher in beiden durch einen Platzhalter ersetzt: die Gäste-Bausteine setzen „Telefon: …, E-Mail: …“ mit
// Doppelpunkt, die Abo-Bedingungen ohne
const klammer = (t) => t.replace(/uns \([^)]*\)/, 'uns (KONTAKT)')
const abo = klammer(klartext(html.slice(von, bis)))
const text = funktion()
const formularKopf = text.indexOf('\n\nMuster-Widerrufsformular\n\n')
assert.ok(formularKopf > 0)
const textBelehrung = text.slice(text.indexOf('Widerrufsrecht'), formularKopf)
assert.equal(klammer(klartext(textBelehrung)), abo)
// Muster-Widerrufsformular: Kasten der Abo-Bedingungen gegen die Formularzeilen, „An“-Zeile vorher ersetzt
const formVon = html.indexOf('<h3 id="formular">Muster-Widerrufsformular</h3>')
const formBis = html.indexOf('<h2 id="widerruf-hinweise">')
assert.ok(formVon > bis && formBis > formVon)
const an = (t) => t.replace(/– An .*?:(?= – Hiermit)/, '– An ANSCHRIFT:')
const aboForm = an(klartext(html.slice(formVon, formBis)).replace(/^Muster-Widerrufsformular /, ''))
const textForm = an(klartext(text.slice(formularKopf + 2).replace(/^Muster-Widerrufsformular\n+/, '')))
assert.equal(textForm, aboForm)
assert.ok(aboForm.includes('– An ANSCHRIFT:') && textForm.includes('– An ANSCHRIFT:'))
})
// ---------------------------------------------------------------- Dokument widerrufsbelehrung-funktionen.md
const DOKU = 'docs/rechtliches/widerrufsbelehrung-funktionen.md'
/** Absätze eines Textes: durch Leerzeilen getrennt, jede Zeile eines Absatzes für sich (Formularzeilen), Überschriften-Zeichen weg. */
const absaetze = (text) =>
text
.replace(/\r/g, '')
.split(/\n{2,}/)
.flatMap((block) => block.split('\n'))
.map((z) => z.replace(/^#+\s+/, '').trim())
.filter(Boolean)
test('belehrung_dokument_gepinnt: Textblock der Datei = Ausgabe von widerrufsbelehrungFunktion(), Kopf mit Fassung, keine Platzhalter', () => {
const md = lies(DOKU).replace(/\r/g, '')
const zeilenMd = md.split('\n')
assert.equal(zeilenMd[0], '# Widerrufsbelehrung und Muster-Widerrufsformular (Einrichtung von Funktionen)')
assert.equal(zeilenMd[2], `**Fassung ${config.termine.avvVersion}**`)
assert.ok(!/\[|\]|…|\{\{|\}\}/.test(md), 'offener Platzhalter')
assert.ok(!/ENTWURF|Entwurf|Prüfpunkt|TODO|<!--/.test(md))
assert.ok(md.includes('Das Widerrufsrecht gilt nur für Verbraucher.'))
// Belehrung und Formular stehen als Überschriften und Absätze (Markdown ohne Codeblock), wörtlich und in Reihenfolge
const von = md.indexOf('## Widerrufsbelehrung')
const bis = md.indexOf('## Hinweise zum Widerruf')
assert.ok(von > 0 && bis > von, 'Aufbau der Datei: Abschnitte „Widerrufsbelehrung“ … „Hinweise zum Widerruf“')
assert.deepEqual(absaetze(md.slice(von, bis)), absaetze(widerrufsbelehrungFunktion()))
// keine Codeblöcke, Zitate oder Tabellen im Belehrungsteil
assert.ok(!/```|^>|^\|/m.test(md.slice(von, bis)))
})
test('belehrung_dokument_hinweise: vier Hinweise zum Widerruf, nicht Teil der gesetzlichen Belehrung', () => {
const md = lies(DOKU).replace(/\r/g, '')
const hinweise = md.slice(md.indexOf('## Hinweise zum Widerruf'))
assert.match(hinweise, /nicht zur gesetzlichen Widerrufsbelehrung/)
const punkte = hinweise.split('\n').filter((z) => z.startsWith('- '))
assert.equal(punkte.length, 4)
assert.match(punkte[0], /jede Bestellung einer Funktion gesondert/)
assert.match(punkte[0], /Website-Projekt/)
assert.match(punkte[1], /endet diese Funktion/)
assert.match(punkte[1], /Wartungs-Abo bleibt davon unberührt/)
assert.match(punkte[2], /ausdrücklichen Wunsch/)
assert.match(punkte[2], /angemessenen Betrag für die bis dahin erbrachte Leistung/)
assert.match(punkte[2], /Anteil am Gesamtpreis/)
assert.match(punkte[2], /Arbeitsblatt erfasste Aufwand/)
assert.match(punkte[3], /ursprüngliche Zahlungsmittel/)
assert.match(punkte[3], /Stripe/)
assert.ok(!DU_WOERTER.test(md))
})