Files
Webklar-Kundenbereich/test/legalTexts.test.js
T

229 lines
14 KiB
JavaScript

import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { createHash } from 'node:crypto'
const { config } = await import('../server/config.js')
// = `git show 18c3cdf:public/abo-bedingungen.html | sha256sum` (Fassung 3, Stand 25.09.2026).
// Fest eingetragen, weil die Tests auch ohne git laufen muessen (node:20-alpine).
const V3_SHA256 = '1c9e4b002dcd5d7896bbda24c573e56544b7123e3a88d154e5db77bd2855bc47'
const file = (rel) => new URL(`../${rel}`, import.meta.url)
const read = (rel) => readFileSync(file(rel), 'utf8')
const sha256 = (rel) => createHash('sha256').update(readFileSync(file(rel))).digest('hex')
const strip = (html) => html.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/\s+/g, ' ').trim()
// HTML-Ausschnitt von einem Anker bis zum naechsten, danach ohne Tags. Die Inhaltsuebersicht wiederholt
// die Ueberschriften, deshalb wird nicht nach dem ersten Text-Treffer, sondern nach id="..." geschnitten.
const section = (html, fromId, toId) => {
const a = html.indexOf(`id="${fromId}"`)
assert.ok(a >= 0, `Anker #${fromId} fehlt`)
const b = html.indexOf(`id="${toId}"`, a + 1)
assert.ok(b > a, `Anker #${toId} fehlt nach #${fromId}`)
return strip(html.slice(a, b))
}
const between = (s, a, b) => {
const i = s.indexOf(a)
assert.ok(i >= 0, `Text fehlt: ${a}`)
const j = s.indexOf(b, i)
assert.ok(j > i, `Text fehlt: ${b}`)
return s.slice(i, j)
}
const V3 = 'public/vertragsunterlagen/v3/abo-bedingungen.html'
const V4 = 'public/abo-bedingungen.html'
test('Fassung 3 unverändert archiviert (HTML und PDF)', () => {
assert.equal(sha256(V3), V3_SHA256)
// Das bisherige PDF bleibt die Fassung 3 und liegt identisch im Archiv
assert.equal(
sha256('public/vertragsunterlagen/v3/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf'),
sha256('public/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf')
)
assert.ok(read(V3).includes('Version 3 · Stand 25.09.2026'))
})
test('Widerrufsbelehrung und Muster-Widerrufsformular wortgleich mit Fassung 3', () => {
const v3 = read(V3), v4 = read(V4)
const muster = (belehrung) => between(belehrung, 'Widerrufsrecht Sie haben das Recht', 'Ende der Widerrufsbelehrung')
const muster3 = muster(section(v3, 'widerruf', 'formular'))
assert.ok(muster3.length > 1500)
assert.equal(muster(section(v4, 'widerruf', 'formular')), muster3)
// Muster-Widerrufsformular (Anlage 2 zu Art. 246a EGBGB) ebenfalls unveraendert
const formular = (s) => between(s, 'Muster-Widerrufsformular', '(*) Unzutreffendes streichen.')
assert.equal(formular(section(v4, 'formular', 'widerruf-hinweise')), formular(section(v3, 'formular', 'avv')))
})
test('Fassung 4: beide Abos, Postfächer, Zusatzspeicher, Wechsel', () => {
const v4html = read(V4), v4 = strip(v4html)
for (const s of ['15,00 €', '50,00 €', 'beliebig viele Postfächer', 'zusammen 5 GB', '1,00 €', 'Zusatzspeicher',
'12 Monate', 'statische', 'Hetzner', '§ 19 UStG', 'Leistungsbeschreibung A', 'Leistungsbeschreibung B',
'„Zahlungspflichtig bestellen“', '„Verträge hier kündigen“', '„Nur den E-Mail-Zusatzspeicher“', '„Speichern“',
'Vertragsbedingungen für die WEBklar-Abos']) assert.ok(v4.includes(s), s)
assert.ok(!v4.includes('Version 3'))
const hosting = section(v4html, 'hosting', 'wartung')
assert.ok(hosting.includes('1 E-Mail-Postfach'))
assert.ok(hosting.includes('statische'))
const wartung = section(v4html, 'wartung', 'widerruf')
assert.ok(wartung.includes('beliebig viele Postfächer'))
assert.ok(wartung.includes('zusammen 5 GB'))
assert.ok(!wartung.includes('1 E-Mail-Postfach'))
// Hinweise zum Widerruf bei Wechsel und Zusatzspeicher stehen ausserhalb der Musterbelehrung
const hinweise = section(v4html, 'widerruf-hinweise', 'avv')
assert.ok(hinweise.includes('Wechsel'))
assert.ok(hinweise.includes('Zusatzspeicher'))
assert.ok(hinweise.includes('Wertersatz'))
})
test('Anker der Fassung 4', () => {
const v4 = read(V4)
for (const id of ['widerruf', 'hosting', 'wartung', 'zusatzspeicher', 'wechsel', 'avv', 'widerruf-hinweise', 'formular', 'tom', 'sub']) {
assert.equal(v4.split(`id="${id}"`).length, 2, `Anker #${id} genau einmal`)
}
// Reihenfolge: allgemeiner Teil, Leistungsbeschreibung A, B, Widerruf, Hinweise, Anlagen 1-3
const order = ['id="wechsel"', 'id="zusatzspeicher"', 'id="hosting"', 'id="wartung"', 'id="widerruf"', 'id="formular"',
'id="widerruf-hinweise"', 'id="avv"', 'id="tom"', 'id="sub"'].map((m) => v4.indexOf(m))
assert.deepEqual([...order].sort((a, b) => a - b), order)
})
test('Version und Stand passen zu termsVersion', () => {
const m = /^(\d{4})-(\d{2})-(\d{2})-v4$/.exec(config.abo.termsVersion)
assert.ok(m, `termsVersion ${config.abo.termsVersion}`)
const [, y, mo, d] = m
assert.ok(strip(read(V4)).includes(`Version 4 · Stand ${d}.${mo}.${y}`))
assert.ok(read(V4).includes(`content: "Version 4 · Stand ${d}.${mo}.${y}"`), 'Kopfzeile im Druck')
})
test('PDF der Fassung 4 verlinkt, erzeugt und im Build-Skript', () => {
const v4 = read(V4)
assert.ok(v4.includes('href="/WEBklar-Abo-Vertragsunterlagen.pdf"'))
assert.ok(!v4.includes('WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf'))
const pdf = readFileSync(file('public/WEBklar-Abo-Vertragsunterlagen.pdf'))
assert.ok(pdf.length > 50_000 && pdf.subarray(0, 5).toString() === '%PDF-')
const build = read('scripts/build-legal-pdfs.sh')
assert.ok(build.includes('pdf public/abo-bedingungen.html public/WEBklar-Abo-Vertragsunterlagen.pdf'))
const erzeugt = build.split('\n').filter((l) => l.startsWith('pdf '))
assert.ok(!erzeugt.some((l) => l.includes('WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf')), 'Fassung 3 wird nicht neu erzeugt')
})
// --- Korrekturen aus der Prüfung (Review 1) ---
const v4Section = (fromId, toId) => section(read(V4), fromId, toId)
test('Widerrufsfrist beginnt mit dem Vertragsschluss, nicht mit der Bestellung', () => {
const hinweise = v4Section('widerruf-hinweise', 'avv')
assert.ok(hinweise.includes('mit dem Vertragsschluss (§ 2 Abs. 3 bzw. Abs. 5)'))
assert.ok(!hinweise.includes('mit der Bestellung'))
// § 2 Abs. 3 = Vertragsschluss beim Abo, Abs. 5 = Vertragsschluss bei Wechsel und Zusatzspeicher
const html = read(V4)
const p2 = html.slice(html.indexOf('id="p2"'), html.indexOf('id="p3"'))
// Absaetze auf oberster Ebene stehen mit genau vier Leerzeichen am Zeilenanfang (die Liste a-e in Abs. 2 mit acht)
const absaetze = [...p2.matchAll(/^ <li>([\s\S]*?)<\/li>$/gm)].map((m) => strip(m[1]))
assert.equal(absaetze.length, 6)
assert.ok(absaetze[2].startsWith('Der Vertrag kommt zustande, sobald der Kunde den Vorgang bei Stripe abgeschlossen hat'))
assert.ok(absaetze[4].includes('Mit dem Klick auf „Zahlungspflichtig bestellen“ kommt der Vertrag zustande'))
assert.ok(hinweise.includes('gehören zu diesen Bedingungen, nicht zur gesetzlichen Widerrufsbelehrung'))
assert.ok(hinweise.includes('Mehrbetrag ist, was der Kunde seit dem Wechsel mehr gezahlt hat'))
assert.ok(hinweise.includes('Wertersatz verlangen wir dafür nicht (Nr. 4)'))
})
test('Wechsel, Plus und Minus im Portal nur ohne Kündigung und ohne offene Zahlung', () => {
const wechsel = v4Section('wechsel', 'zusatzspeicher'), speicher = v4Section('zusatzspeicher', 'p11')
assert.ok(wechsel.includes('nicht gekündigt'))
assert.ok(speicher.includes('nicht gekündigt'))
assert.ok(speicher.includes('„Nur den E-Mail-Zusatzspeicher“'))
// Übersicht: kein unbedingtes "jederzeit" mehr bei Wechsel und Zusatzspeicher
const uebersicht = strip(read(V4)).split('Inhalt ')[0]
assert.ok(!/jederzeit im Kundenportal/.test(uebersicht))
assert.ok(!/Jederzeit zum Ende des Abrechnungsmonats reduzierbar/.test(uebersicht))
})
test('Bestellübersichten nennen Laufzeit und Kündigungsmöglichkeit (§ 312j Abs. 2 BGB)', () => {
assert.ok(v4Section('p2', 'p3').includes('sowie Laufzeit und Kündigungsmöglichkeit'))
assert.ok(v4Section('wechsel', 'zusatzspeicher').includes('die Kündigungsmöglichkeit nach Ablauf der neuen Mindestlaufzeit'))
assert.ok(v4Section('zusatzspeicher', 'p11').includes('zum Ende jedes Abrechnungsmonats kündbar'))
})
test('Faire Nutzung der Postfächer, Hetzner benannt, Formulare, AVV-Zusatzleistungen', () => {
const leistungen = v4Section('p3', 'p4')
assert.ok(leistungen.includes('auch unentgeltlich'))
assert.ok(leistungen.includes('Versandgrenzen von Hetzner'))
assert.ok(leistungen.includes('(im Folgenden „Hetzner“)'))
assert.ok(!/\bder Anbieter\b/.test(leistungen))
assert.ok(v4Section('p6', 'p7').includes('für ihn tätige Personen'))
const hosting = v4Section('hosting', 'wartung')
assert.ok(hosting.includes('sind im Hosting-Abo nicht enthalten; eine Lösung dafür bieten wir auf Anfrage gesondert an'))
assert.ok(!hosting.includes('Formulardienst'))
const avv = v4Section('avv', 'tom')
assert.ok(avv.includes('sowie für gesondert vereinbarte Zusatzleistungen zum Abo, etwa eine Formularlösung,'))
assert.ok(avv.includes('sowie gesondert vereinbarter Zusatzleistungen'))
assert.ok(avv.includes('zu dem der neue Unterauftragsverarbeiter eingesetzt werden soll'))
assert.ok(strip(read(V4)).includes('über „Abo verwalten & Rechnungen“'))
})
test('Jeder Preis in Absätzen und Listenpunkten mit Hinweis auf § 19 UStG', () => {
// Die Übersichtstabelle ist ausgenommen (<td>), ihre Zeile "Preise" trägt den Hinweis
const elemente = [...read(V4).matchAll(/<(li|p)\b[^>]*>([\s\S]*?)<\/\1>/g)].map((m) => strip(m[2]))
const mitPreis = elemente.filter((t) => /\d+(,\d\d)? €/.test(t))
assert.ok(mitPreis.length >= 7, `Preisstellen: ${mitPreis.length}`)
for (const t of mitPreis) assert.ok(t.includes('§ 19 UStG'), `ohne § 19 UStG: ${t.slice(0, 90)}`)
})
test('Alle internen Links und Querverweise treffen', () => {
const html = read(V4)
const ids = [...html.matchAll(/ id="([^"]+)"/g)].map((m) => m[1])
for (const [, ref] of html.matchAll(/href="#([^"]+)"/g)) assert.equal(ids.filter((i) => i === ref).length, 1, `#${ref}`)
// Absätze je Paragraph: <li> der ol.abs auf oberster Ebene; bei § 3 die Überschriften "(M)"
const absaetze = {}
for (const [, n, body] of html.matchAll(/<h2 id="[^"]+">§ (\d+) [^<]*<\/h2>([\s\S]*?)(?=<h2 |<p class="anlage-kopf)/g)) {
const h3 = [...body.matchAll(/<h3>\((\d+)\)/g)].map((x) => Number(x[1]))
if (h3.length) { absaetze[n] = Math.max(...h3); continue }
const start = body.indexOf('<ol class="abs">')
let depth = 0, count = 0
if (start >= 0) for (const [, close, tag] of body.slice(start).matchAll(/<(\/?)(ol|ul|li)\b[^>]*>/g)) {
if (tag === 'li') { if (!close && depth === 1) count++; continue }
depth += close ? -1 : 1
if (depth === 0) break
}
absaetze[n] = count
}
assert.equal(Object.keys(absaetze).length, 16)
const text = strip(html)
const refs = [...text.matchAll(/§ (\d+) Abs\. (\d+)/g)]
assert.ok(refs.length >= 15, `Querverweise: ${refs.length}`)
for (const [ref, n, a] of refs) assert.ok(Number(a) <= (absaetze[n] ?? 0), `${ref}: § ${n} hat ${absaetze[n] ?? 0} Absätze`)
// Leistungsbeschreibung A/B Nr. N: nummerierte <h3> der jeweiligen Beschreibung
const nummern = (fromId, toId) => [...html.slice(html.indexOf(`id="${fromId}"`), html.indexOf(`id="${toId}"`)).matchAll(/<h3>(\d+)\. /g)].map((x) => Number(x[1]))
const maxA = Math.max(...nummern('hosting', 'wartung')), maxB = Math.max(...nummern('wartung', 'widerruf'))
for (const [ref, ab, nr] of text.matchAll(/Leistungsbeschreibung ([AB]) Nr\. (\d+)/g)) assert.ok(Number(nr) <= (ab === 'A' ? maxA : maxB), ref)
for (const [ref, nr] of v4Section('hosting', 'wartung').matchAll(/\bNr\. (\d+)/g)) assert.ok(Number(nr) <= maxA, `A: ${ref}`)
for (const [ref, nr] of v4Section('wartung', 'widerruf').matchAll(/\bNr\. (\d+)/g)) assert.ok(Number(nr) <= maxB, `B: ${ref}`)
})
// --- Datenschutzerklärung (Plan 2, Task 2) ---
test('Datenschutz: Abos, UptimeRobot, Stand wie termsVersion', () => {
const ds = strip(read('public/datenschutz.html'))
for (const s of ['Abos und Zahlung über Stripe', 'UptimeRobot s. r. o.', 'Obchodná 507/2', '811 06 Bratislava',
'Standardvertragsklauseln', 'im Rahmen unserer Abos']) assert.ok(ds.includes(s), s)
// Produktneutral: keine Formulierung mehr, die nur das Wartungs-Abo meint, wo beide Abos gemeint sind
for (const s of ['insbesondere das Wartungs-Abo', 'Schließen Sie das Wartungs-Abo', 'im Rahmen des Wartungs-Abos',
'für das Wartungs-Abo über Stripe']) assert.ok(!ds.includes(s), `noch da: ${s}`)
// Abschnitt 3 und 12: Überwachung der Erreichbarkeit durch UptimeRobot, Drittland auf Grundlage von SCC
const hosting = between(ds, '3. Hosting', '4. Cookies')
for (const s of ['UptimeRobot s. r. o., Obchodná 507/2, 811 06 Bratislava (Slowakei)', 'Art. 46 Abs. 2 lit. c DSGVO',
'Auftragsverarbeitung', 'Nutzungsbedingungen']) assert.ok(hosting.includes(s), `Abschnitt 3: ${s}`)
assert.ok(between(ds, '12. Empfänger im Überblick', '13. Ihre Rechte').includes('UptimeRobot'))
// Abschnitt 7: Nachweisdaten auch bei Wechsel und Zusatzspeicher, Aufbewahrung wie bisher
const abos = between(ds, '7. Abos und Zahlung über Stripe', '8. E-Mail')
for (const s of ['Wechsel', 'Zusatzspeicher', 'Kündigung', 'Widerruf', 'Version der Vertragsbedingungen',
'drei Jahre nach Vertragsende']) assert.ok(abos.includes(s), `Abschnitt 7: ${s}`)
// Stand der Erklärung = Datum aus termsVersion (JJJJ-MM-TT-v4); das Freigabedatum kommt erst beim Livegang (Task 20)
const [, y, mo, d] = /^(\d{4})-(\d{2})-(\d{2})-v4$/.exec(config.abo.termsVersion)
const stand = /Stand (\d{2}\.\d{2}\.\d{4})/.exec(ds)
assert.ok(stand, 'Stand fehlt')
assert.equal(stand[1], `${d}.${mo}.${y}`)
assert.ok(read('scripts/build-legal-pdfs.sh').includes('pdf public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf'))
})