229 lines
14 KiB
JavaScript
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(/ /g, ' ').replace(/&/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'))
|
|
})
|