Rechtstexte: Leistungsbeschreibung Terminplaner

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-05 02:39:28 +00:00
1 parent 6b4cbb3204
commit 6c474c46c3
2 files changed
+365

No files matched your search

+238
View File
@@ -0,0 +1,238 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { existsSync, readFileSync } from 'node:fs'
import { config } from '../server/config.js'
import { featureConfig } from '../server/services/featureRegistry.js'
import { LOESCHFRIST_TAGE } from '../server/services/termine/frist.js'
// Vertragsunterlagen des Terminplaners als Markdown: Leistungsbeschreibung (Task F4), AVV (Task F5).
// Die Markdown-Dateien sind die Quelle der ausgelieferten Seiten (scripts/build-legal-html.mjs, Task F6).
const lies = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8').replace(/\r/g, '')
const norm = (text) => text.replace(/\s+/g, ' ').trim()
const V = config.termine.avvVersion
// ----------------------------------------------------------------------------------------- Aufbau eines Dokuments
/** Zerlegt eine Vertragsunterlage: h1, Kopfzeile und Abschnitte (## N. Titel) mit ihren Absätzen "(n) …". */
function lese(md) {
const zeilen = md.split('\n')
const h1 = zeilen.filter((z) => /^# /.test(z))
const abschnitte = []
let aktuell = null
for (const zeile of zeilen) {
const h2 = /^## (.+)$/.exec(zeile)
if (h2) {
aktuell = { titel: h2[1], zeilen: [] }
abschnitte.push(aktuell)
} else if (aktuell) {
aktuell.zeilen.push(zeile)
}
}
for (const a of abschnitte) {
a.nummer = /^(\d+)\. /.exec(a.titel)?.[1] ?? null
a.text = a.zeilen.join('\n')
a.absaetze = [...a.text.matchAll(/^\((\d+)\) /gm)].map((m) => Number(m[1]))
}
return { h1, kopf: zeilen[2], abschnitte, md }
}
const LB_MD = lies('docs/rechtliches/leistungsbeschreibung-terminplaner.md')
const LB = lese(LB_MD)
const abschnitt = (doc, nummer) => doc.abschnitte.find((a) => a.nummer === String(nummer))
// ----------------------------------------------------------------------------------------- Leistungsbeschreibung
test('lb_aufbau: Titel, Kopf mit Fassung und die elf Abschnitte in fester Nummerierung', () => {
assert.deepEqual(LB.h1, ['# Leistungsbeschreibung Terminplaner'])
assert.equal(LB_MD.split('\n')[0], '# Leistungsbeschreibung Terminplaner')
assert.equal(LB.kopf, `**Fassung ${V}** · Zusatzbedingungen zum Wartungs-Abo`)
assert.deepEqual(LB.abschnitte.map((a) => a.titel), [
'1. Geltung',
'2. Leistung',
'3. Nicht enthalten',
'4. Voraussetzungen und Pflichten des Kunden',
'5. Einrichtung und Preis',
'6. Verfügbarkeit und Störungen',
'7. Haftung für verpasste Termine',
'8. Ende der Leistung',
'9. Änderungen und Einstellung',
'10. Widerruf',
'11. Hinweise',
])
// Absätze sind lückenlos nummeriert: (1), (2), …
for (const a of LB.abschnitte) {
assert.ok(a.absaetze.length >= 1, `${a.titel}: keine Absätze`)
assert.deepEqual(a.absaetze, a.absaetze.map((_, i) => i + 1), `${a.titel}: Absätze nicht lückenlos`)
}
})
test('lb_sauber: keine Platzhalter, Entwurfsmarken, Kommentare oder Kanzlei-Marker', () => {
assert.doesNotMatch(LB_MD, /ENTWURF|Entwurf|Prüfpunkt|TODO|\{\{|\}\}|<!--|⚖|\bQ\d+\b/)
assert.doesNotMatch(LB_MD, /\[|\]|…\s*$/m, 'offene Klammer oder Auslassung am Zeilenende')
assert.doesNotMatch(LB_MD, /undefined|null|NaN/)
assert.ok(!/```|^>|<[a-z]/im.test(LB_MD), 'nur die Markdown-Teilmenge: kein Codeblock, Zitat oder rohes HTML')
})
test('lb_festwerte: Preis, Umsatzsteuer-Hinweis, Voraussetzung Wartungs-Abo und die Sätze von Nr. 8 wörtlich', () => {
const text = norm(LB_MD)
assert.ok(text.includes('149 €'))
assert.ok(text.includes('§ 19 UStG'))
assert.ok(text.includes(config.ustHinweis), 'Nr. 5: Hinweis wörtlich aus config.ustHinweis')
assert.ok(text.includes('ein Hosting-Abo genügt nicht'))
assert.ok(text.includes('Zusatzbedingungen zum Wartungs-Abo'))
const nr8 = norm(abschnitt(LB, 8).text)
for (const satz of [
'Mit dem Ende des Wartungs-Abos endet der Terminplaner. WEBklar schaltet ihn spätestens am folgenden Tag ab und informiert den Kunden per E-Mail. Das gilt auch bei einem Wechsel ins Hosting-Abo und beim Widerruf des Abschlusses oder eines Wechsels ins Wartungs-Abo.',
'WEBklar löscht alle Termindaten und die Einstellungen 30 Tage nach dem Abschalten.',
'Beginnt innerhalb dieser 30 Tage wieder ein Wartungs-Abo, schaltet WEBklar den Terminplaner mit den vorhandenen Daten und Einstellungen ohne weitere Kosten wieder ein. Nach der Löschung ist eine neue Einrichtung nötig (neuer Fragebogen, neuer Preis nach Nr. 5).',
'Endet das Wartungs-Abo, bevor WEBklar den Terminplaner freigeschaltet hat, erstattet WEBklar den Einrichtungspreis vollständig.',
]) {
assert.ok(nr8.includes(satz), `Nr. 8: Satz fehlt oder ist verändert: ${satz}`)
}
// Absatz 1, 3, 4, 5 stehen an der festgelegten Stelle
const absatz = (n) => norm(abschnitt(LB, 8).text.split(/^\(\d+\) /m)[n])
assert.ok(absatz(1).startsWith('Mit dem Ende des Wartungs-Abos endet der Terminplaner.'))
assert.ok(absatz(3).startsWith('WEBklar löscht alle Termindaten'))
assert.ok(absatz(4).startsWith('Beginnt innerhalb dieser 30 Tage'))
assert.ok(absatz(5).startsWith('Endet das Wartungs-Abo, bevor'))
// keine Einstellung ohne Grund
assert.ok(norm(abschnitt(LB, 9).text).includes('Eine Einstellung ohne einen Grund nach Absatz 2 ist ausgeschlossen.'))
})
test('lb_preis_gleich_register: Preis der LB = priceLabel des Registers (Nr. 5)', () => {
const nr5 = norm(abschnitt(LB, 5).text)
const preis = /kostet (\d+ €) einmalig/.exec(nr5)
assert.ok(preis, 'Nr. 5 nennt den Preis')
assert.equal(preis[1], featureConfig('termine').priceLabel)
assert.equal(config.termineSetup.amountCents, 14900)
assert.equal(`${config.termineSetup.amountCents / 100} €`, preis[1])
// der Preis steht nur einmal in der LB
assert.equal(norm(LB_MD).split('149 €').length - 1, 1)
})
test('lb_fristen_gleich_code: 30 Tage bis zur Löschung = LOESCHFRIST_TAGE (termine/frist.js)', () => {
assert.equal(LOESCHFRIST_TAGE, 30)
const nr8 = norm(abschnitt(LB, 8).text)
const loeschung = /löscht alle Termindaten und die Einstellungen (\d+) Tage nach dem Abschalten/.exec(nr8)
const rueckkehr = /Beginnt innerhalb dieser (\d+) Tage wieder/.exec(nr8)
assert.ok(loeschung && rueckkehr)
assert.equal(Number(loeschung[1]), LOESCHFRIST_TAGE)
assert.equal(Number(rueckkehr[1]), LOESCHFRIST_TAGE)
// die Abschalt-Frist "spätestens am folgenden Tag" steht nur in Nr. 8 Abs. 1
assert.equal(norm(LB_MD).split('spätestens am folgenden Tag').length - 1, 1)
})
/**
* Verweise: "Nr. N [Abs. M]" auf diese LB (ohne Zusatz), "Nummer N des AVV Terminplaner", "Leistungsbeschreibung B Nr. N der
* Abo-Bedingungen" und "§ N [Abs. M] der Abo-Bedingungen" müssen auf vorhandene Abschnitte zeigen.
*/
test('lb_verweise: jedes „Nr. N“ auf LB oder AVV und jedes „§ N der Abo-Bedingungen“ existiert', () => {
const text = norm(LB_MD)
// 1. Verweise auf diese Leistungsbeschreibung: "Nr. N", "Nr. N Abs. M", "nach Nr. N"
const eigene = []
for (const m of text.matchAll(/\bNr\. (\d+)(?: Abs\. (\d+))?(?! der Abo-Bedingungen)/g)) {
const davor = text.slice(Math.max(0, m.index - 40), m.index)
if (/Leistungsbeschreibung B $/.test(davor)) continue // Abo-Bedingungen, siehe unten
eigene.push({ nr: m[1], abs: m[2], zitat: m[0] })
}
assert.ok(eigene.length >= 8, `zu wenige interne Verweise gefunden: ${eigene.length}`)
for (const v of eigene) {
const a = abschnitt(LB, v.nr)
assert.ok(a, `„${v.zitat}“: Abschnitt ${v.nr} gibt es nicht`)
if (v.abs) assert.ok(a.absaetze.includes(Number(v.abs)), `„${v.zitat}“: Absatz ${v.abs} gibt es nicht in Nr. ${v.nr}`)
}
// 2. Verweise auf den AVV Terminplaner (Vorbemerkung, Nr. 1 bis 12, Anlage): Nummernbereich; ist der AVV schon geschrieben, auch seine Überschriften
const avvNummern = [...text.matchAll(/Nummer (\d+)(?: Abs\. (\d+))? (?:des )?AVV Terminplaner|AVV Terminplaner Nummer (\d+)(?: Abs\. (\d+))?/g)]
assert.ok(avvNummern.length >= 2)
const avvPfad = 'docs/rechtliches/avv-terminplaner.md'
const avv = existsSync(new URL(`../${avvPfad}`, import.meta.url)) ? lese(lies(avvPfad)) : null
for (const m of avvNummern) {
const n = Number(m[1] ?? m[3])
assert.ok(n >= 1 && n <= 12, `„${m[0]}“: der AVV hat Nr. 1 bis 12`)
if (avv) assert.ok(abschnitt(avv, n), `„${m[0]}“: Abschnitt ${n} fehlt im AVV`)
}
// 3. Leistungsbeschreibung B Nr. 3 der Abo-Bedingungen: "Nicht enthalten" im Wartungs-Abo
const abo = lies('public/abo-bedingungen.html')
const wartung = abo.slice(abo.indexOf('<h2>Wartungs-Abo</h2>'), abo.indexOf('<h2 id="widerruf">'))
assert.ok(wartung.includes('<h3>3. Nicht enthalten</h3>'))
assert.ok(text.includes('Leistungsbeschreibung B Nr. 3 der Abo-Bedingungen („Rechtstexte … nicht enthalten“)'))
assert.ok(/Texte, Bilder und Rechtstexte \(Impressum, Datenschutzerklärung\)/.test(wartung.replace(/<[^>]+>/g, '')))
// 4. Paragrafen der Abo-Bedingungen: § N existiert, Absatz M existiert (Aufzählungspunkte bzw. Überschriften "(M) …" des Paragrafen)
const paragrafen = [...text.matchAll(/§§? (\d+)(?: Abs\. (\d+)(?: und (\d+))?)?(?: und (\d+))? der Abo-Bedingungen/g)]
assert.ok(paragrafen.length >= 7, `zu wenige Verweise auf die Abo-Bedingungen: ${paragrafen.length}`)
const nummern = new Set()
for (const m of paragrafen) {
for (const n of [m[1], m[4]].filter(Boolean)) nummern.add(n)
const start = abo.indexOf(`<h2 id="p${m[1]}">§ ${m[1]} `)
assert.ok(start > 0, `„${m[0]}“: § ${m[1]} gibt es in den Abo-Bedingungen nicht`)
const ende = abo.indexOf('<h2', start + 10)
const stueck = abo.slice(start, ende)
const anzahl = Math.max((stueck.match(/<li>/g) || []).length, (stueck.match(/<h3>\(\d+\)/g) || []).length)
for (const abs of [m[2], m[3]].filter(Boolean)) {
assert.ok(Number(abs) <= anzahl, `„${m[0]}“: § ${m[1]} hat keinen Absatz ${abs}`)
}
}
// "§§ 12 und 13 der Abo-Bedingungen" nennt beide Paragrafen
assert.ok(nummern.has('12') && nummern.has('13'))
for (const n of ['3', '4', '5', '6', '12', '13']) assert.ok(nummern.has(n), `§ ${n} der Abo-Bedingungen wird zitiert`)
// § 327r BGB und § 3 BFSG sind Gesetzesverweise, keine Verweise in die Abo-Bedingungen
assert.ok(text.includes('§ 327r Abs. 3 BGB') && text.includes('§ 3 Abs. 3 BFSG'))
// 5. Die Anlage zur Widerrufsbelehrung heißt wie die Überschrift der Datei
const anlage = lies('docs/rechtliches/widerrufsbelehrung-funktionen.md').split('\n')[0].replace(/^# /, '')
assert.ok(text.includes(`„${anlage}“`), 'Nr. 10 nennt die Anlage mit dem Titel der Widerrufsbelehrung')
})
test('lb_ohne_du: Dritte Person und Sie-Form, keine Du-Form', () => {
assert.doesNotMatch(LB_MD, /\bdein|\bdu\b|\bdir\b|\bdich\b|\beuch\b|\beuer/i)
assert.ok(/\bder Kunde\b/.test(LB_MD))
})
test('lb_bfsg_ohne_konformitaetszusage: keine Zusage, das Widget erfülle WCAG oder EN 301 549', () => {
const nr11 = norm(abschnitt(LB, 11).text)
assert.ok(nr11.includes('Eine Konformität nach WCAG 2.1 AA oder EN 301 549 sagt WEBklar nicht zu.'))
assert.ok(nr11.includes('Ob der Kunde als Dienstleistungserbringer unter das BFSG fällt (Ausnahme für Kleinstunternehmen, § 3 Abs. 3 BFSG), entscheidet und prüft der Kunde.'))
assert.ok(nr11.includes('Das Widget ist für die Bedienung mit Tastatur und Hilfstechniken gestaltet (Beschriftungen, Fokusführung, Fehlermeldungen als Live-Region).'))
const alles = norm(LB_MD)
assert.doesNotMatch(alles, /WCAG[^.]*\b(entspricht|erfüllt|erfüllen|barrierefrei|konform ist|sind konform)\b/i)
assert.doesNotMatch(alles, /\b(entspricht|erfüllt|erfüllen)\b[^.]*(WCAG|EN 301 549|BFSG)/i)
assert.doesNotMatch(alles, /vollständig barrierefrei|barrierefrei nach/i)
})
test('lb_inhalt: Rangfolge, Gästetexte, Gesundheitsbereich, Datenschutz-Absatz, Zahlung nur vor Ort', () => {
const nr1 = norm(abschnitt(LB, 1).text)
assert.ok(nr1.includes('Bei Widersprüchen geht für den Terminplaner diese Leistungsbeschreibung vor, in Datenschutzfragen der AVV Terminplaner.'))
assert.ok(nr1.includes('Leistungsbeschreibung B Nr. 3 der Abo-Bedingungen („Rechtstexte … nicht enthalten“) gilt nicht für den Datenschutz-Absatz'))
const nr2 = norm(abschnitt(LB, 2).text)
assert.ok(nr2.includes('Bezahlt wird nur vor Ort.'))
assert.ok(nr2.includes('Mustertexte der Standardkonfiguration'))
for (const wort of ['geheimen Kalender-Link (ICS)', 'CSV-Datei', 'Missbrauchsschutz', 'Bestätigungslink', 'Erinnerung (nur mit seinem Haken)', 'Widerrufs']) {
assert.ok(nr2.includes(wort), `Nr. 2 nennt ${wort}`)
}
const nr3 = norm(abschnitt(LB, 3).text)
for (const wort of ['SMS', 'Online-Zahlung', 'Kalender-Abgleich', 'Konten für Gäste', 'Marketing-E-Mails', 'Gruppentermine', 'Mehrsprachigkeit', 'Gesundheitsbereich', 'zweites Widget', 'Sonderdesign', '§ 4 der Abo-Bedingungen']) {
assert.ok(nr3.includes(wort), `Nr. 3 nennt ${wort}`)
}
const nr4 = norm(abschnitt(LB, 4).text)
assert.ok(nr4.includes('Der Terminplaner ist nicht für Angebote im Gesundheitsbereich bestimmt'))
assert.ok(nr4.includes('WEBklar darf die Einrichtung für eine solche Website ablehnen oder den Terminplaner dort abschalten.'))
assert.ok(nr4.includes('Kinder unter 16 Jahren'))
assert.ok(nr4.includes('wird nicht auf das Stundenkontingent angerechnet'))
assert.ok(nr4.includes('Textbaustein'))
// Keine Gesundheitsangaben anfordern: der Text fordert nirgends welche an
assert.doesNotMatch(LB_MD, /Gesundheitsdaten (erfassen|speichern)|Diagnose|Patient/i)
const nr5 = norm(abschnitt(LB, 5).text)
for (const wort of ['Fragebogen', 'Einbau', 'Abnahme', 'Freischaltung', 'Stripe', 'Rechnung', 'keine verbindliche Zusage eines Termins', 'weder auf das Kontingent angerechnet noch damit bezahlt']) {
assert.ok(nr5.includes(wort), `Nr. 5 nennt ${wort}`)
}
const nr7 = norm(abschnitt(LB, 7).text)
assert.ok(nr7.includes('§ 13 Abs. 1 und 2 der Abo-Bedingungen'))
const nr10 = norm(abschnitt(LB, 10).text)
assert.ok(nr10.includes('Mit dem Widerruf endet der Terminplaner.'))
})