Terminplaner: Datenschutz-Absatz als Rechtstext-Baustein

datenschutzAbsatz(einstellung, anrede) liefert den Absatz "Online-Terminbuchung" fuer die
Datenschutzerklaerung der Betriebs-Website als reinen Text (Sie- und Du-Fassung, fuenf bedingte
Saetze: Telefon-Feld, Nachricht/Zusatzfelder, Erinnerung, Widerruf ueber zeigtWiderruf, Telefon des
Betriebs). RECHTSTEXTE_ENTWURF = true. Test pinnt den Wortlaut gegen die Markdown-Quelle in allen
32 Bedingungskombinationen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 23:47:58 +00:00
1 parent 1ac142b92f
commit 06ebcd1b0a
2 files changed
+403 -5

No files matched your search

+210 -2
View File
@@ -3,8 +3,10 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import {
RECHTSTEXTE_VERSION,
RECHTSTEXTE_ENTWURF,
widerrufsbelehrung,
formularHinweis,
datenschutzAbsatz,
ZUSTIMMUNG_VORZEITIG,
ZUSTIMMUNG_STORNO,
ZUSTIMMUNG_ERINNERUNG,
@@ -19,6 +21,10 @@ 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
@@ -214,6 +220,208 @@ test('Formular-Hinweis: Sie ist die Vorgabe, Aufbewahrung 90 Tage ohne gültigen
assert.throws(() => formularHinweis(undefined), TypeError)
})
// ---------------------------------------------------------------- Datenschutz-Absatz „Online-Terminbuchung“ (Quelle der Einbauanleitung und der Doku)
test('Rechtstexte sind Entwurf, bis Block F die ENTWURF-Köpfe und diese Konstante zusammen entfernt', () => {
assert.equal(RECHTSTEXTE_ENTWURF, true)
})
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 …“), steht in der Quelle ohne Leerzeichen am Vordertext
* („Platz[[…: und die Angaben …]]“); er wird hier mit Leerzeichen angefügt (Satzteile mit Komma bleiben unmittelbar dran).
*/
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, offset, ganz) => {
const eintrag = DS_BEDINGUNGEN.find(([muster]) => muster.test(bedingung))
assert.ok(eintrag, `unbekannte Bedingung in der Quelle: ${bedingung}`)
if (!zustand[eintrag[1]]) return ''
return /^[A-Za-zÄÖÜäöüß]/.test(satz) && offset > 0 && !/\s/.test(ganz[offset - 1]) ? ` ${satz}` : 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.'))
})
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 = [
@@ -260,8 +468,8 @@ test('Datenschutz-Absatz: Löschfristen der Vorgaben, Sie- und Du-Fassung, Auftr
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')
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)', () => {