276 lines
16 KiB
JavaScript
276 lines
16 KiB
JavaScript
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('&') && !/<[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')
|
||
})
|