- server/config.js: amountCents 2000, lookup_key webklar_hosting_monatlich_2000 - ensurePrice haengt einen neuen Preis an das vorhandene Stripe-Produkt (metadata.webklar), statt ein zweites "WEBklar Hosting-Abo" anzulegen - abo-bedingungen.html (Uebersicht, Preise, Leistungsbeschreibung) + PDF, Leitfaden + PDF - Tests auf 20 € umgestellt (399/399) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
428 lines
30 KiB
JavaScript
428 lines
30 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-30': '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(/ /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 ['20,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 über „Verträge hier kündigen“ oder 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 Ablauf des Jahres, in dem Ihr letztes Abo endet'))
|
|
// Ö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 Ablauf des Jahres, in dem Ihr letztes Abo endet', 'Zahl der Pakete']) assert.ok(formulare.includes(s), `Formulare: ${s}`)
|
|
})
|
|
|
|
// --- Rechtsrunde 28.09.2026 (Spec § 14: Wechsel erst mit Zahlung, Zusatzspeicher teilweise kündbar, Aufbewahrung) ---
|
|
|
|
test('Rechtsrunde: Wechsel steht unter der Bedingung der Zahlung (§ 9 Abs. 3, § 2 Abs. 5, § 7 Abs. 4, Übersicht)', () => {
|
|
const wechsel = absaetzeVon('wechsel', 'zusatzspeicher')
|
|
assert.equal(wechsel.length, 5)
|
|
for (const s of ['Der Wechsel steht unter der Bedingung, dass die Wechselrechnung bezahlt wird.',
|
|
'Bei Zahlung per Kreditkarte wird er erst mit der erfolgreichen Zahlung wirksam.',
|
|
'Gelingt die Zahlung nicht, etwa weil der Kunde eine von seiner Bank verlangte Freigabe nicht bestätigt, wird er nicht wirksam; spätestens 23 Stunden nach der Bestellung verfällt die Wechselrechnung.',
|
|
'Bei SEPA-Lastschrift beginnt das Wartungs-Abo schon mit der Bestellung; wird die Lastschrift nicht eingelöst, gilt der Wechsel als nicht erfolgt.',
|
|
'läuft das Hosting-Abo mit Preis und Mindestlaufzeit wie vor dem Wechsel weiter; bei SEPA-Lastschrift bleibt der Abbuchungstag der Wechseltag; wir informieren den Kunden per E-Mail.',
|
|
'Das Wartungs-Abo beginnt, sobald der Wechsel wirksam ist',
|
|
// Leistung erst ab dem wirksamen Wechsel; Abbuchungstag und Mindestlaufzeit ab dem Bestelltag
|
|
'Ab Beginn des Wartungs-Abos gilt Leistungsbeschreibung B. Wechseltag ist der Tag der Bestellung; er ist der neue Abbuchungstag (§ 7 Abs. 4), und mit ihm beginnt eine neue Mindestlaufzeit von zwölf Monaten (§ 8 Abs. 1).',
|
|
'ab Beginn des Wartungs-Abos kann der Kunde weitere Postfächer beauftragen; wir richten sie ein, sobald die Wechselrechnung bezahlt ist.']) assert.ok(wechsel[2].includes(s), `§ 9 Abs. 3: ${s}`)
|
|
const p9 = v4Section('wechsel', 'zusatzspeicher')
|
|
for (const s of ['beginnt sofort mit dem Wechsel', 'Ab dem Wechseltag gilt Leistungsbeschreibung B', 'kommt er nicht zustande']) assert.ok(!p9.includes(s), `noch da: ${s}`)
|
|
// Wertersatz beim Widerruf des Wechsels erst ab Beginn des Wartungs-Abos, nicht ab der Bestellung
|
|
const hinweise = v4Section('widerruf-hinweise', 'avv')
|
|
assert.ok(hinweise.includes('für die Tage vom Beginn des Wartungs-Abos bis zum Widerruf'))
|
|
assert.ok(!hinweise.includes('vom Wechsel bis zum Widerruf'))
|
|
assert.ok(wechsel[1].includes('Wird der Wechsel wirksam, endet das Hosting-Abo im gegenseitigen Einvernehmen.'))
|
|
assert.ok(absaetzeVon('p2', 'p3')[4].includes('beim Wechsel steht er unter der Bedingung des § 9 Abs. 3 (Zahlung der Wechselrechnung)'))
|
|
assert.ok(absaetzeVon('p7', 'p8')[3].includes('Wird die Wechselrechnung nicht bezahlt, gilt § 9 Abs. 3.'))
|
|
assert.ok(absaetzeVon('p4', 'p5')[0].includes('mit der Zahlung der Wechselrechnung (§ 9 Abs. 3)'))
|
|
assert.ok(uebersichtZeile('Wechsel').includes('unter der Bedingung, dass die Wechselrechnung bezahlt wird'))
|
|
})
|
|
|
|
test('Rechtsrunde: Zusatzspeicher ganz oder teilweise kündbar, auch über „Verträge hier kündigen“ (§ 8 Abs. 7 und 8, § 10 Abs. 5, Übersicht)', () => {
|
|
const p8 = absaetzeVon('p8', 'wechsel')
|
|
assert.equal(p8.length, 8)
|
|
assert.ok(p8[6].includes('ganz oder teilweise (mit einer geringeren Zahl von Paketen) kündigen'))
|
|
assert.ok(p8[7].includes('ganz oder mit der Zahl der Pakete, die er kündigt'))
|
|
assert.ok(p8[7].includes('auch, wenn das Abo schon gekündigt ist'))
|
|
assert.ok(p8[7].includes('Den Zeitpunkt, zu dem die Kündigung wirkt, bestätigen wir'), 'passt auch zur teilweisen Kündigung des Zusatzspeichers')
|
|
const abs5 = absaetzeVon('zusatzspeicher', 'p11')[4]
|
|
assert.ok(abs5.includes('Den Zusatzspeicher ganz oder teilweise kündigen kann er jederzeit über „Verträge hier kündigen“ mit der Auswahl „Nur den E-Mail-Zusatzspeicher“ und der Zahl der Pakete oder in Textform (§ 8 Abs. 8)'))
|
|
assert.ok(abs5.includes('nach einer Kündigung des Abos kann der Kunde die Zahl der Pakete nur noch über „Verträge hier kündigen“ oder in Textform verringern'))
|
|
assert.ok(uebersichtZeile('Kündigen').includes('ganz oder mit der Zahl der Pakete'))
|
|
assert.ok(uebersichtZeile('E-Mail-Zusatzspeicher').includes('ganz oder teilweise kündbar'))
|
|
})
|
|
|
|
test('Rechtsrunde Datenschutz: Aufbewahrung nach dem Ende des letzten Abos, Zahl der Pakete, kurzer UptimeRobot-Eintrag', () => {
|
|
const html = read(DS), ds = strip(html)
|
|
assert.equal(ds.split('drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet').length - 1, 2, 'Abschnitt 7 und Kündigungs-/Widerrufsseite')
|
|
for (const s of ['drei Jahre nach Vertragsende', 'nach dem Ende Ihres letzten Abos']) assert.ok(!ds.includes(s), `noch da: ${s}`)
|
|
// Abschnitt 6 (Kundenkonto) verweist für Abos und Bestellungen auf die Fristen in Abschnitt 7
|
|
assert.ok(between(ds, '6. Kundenportal', '7. Abos').includes('Für Ihre Abos und Bestellungen gelten die Fristen in Abschnitt 7.'))
|
|
// Übernahmegutschrift und Abschnitt 9 behalten ihre eigenen Fristen
|
|
assert.ok(ds.includes('sechs Monate nach der Entscheidung'))
|
|
assert.ok(ds.includes('acht Jahre auf (§ 147 AO, § 14b UStG), Handels- und Geschäftsbriefe sechs Jahre'))
|
|
assert.ok(between(ds, 'kuendigen.html', 'Abschnitt 9').includes('beim E-Mail-Zusatzspeicher die Zahl der Pakete, die Sie kündigen'))
|
|
// Abschnitt 12: der UptimeRobot-Eintrag etwa so lang wie seine Nachbarn, mit Drittland und Rolle
|
|
const empfaenger = [...html.slice(html.indexOf('12. Empfänger'), html.indexOf('13. Ihre Rechte')).matchAll(/<li>([\s\S]*?)<\/li>/g)].map((m) => strip(m[1]))
|
|
const robot = empfaenger.find((e) => e.startsWith('UptimeRobot'))
|
|
assert.ok(robot.includes('Daten auch außerhalb der EU (insbesondere USA)') && robot.includes('Auftragsverarbeiter') && robot.includes('Abschnitt 3'), robot)
|
|
assert.ok(robot.length <= 135, `UptimeRobot-Eintrag zu lang: ${robot.length} Zeichen`)
|
|
})
|
|
|
|
// --- Ü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 ['20,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')
|
|
})
|
|
|
|
test('§ 13 Abs. 3: Datenverlust-Klausel mit Ausnahme für Absatz 1 (Vorsatz, grobe Fahrlässigkeit)', () => {
|
|
const html = readFileSync(new URL('../public/abo-bedingungen.html', import.meta.url), 'utf8')
|
|
assert.ok(html.includes('Außer in den Fällen von Absatz 1 haften wir für den Verlust von Daten'))
|
|
})
|