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

365 lines
24 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'
// = sha256 des Blocks ab `const TAKEOVER_CONDITIONS` bis vor das schliessende ] in public/app.js, je conditionsVersion
// (Nachweis der Zustimmung). Aendert sich der Text der Bedingungen, muss config.takeover.conditionsVersion hochgezogen
// und der neue Hash hier eingetragen werden; die Fehlermeldung des Tests nennt ihn.
const TAKEOVER_CONDITIONS_SHA256 = { '2026-09-27': '2b7a745cb599c0ab28f20615a745bd86da6faa8d120391ca56beac35a0473e62' }
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)'))
})
// Absaetze eines Paragraphen auf oberster Ebene: genau vier Leerzeichen vor <li> (verschachtelte Listen haben acht)
const absaetzeVon = (fromId, toId) => {
const html = read(V4)
const slice = html.slice(html.indexOf(`id="${fromId}"`), html.indexOf(`id="${toId}"`))
return [...slice.matchAll(/^ <li>([\s\S]*?)<\/li>$/gm)].map((m) => strip(m[1]))
}
// Eine Zeile der Übersicht "Das Wichtigste auf einen Blick"
const uebersichtZeile = (name) => strip(between(read(V4), `<tr><td>${name}</td><td>`, '</td></tr>'))
test('Wechsel, Plus und Minus im Portal nur ohne Kündigung und ohne offene Zahlung', () => {
const wechsel = absaetzeVon('wechsel', 'zusatzspeicher'), speicher = absaetzeVon('zusatzspeicher', 'p11')
assert.equal(wechsel.length, 5)
assert.equal(speicher.length, 6)
// Jeder Absatz einzeln, damit ein fehlendes "nicht gekündigt" in Abs. 1 ODER Abs. 5 auffällt
assert.ok(wechsel[0].includes('nicht gekündigt'), '§ 9 Abs. 1')
assert.ok(speicher[0].includes('nicht gekündigt'), '§ 10 Abs. 1 (Bestellen und Ändern im Portal)')
assert.ok(speicher[4].includes('nicht gekündigt'), '§ 10 Abs. 5 (Verringern mit „Speichern“)')
assert.ok(speicher[4].includes('„Nur den E-Mail-Zusatzspeicher“'))
assert.ok(speicher[4].includes('kann der Kunde die Zahl der Pakete nur noch in Textform verringern'))
assert.ok(!speicher[4].includes('auch eine Verringerung'))
// Übersicht: dieselben Bedingungen wie § 9 Abs. 1 und § 10 Abs. 1, kein unbedingtes "jederzeit"
for (const name of ['E-Mail-Zusatzspeicher', 'Wechsel']) {
const zeile = uebersichtZeile(name)
for (const s of ['nach Leistungsbeginn', 'nicht gekündigt', 'keine Zahlung offen']) assert.ok(zeile.includes(s), `${name}: ${s}`)
assert.ok(!/jederzeit im Kundenportal|Jederzeit zum Ende/.test(zeile), name)
}
})
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“'))
})
// Text je <li> und <p>, jeweils dem innersten offenen Element zugeordnet. Text hinter einer verschachtelten
// Liste zaehlt so zum aeusseren <li> (eine Regex bis zum ersten </li> wuerde ihn uebersehen).
const textJeElement = (html) => {
const out = [], stack = []
for (const [, close, tag, text] of html.matchAll(/<(\/?)([a-zA-Z0-9]+)[^>]*>|([^<]+)/g)) {
if (text !== undefined) { if (stack.length) stack[stack.length - 1].text += text; continue }
const t = tag.toLowerCase()
if (t !== 'li' && t !== 'p') continue
if (!close) stack.push({ tag: t, text: '' })
else { const el = stack.pop(); assert.equal(el?.tag, t, `Tag-Verschachtelung bei </${t}>`); out.push(strip(el.text)) }
}
assert.equal(stack.length, 0, 'offene <li>/<p>')
return out
}
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
assert.ok(uebersichtZeile('Preise').includes('Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.'))
const mitPreis = textJeElement(read(V4)).filter((t) => /\d+(,\d\d)? €/.test(t))
assert.ok(mitPreis.length >= 7, `Preisstellen: ${mitPreis.length}`)
// Ein Wortlaut je Stelle: Fliesstext in Klammern, Kopfzeilen von A und B mit Punkt, § 7 Abs. 1 mit vollem Satz
const fliesstext = '(Endpreis, gemäß § 19 UStG ohne Umsatzsteuer)'
const kopfzeile = '· Endpreis, gemäß § 19 UStG ohne Umsatzsteuer'
const p7 = 'Alle Preise sind Endpreise. Als Kleinunternehmer im Sinne von § 19 UStG berechnen wir keine Umsatzsteuer.'
for (const t of mitPreis) {
assert.ok(t.includes(fliesstext) || t.includes(kopfzeile) || t.includes(p7), `ohne einheitlichen § 19-Hinweis: ${t.slice(0, 90)}`)
}
assert.ok(!strip(read(V4)).includes('(gemäß § 19 UStG ohne Umsatzsteuer)'), 'alter Wortlaut ohne „Endpreis“')
})
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}`)
})
// --- Follow-up vor der Rechtsrunde (Task 1, Fix 2) ---
test('Follow-up: Webmail, Überschrift der Hinweise, § 11 Abs. 3, § 4 Preiswortlaut', () => {
const html = read(V4), text = strip(html)
// Webmail als Zugang zu den Postfaechern, einmal genannt (§ 3 Abs. 4); Übersicht und A/B nennen keine Zugangswege
const p3 = html.slice(html.indexOf('id="p3"'), html.indexOf('id="p4"'))
const abs4 = strip(between(p3, '<h3>(4) E-Mail-Postfächer</h3>', '<h3>(5)'))
assert.ok(abs4.includes('Webmail'))
assert.equal(text.split('Webmail').length - 1, 1, 'Webmail genau einmal')
// Die Hinweise betreffen auch den Widerruf des Abos selbst: kurze Überschrift, Anker bleibt
assert.ok(html.includes('<h2 id="widerruf-hinweise">Hinweise zum Widerruf</h2>'))
assert.ok(html.includes('<a href="#widerruf-hinweise">Hinweise zum Widerruf</a>'))
assert.ok(!text.includes('Hinweise zum Widerruf bei Wechsel und Zusatzspeicher'))
// § 11 Abs. 3: nur die fuer den Kunden gespeicherten Daten werden geloescht; Vertrags- und Abrechnungsdaten
// bleiben nach der Datenschutzerklaerung (Nachweis drei Jahre, Rechnungen nach § 147 AO)
const p11 = absaetzeVon('p11', 'p12')
assert.equal(p11.length, 3)
assert.ok(p11[2].includes('löschen wir die Daten, die wir für den Kunden gespeichert haben (Website bzw. Anwendungsdaten und alle Postfächer)'))
assert.ok(p11[2].includes('Vertrags- und Abrechnungsdaten bewahren wir auf, wie es die Datenschutzerklärung beschreibt (project.webklar.com/datenschutz.html)'))
assert.ok(p11[2].includes('Sicherungskopien werden spätestens mit Ablauf ihrer Aufbewahrungszeit (§ 3 Abs. 5) überschrieben'))
assert.ok(!p11[2].includes('soweit keine gesetzliche Aufbewahrungspflicht besteht'))
// § 4 Abs. 6 und 7: derselbe Wortlaut wie an den anderen Fliesstext-Preisen
const p4 = absaetzeVon('p4', 'p5')
assert.equal(p4.length, 7)
assert.ok(p4[5].includes('100 € pro Stunde (Endpreis, gemäß § 19 UStG ohne Umsatzsteuer)'), '§ 4 Abs. 6')
assert.ok(p4[6].includes('100 € pro Stunde (Endpreis, gemäß § 19 UStG ohne Umsatzsteuer)'), '§ 4 Abs. 7')
})
// --- Datenschutzerklärung (Plan 2, Task 2) ---
const DS = 'public/datenschutz.html'
// Ein Satz ab seinem Anfang bis zum nächsten Satzende (in den geprüften Sätzen kommt kein "Art. 6" o. ä. vor)
const sentence = (s, start) => between(s, start, '. ') + '.'
test('Datenschutz: Abos, produktneutral, Stand wie termsVersion', () => {
const ds = strip(read(DS))
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 Zusage einer Ereignisansicht (Plan 2 baut keine); UptimeRobot ist kein Unterauftragsverarbeiter
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', 'Sie sehen diese Ereignisse', 'Unterauftragsverarbeiter']) assert.ok(!ds.includes(s), `noch da: ${s}`)
// Stand = Datum aus termsVersion (JJJJ-MM-TT-v4). Die Kopplung gilt bis zum Livegang: Task 20 trägt in beiden das
// Freigabedatum ein. Danach darf sie gelockert werden, sobald die Erklärung unabhängig von den Bedingungen geändert wird.
const m = /^(\d{4})-(\d{2})-(\d{2})-v4$/.exec(config.abo.termsVersion)
assert.ok(m, `termsVersion nicht im Format JJJJ-MM-TT-v4: ${config.abo.termsVersion}`)
const [, y, mo, d] = m
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'))
})
test('Datenschutz Abschnitt 3 und 12: UptimeRobot, Drittland USA, SCC im AVV mit Kopie', () => {
const ds = strip(read(DS))
// Nur die UptimeRobot-Absätze (nach dem Hetzner-Absatz), damit "Auftragsverarbeitung" wirklich UptimeRobot meint
const robot = between(ds, 'Ob unsere Websites', '4. Cookies')
for (const s of ['UptimeRobot s. r. o., Obchodná 507/2, 811 06 Bratislava (Slowakei)', 'Adressen (URLs)', 'Daten der Besucher',
'Auftragsverarbeitung', 'außerhalb der EU', 'insbesondere in den USA', 'Standardvertragsklauseln', 'Art. 46 Abs. 2 lit. c DSGVO',
'uptimerobot.com/dpa', 'Kopie', 'lit. b', 'lit. f', 'entfernen wir']) assert.ok(robot.includes(s), `UptimeRobot: ${s}`)
const empfaenger = between(ds, '12. Empfänger im Überblick', '13. Ihre Rechte')
assert.ok(empfaenger.includes('UptimeRobot s. r. o.'))
assert.ok(empfaenger.includes('(insbesondere USA)'))
})
test('Datenschutz Abschnitt 7: Nachweisdaten je Bestellung, Vertragsereignisse, Kündigungs- und Widerrufsseite', () => {
const abos = between(strip(read(DS)), '7. Abos und Zahlung über Stripe', '8. E-Mail')
// Nachweisdaten (mit IP und Browser) für Abschluss, Wechsel und Zusatzspeicher-Bestellung, im Portal und bei Stripe
const nachweis = between(abos, 'Um Ihre Bestellungen nachweisen', 'Außerdem')
for (const s of ['Version der Vertragsbedingungen', 'gegebenenfalls Ihre Angaben zum Leistungsbeginn', 'IP-Adresse', 'Browserkennung',
'bei Stripe', 'Abschluss eines Abos', 'Wechsel', 'Zusatzspeicher']) assert.ok(nachweis.includes(s), `Nachweis: ${s}`)
// Ereignis-Satz nennt Kündigung und Widerruf als Ereignisse (eine "Kündigungsbestätigung" genügt nicht)
const ereignisse = sentence(abos, 'Außerdem halten wir')
for (const re of [/[ :,]Abschluss[ ,.]/, /[ :,]Wechsel[ ,.]/, /[ :,]Zusatzspeicher[ ,.]/, /[ :,]Verringerungen[ ,.]/,
/[ :,]Kündigung[ ,.]/, /[ :,]Widerruf[ ,.]/]) assert.match(ereignisse, re)
// Verringerung, Kündigung, Widerruf: nur Zeitpunkt und Status (Task 8 speichert beim Minus keine IP/Browserkennung)
assert.ok(abos.includes('nur Zeitpunkt und Status, keine IP-Adresse und Browserkennung'))
assert.ok(abos.includes('Den Stand Ihrer Bestellungen, etwa laufende Widerrufsfristen und die Einrichtung von Zusatzspeicher, sehen Sie im Kundenportal unter „Verträge“.'))
assert.ok(abos.includes('Aufgabenliste, um Änderungen am Zusatzspeicher umzusetzen'))
assert.ok(sentence(abos, 'Diese Angaben löschen wir').includes('drei Jahre nach Vertragsende'))
// Öffentliche Kündigungs- und Widerrufsseite (§§ 312k, 356a BGB): Daten, Zweck, Rechtsgrundlage, IP nur zur Begrenzung
const formulare = between(abos, 'kuendigen.html', 'Abschnitt 9')
for (const s of ['widerrufen.html', 'ohne Anmeldung', 'Namen', 'E-Mail-Adresse', 'Kundennummer', 'Eingang', 'Bestätigung', 'Team',
'Art. 6 Abs. 1 lit. c DSGVO', '§§ 312k, 356a BGB', 'lit. b', 'IP-Adresse', 'Arbeitsspeicher', 'zehn',
'drei Jahre nach Vertragsende']) assert.ok(formulare.includes(s), `Formulare: ${s}`)
})
// --- Übernahmegutschrift, Prüftexte, Leitfaden (Plan 2, Task 3) ---
test('Übernahmegutschrift und Leitfaden: produktneutral', () => {
const app = read('public/app.js')
const cond = between(app, 'const TAKEOVER_CONDITIONS', ']')
assert.ok(!cond.includes('Wartungs-Abo') && cond.includes('Ihr Abo'), 'Bedingungen nennen „Ihr Abo“ statt des Wartungs-Abos')
assert.notEqual(config.takeover.conditionsVersion, '2026-09-25', 'conditionsVersion hochgezogen')
const tc = read('server/services/takeoverCredits.js')
assert.ok(!/Wartungs-Abo (läuft|ist beendet)|kein (laufendes )?Wartungs-Abo/.test(tc), 'Prüf-Labels und Fehlermeldungen sagen „Abo“')
const lf = read('docs/leitfaden-empfehlungsgeber.html')
assert.ok(lf.includes('Hosting-Abo') && lf.includes('beliebig viele Postfächer'), 'Leitfaden nennt beide Abos')
// Festlegungen zur Umsetzung: Postfächer im Plural (Wartungs-Abo: beliebig viele), Entwurfsdatum als Version,
// Karte im Portal und Checkliste ohne Produktnamen (Preisvergleich je Produkt: test/takeoverCredits.test.js)
assert.ok(cond.includes('E-Mail-Postfächer'))
assert.match(config.takeover.conditionsVersion, /^\d{4}-\d{2}-\d{2}$/)
const condHash = createHash('sha256').update(cond).digest('hex')
assert.equal(condHash, TAKEOVER_CONDITIONS_SHA256[config.takeover.conditionsVersion],
`Bedingungen der Übernahmegutschrift geändert: conditionsVersion hochziehen und Hash eintragen (${condHash})`)
assert.ok(app.includes('sobald Ihr Abo läuft') && !app.includes('sobald Ihr Wartungs-Abo läuft'))
assert.ok(!tc.includes('durch das Wartungs-Abo ersetzt'))
// Leitfaden: beide Preise mit § 19 UStG, Zusatzspeicher, Wechsel, Gutschrift für beide Abos, Bestellweg „Verträge“
const lfText = strip(lf)
for (const s of ['zusammen 5 GB', 'Zusatzspeicher', 'Wechsel', 'Übernahmegutschrift', 'beiden Abos', '„Verträge“']) assert.ok(lfText.includes(s), s)
for (const preis of ['15,00 €', '50,00 €', '1,00 €']) {
const i = lfText.indexOf(preis)
assert.ok(i >= 0, preis)
assert.ok(lfText.slice(i, i + 80).includes('§ 19 UStG'), `${preis} ohne § 19 UStG`)
}
assert.ok(!lfText.includes('1 E-Mail-Postfach und 1 Stunde'), 'Wartungs-Abo nicht mehr mit einem Postfach')
assert.ok(!lfText.includes('Startseite'), 'kein veralteter Bestellweg')
})