739 lines
43 KiB
JavaScript
739 lines
43 KiB
JavaScript
import { test } from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { 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'
|
||
|
||
// 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())
|
||
|
||
// 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('&') && !/<[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('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 …“), 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('&') && !text.includes('<'))
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 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(!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-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')
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 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(!/&|<[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(/&/g, '&')
|
||
.replace(/ /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))
|
||
})
|