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(/^