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

276 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import {
RECHTSTEXTE_VERSION,
widerrufsbelehrung,
formularHinweis,
ZUSTIMMUNG_VORZEITIG,
ZUSTIMMUNG_STORNO,
ZUSTIMMUNG_ERINNERUNG,
GESUNDHEIT_HINWEIS,
} from '../server/services/termine/rechtstexte.js'
// ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen.
// 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())
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('Fassung der Rechtstexte', () => {
assert.equal(RECHTSTEXTE_VERSION, '2026-10-02')
})
// ---------------------------------------------------------------- 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.')
assert.equal(ZUSTIMMUNG_ERINNERUNG(2), 'Ich möchte 2 Stunden vor dem Termin eine Erinnerung per E-Mail erhalten.')
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"))
})
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)
})
// ---------------------------------------------------------------- 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-entwurf.md',
]
test('Jedes Rechtstext-Dokument beginnt mit dem Entwurfs-Kopf', () => {
for (const rel of DOKUMENTE) {
assert.equal(lies(rel).split('\n')[0].trim(), KOPF, rel)
}
})
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(!/\bdein|\bdu\b|\bdir\b|\bdich\b/i.test(fassungen[0]), 'Du-Form in der Sie-Fassung')
assert.ok(!/\bIhr|\bIhnen\b|\bSie (haben|können|erhalten|wünschen|machen|buchen|klicken|sind|wollen)\b/.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-entwurf.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')
})