Rechtstexte: Leistungsbeschreibung Terminplaner
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6b4cbb3204
commit
6c474c46c3
2 files changed
+365
No files matched your search
@@ -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.'))
|
||||
})
|
||||
Reference in new issue
Block a user