Merge kn/recht (P8 Rechtsrunde Fassung 6) in feature/kundenansicht-neu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
72c5fa9fea
39 files changed
+5772
-35
No files matched your search
@@ -0,0 +1,128 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
const { config } = await import('../server/config.js')
|
||||
const ENTWURF = true
|
||||
const lies = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8')
|
||||
|
||||
const PFLICHT = [ // stelle = Überschrift im Entwurf; von/bis = Anker-Ids der Seite (null = Dokumentanfang bzw. -ende)
|
||||
{ stelle: 'Das Wichtigste auf einen Blick', von: null, bis: 'p1', texte: ['Das Hosting-Abo enthält die Website-Analyse, das Wartungs-Abo zusätzlich News-Pinnwand, Terminplaner, Webshop, Buchungskalender und Öffnungszeiten live. Das Einschalten kostet nichts extra; die Einrichtung geht vom Stundenkontingent ab (§ 4 Abs. 8).'] },
|
||||
{ stelle: '§ 1 Abs. 2', von: 'p1', bis: 'p2', texte: ['Die Bestellseiten richten sich nach der Kundenart, die wir im Kundenkonto hinterlegt haben („Betrieb“ oder „privat“). Wir hinterlegen sie nach den Angaben des Kunden; ist keine hinterlegt, gilt der Kunde als privat. Ob der Kunde Verbraucher oder Unternehmer ist, richtet sich nach dem Zweck, zu dem er den Vertrag abschließt, nicht nach dieser Angabe.'] },
|
||||
{ stelle: '§ 2 Abs. 2 c', von: 'p2', bis: 'p3', texte: ['Direkt über der Schaltfläche steht der Hinweis, dass diese Bedingungen mit den Leistungsbeschreibungen, dem Auftragsverarbeitungsvertrag (Anlage 1) und der Widerrufsbelehrung gelten, jeweils mit Fassung und Link; die Unterlagen erhält der Kunde nach der Bestellung zusätzlich als PDF per E-Mail. Ist die Kundenart „privat“ hinterlegt, kann der Kunde dort durch ein Kästchen verlangen, dass wir sofort mit der Leistung beginnen; ohne das Kästchen beginnen wir erst nach Ablauf der 14-tägigen Widerrufsfrist. Ist die Kundenart „Betrieb“ hinterlegt, beginnen wir mit Vertragsschluss, ohne dass der Kunde etwas markieren muss.'] },
|
||||
{ stelle: '§ 2 Abs. 6', von: 'p2', bis: 'p3', texte: ['Wir speichern außerdem den Wortlaut der angezeigten Hinweise und Kästchen, die hinterlegte Kundenart sowie Zeit, IP-Adresse und Browserkennung des Klicks.'] },
|
||||
{ stelle: '§ 3 Abs. 8', von: 'p3', bis: 'p4', texte: [
|
||||
'Zum Abo gehören Funktionen, die der Kunde im Kundenportal unter „Funktionen“ einschaltet. Das Hosting-Abo enthält die Website-Analyse. Das Wartungs-Abo enthält News-Pinnwand, Terminplaner, Webshop, Buchungskalender, Öffnungszeiten live und Website-Analyse. Umfang, Voraussetzungen und Pflichten jeder Funktion beschreiben ihre Leistungsbeschreibung und, soweit die Funktion personenbezogene Daten im Auftrag des Kunden verarbeitet, ihr Auftragsverarbeitungsvertrag, jeweils in der Fassung, die das Kundenportal beim Einschalten anzeigt. Eine Funktion kostet keine zusätzliche Gebühr; die Einrichtung richtet sich nach § 4 Abs. 8 und 9.',
|
||||
'Vorbehaltlich § 11 Abs. 4 läuft eine Funktion nur, solange das Abo läuft, das sie enthält. Das gilt auch für eine Funktion, die wir auf Wunsch des Kunden schon bei der Erstellung seiner Website eingebaut haben. Funktionen, die der Kunde vor Veröffentlichung dieser Fassung einzeln gekauft hat, laufen nach den Bedingungen weiter, unter denen er sie gekauft hat.'] },
|
||||
{ stelle: '§ 4 Abs. 2', von: 'p4', bis: 'p5', texte: ['Einen Vorschuss nach Absatz 8 gleichen die folgenden Kontingente zuerst aus.'] },
|
||||
{ stelle: '§ 4 Abs. 8', von: 'p4', bis: 'p5', texte: ['Die Einrichtung einer Funktion rechnen wir als Arbeitszeit auf das Stundenkontingent an. Angerechnet wird die tatsächlich aufgewendete Zeit nach Absatz 4. Reicht das verfügbare Kontingent nicht aus, führen wir die Einrichtung trotzdem ohne weitere Rückfrage aus. Die fehlende Zeit ist ein Vorschuss, den die folgenden Monatskontingente ausgleichen, sobald sie bereitstehen. Dafür berechnen wir nichts, weder als Mehraufwand nach Absatz 6 noch in einer gesonderten Rechnung. Endet das Wartungs-Abo, solange noch ein Vorschuss offen ist, verfällt der Rest; der Kunde schuldet ihn nicht.'] },
|
||||
{ stelle: '§ 4 Abs. 9', von: 'p4', bis: 'p5', texte: ['Im Hosting-Abo ist die Einrichtung der Website-Analyse für den Kunden kostenlos; ein Stundenkontingent gibt es dort nicht (Absatz 7). Lässt der Kunde eine Funktion auf seinen Wunsch schon bei der Erstellung seiner Website einbauen, rechnen wir die Einrichtung nicht über das Stundenkontingent ab, sondern über die Kosten des Projekts, wie wir es mit ihm vereinbart haben.'] },
|
||||
{ stelle: '§ 6 Abs. 6', von: 'p6', bis: 'p7', texte: ['Schaltet der Kunde die Website-Analyse ein, ist er für die Messung auf seiner Website Verantwortlicher. Er entfernt oder umgeht das Cookie-Banner nicht, bindet ohne Abstimmung mit uns keine weiteren Mess- oder Tracking-Werkzeuge ein und ergänzt seine Datenschutzerklärung um einen Hinweis auf die von uns erzeugte Zusatzseite „/datenschutz-analyse.html“.'] },
|
||||
{ stelle: '§ 9 Abs. 6', von: 'wechsel', bis: 'zusatzspeicher', texte: ['Will der Kunde im Hosting-Abo eine Funktion einschalten, die nur das Wartungs-Abo enthält, führt ihn das Kundenportal zum Wechsel. Nach Wirksamkeit des Wechsels schaltet er die Funktion in einem eigenen Vorgang ein. Der Wechsel bleibt eine eigene Bestellung nach Absatz 2.'] },
|
||||
{ stelle: '§ 11 Abs. 4', von: 'p11', bis: 'p12', texte: ['Mit dem Ende des Abos, das eine Funktion enthält, endet die Funktion; abweichend davon läuft Öffnungszeiten live noch 30 Tage unverändert weiter. Wir schalten sie ab und löschen ihre Daten nach den Fristen in ihrer Leistungsbeschreibung; die Messdaten der Website-Analyse löschen wir 60 Tage nach der Abschaltung. Beginnt innerhalb dieser Fristen wieder ein Abo, das die Funktion enthält, schalten wir sie mit den vorhandenen Daten wieder ein. Wechselt der Kunde vom Wartungs- ins Hosting-Abo, bleibt die Website-Analyse; die übrigen Funktionen enden. Das Gleiche gilt, wenn der Kunde den Wechsel ins Wartungs-Abo widerruft.'] },
|
||||
{ stelle: '§ 14 Abs. 2', von: 'p14', bis: 'p15', texte: ['Für Funktionen, die personenbezogene Daten im Auftrag des Kunden verarbeiten, gilt zusätzlich der Auftragsverarbeitungsvertrag der Funktion, dem der Kunde beim Einschalten mit dem Klick auf „Einschalten“ zustimmt.'] },
|
||||
{ stelle: '§ 15', von: 'p15', bis: 'p16', texte: ['Fassung 6 gilt für Abos, die ab ihrer Veröffentlichung abgeschlossen werden. Für Abos, die nach einer früheren Fassung abgeschlossen wurden, gelten diese Fassungen weiter. Schaltet der Kunde mit einem solchen Abo eine Funktion ein, gelten für die Funktion ihre Leistungsbeschreibung und ihr Auftragsverarbeitungsvertrag in der angezeigten Fassung, einschließlich der Regeln zur Einrichtung und zum Stundenkontingent.'] },
|
||||
{ stelle: 'Leistungsbeschreibung A Nr. 1', von: 'hosting', bis: 'wartung', texte: ['Website-Analyse im Kundenportal (§ 3 Abs. 8)'] },
|
||||
{ stelle: 'Leistungsbeschreibung B Nr. 1', von: 'wartung', bis: 'widerruf', texte: ['Funktionen im Kundenportal: News-Pinnwand, Terminplaner, Webshop, Buchungskalender, Öffnungszeiten live und Website-Analyse (§ 3 Abs. 8)'] },
|
||||
{ stelle: 'Widerruf: Hinweise', von: 'widerruf-hinweise', bis: 'avv', texte: ['Das Einschalten einer Funktion ist kein eigener Vertrag, sondern Teil des Abos. Es verlängert die Widerrufsfrist des Abos nicht. Widerruft ein Verbraucher das Abo, endet auch die Funktion (§ 11 Abs. 4).'] },
|
||||
{ stelle: 'Anlage 3', von: 'sub', bis: null, texte: ['Server für die Website-Analyse (Messdienst, nur bei eingeschalteter Funktion)', 'Deutschland (Rechenzentrum Falkenstein)'] },
|
||||
]
|
||||
test('Entwurf Fassung 6: Stellen in Reihenfolge, jeder Pflichtsatz im Wortlaut unter seiner Stelle', () => {
|
||||
const md = lies('docs/rechtliches/abo-bedingungen-v6.md')
|
||||
assert.match(md, /^# Abo-Bedingungen Fassung 6 \(Entwurf\)\n/)
|
||||
assert.deepEqual([...md.matchAll(/^## (.+)$/gm)].map((m) => m[1]), [...PFLICHT.map((p) => p.stelle), 'Was gleich bleibt'])
|
||||
assert.equal(md.includes('Entwurf zur Freigabe, keine Rechtsberatung'), ENTWURF)
|
||||
for (const p of PFLICHT) {
|
||||
const teil = md.slice(md.indexOf(`## ${p.stelle}\n`)).split(/\n## /)[0]
|
||||
for (const t of p.texte) assert.ok(teil.includes(t), `${p.stelle}: ${t.slice(0, 50)}`)
|
||||
assert.match(teil, /\*\*Begründung\.\*\*/, p.stelle)
|
||||
}
|
||||
assert.doesNotMatch(md.slice(0, md.indexOf('## Was gleich bleibt')), /€/, 'die Pflichtsätze nennen keinen Preis')
|
||||
})
|
||||
|
||||
// Hilfen aus test/legalTexts.test.js (kein Import möglich)
|
||||
const strip = (html) => html.replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/&/g, '&').replace(/\s+/g, ' ').trim()
|
||||
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 V6 = 'public/neu/rechtliches/abo-bedingungen.html', V5A = 'public/vertragsunterlagen/v5/abo-bedingungen.html'
|
||||
const abschnitt = (html, von, bis) => {
|
||||
const a = von ? html.indexOf(`id="${von}"`) : 0
|
||||
assert.ok(a >= 0, `Anker #${von}`)
|
||||
const b = bis ? html.indexOf(`id="${bis}"`, a + 1) : html.length
|
||||
assert.ok(b > a, `Anker #${bis}`)
|
||||
return strip(html.slice(a, b))
|
||||
}
|
||||
test('v6-Seite: gleiche Anker wie Fassung 5, Absatzzahlen der geänderten Paragrafen', () => {
|
||||
const h = lies(V6), ids = (x) => [...x.matchAll(/ id="([^"]+)"/g)].map((m) => m[1])
|
||||
assert.deepEqual(ids(h), ids(lies(V5A)))
|
||||
const n = (von, bis) => [...h.slice(h.indexOf(`id="${von}"`), h.indexOf(`id="${bis}"`)).matchAll(/^ <li>([\s\S]*?)<\/li>$/gm)].length
|
||||
assert.deepEqual([['p1', 'p2'], ['p2', 'p3'], ['p4', 'p5'], ['p6', 'p7'], ['wechsel', 'zusatzspeicher'], ['p11', 'p12'], ['p14', 'p15']].map(([a, b]) => n(a, b)), [4, 6, 9, 6, 6, 4, 2])
|
||||
assert.equal((h.slice(h.indexOf('id="p3"'), h.indexOf('id="p4"')).match(/<h3>\(\d\)/g) ?? []).length, 8)
|
||||
})
|
||||
test('v6-Seite: Version, Stand, Entwurfsmarke und PDF-Link passen zu termsVersionNeu', () => {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})-v6$/.exec(config.abo.termsVersionNeu); assert.ok(m)
|
||||
const h = lies(V6), [, y, mo, d] = m
|
||||
assert.ok(strip(h).includes(`Version 6 · Stand ${d}.${mo}.${y}`))
|
||||
assert.ok(h.includes(`content: "Version 6 · Stand ${d}.${mo}.${y}"`), 'Kopfzeile im Druck')
|
||||
assert.ok(!strip(h).includes('Version 5'))
|
||||
assert.equal(strip(h).includes('Entwurf – Prüfung durch Kanzlei offen'), ENTWURF)
|
||||
assert.ok(h.includes('href="/neu/rechtliches/WEBklar-Abo-Vertragsunterlagen.pdf"') && !h.includes('href="/WEBklar-Abo-Vertragsunterlagen.pdf"'))
|
||||
})
|
||||
test('v6-Seite: jede Pflichtstelle steht im Wortlaut an ihrem Platz', () => {
|
||||
const h = lies(V6)
|
||||
for (const p of PFLICHT) for (const t of p.texte) assert.ok(abschnitt(h, p.von, p.bis).includes(t), `${p.stelle}: ${t.slice(0, 50)}`)
|
||||
assert.ok(strip(h).includes(`Funktionen ${PFLICHT[0].texte[0]}`), 'Zeile „Funktionen“ in der Übersichtstabelle')
|
||||
})
|
||||
test('v6-Seite: Widerrufsbelehrung und Muster-Formular wortgleich mit Fassung 5, Abo-Preise unverändert, Funktionen ohne Preis', () => {
|
||||
const h = lies(V6), v5 = lies(V5A)
|
||||
const muster = (x) => between(abschnitt(x, 'widerruf', 'formular'), 'Widerrufsrecht Sie haben das Recht', 'Ende der Widerrufsbelehrung')
|
||||
assert.equal(muster(h), muster(v5))
|
||||
const formular = (x) => between(abschnitt(x, 'formular', 'widerruf-hinweise'), 'Muster-Widerrufsformular', '(*) Unzutreffendes streichen.')
|
||||
assert.equal(formular(h), formular(v5))
|
||||
for (const s of ['20,00 €', '50,00 €', '1,00 €', '100 € pro Stunde', 'zwölf Monaten']) assert.ok(strip(h).includes(s), s)
|
||||
assert.doesNotMatch(abschnitt(h, 'p3', 'p4').split('(8) Funktionen')[1], /€/)
|
||||
})
|
||||
test('v6-Seite: neues Aussehen, keine alten Farben, Druckkopf', () => {
|
||||
const style = lies(V6).match(/<style>([\s\S]*?)<\/style>/)[1]
|
||||
for (const t of ['#1a1f36', '#5e6678', '#ebeef1', '#d8dde3', '#f8f9fb', '#111', '-apple-system', 'color-scheme: light']) assert.ok(style.includes(t), t)
|
||||
assert.doesNotMatch(style, /#6d1fd1|#9d3dfe|gradient|prefers-color-scheme|backdrop-filter|#f7f6f3/)
|
||||
})
|
||||
|
||||
test('v6-Seite: Querverweise treffen (16 Paragrafen, mindestens 15 Verweise, Leistungsbeschreibung A/B Nr.)', () => {
|
||||
const html = lies(V6)
|
||||
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 Paragraf: <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)
|
||||
assert.deepEqual([absaetze[3], absaetze[4], absaetze[6], absaetze[9], absaetze[11], absaetze[14], absaetze[15]], [8, 9, 6, 6, 4, 2, 2])
|
||||
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)
|
||||
})
|
||||
test('v6-Seite: Entwurf ist nicht indexierbar (N45)', () => {
|
||||
assert.equal(lies(V6).includes('<meta name="robots" content="noindex">'), ENTWURF)
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
const { config } = await import('../server/config.js')
|
||||
const { WORTLAUT_V6, FUNKTIONEN_FASSUNG_V6, FUNKTIONEN_UNTERLAGEN_V6, mitFassung } = await import('../server/services/products.js')
|
||||
|
||||
test('Linkzeilen: Wortlaut, Platzhalter wird gefüllt', () => {
|
||||
assert.equal(mitFassung(WORTLAUT_V6.linkzeile.bestellen, '2026-10-16-v6'), 'Es gelten die Vertragsbedingungen mit Leistungsbeschreibung, Auftragsverarbeitung und Widerrufsbelehrung (Fassung 2026-10-16-v6). Alles kommt danach auch als PDF per Mail.')
|
||||
assert.equal(mitFassung(WORTLAUT_V6.linkzeile.einschaltenMitAvv, '2026-10-16'), 'Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung 2026-10-16).')
|
||||
assert.equal(mitFassung(WORTLAUT_V6.linkzeile.einschaltenOhneAvv, '2026-10-16'), 'Es gilt die Leistungsbeschreibung (Fassung 2026-10-16).')
|
||||
assert.throws(() => mitFassung(WORTLAUT_V6.linkzeile.bestellen, ''), /Fassung/)
|
||||
assert.throws(() => mitFassung('ohne Platzhalter', '2026-10-16'), /Platzhalter/)
|
||||
})
|
||||
test('Häkchen beginn: Wortlaut je Fall (nur privat in der Frist)', () => {
|
||||
assert.deepEqual(WORTLAUT_V6.beginn, {
|
||||
abo: 'Bitte beginnt sofort mit dem Abo. Mir ist klar: Widerrufe ich innerhalb von 14 Tagen, zahle ich den bis dahin erbrachten Teil.',
|
||||
wechsel: 'Bitte beginnt sofort mit dem Wechsel. Mir ist klar: Widerrufe ich innerhalb von 14 Tagen, zahle ich den bis dahin erbrachten Teil.',
|
||||
einrichtung: 'Bitte beginnt sofort mit der Einrichtung. Mir ist klar: Mit der vollständigen Einrichtung erlischt mein Widerrufsrecht, und wenn ich vorher widerrufe, zahle ich den bis dahin erbrachten Teil.',
|
||||
})
|
||||
})
|
||||
test('Pflichten aus den alten Häkchen verantwortung, stripe, gesundheit', () => {
|
||||
assert.deepEqual(WORTLAUT_V6.hinweis, {
|
||||
shop: 'Du verkaufst in eigenem Namen und bist für deine Produkte, Preise und Rechtstexte verantwortlich. Die Zahlungen deiner Käufer laufen über dein eigenes Stripe-Konto.',
|
||||
unterkunft: 'Du vermietest in eigenem Namen und bist für Preise, Stornobedingungen, Rechnungen und Meldepflichten verantwortlich. Online-Zahlungen deiner Gäste laufen über dein eigenes Stripe-Konto.',
|
||||
termine: 'Der Terminplaner ist nicht für den Gesundheitsbereich gedacht. Das fragen wir im Fragebogen ab.',
|
||||
})
|
||||
})
|
||||
test('alle Wortlaute: du-Form, kein Preis, nur {fassung} als Platzhalter, tief eingefroren', () => {
|
||||
const alle = JSON.stringify(WORTLAUT_V6)
|
||||
assert.doesNotMatch(alle, /€|\bSie\b|\bIhr(e[nmrs]?)?\b/)
|
||||
assert.deepEqual([...alle.matchAll(/\{[a-z]+\}/g)].map((m) => m[0]).filter((x) => x !== '{fassung}'), [])
|
||||
for (const t of Object.values(WORTLAUT_V6.linkzeile)) assert.equal((t.match(/\{fassung\}/g) ?? []).length, 1)
|
||||
for (const o of [WORTLAUT_V6, WORTLAUT_V6.linkzeile, WORTLAUT_V6.beginn, WORTLAUT_V6.hinweis]) assert.ok(Object.isFrozen(o))
|
||||
})
|
||||
test('Funktionsunterlagen Fassung 6: Reihenfolge, Vorschaupfad, AVV nur wo Daten im Auftrag verarbeitet werden', () => {
|
||||
assert.equal(FUNKTIONEN_FASSUNG_V6, '2026-10-16')
|
||||
assert.deepEqual(Object.keys(FUNKTIONEN_UNTERLAGEN_V6), ['news', 'termine', 'shop', 'unterkunft', 'zeiten', 'analyse'])
|
||||
assert.deepEqual(FUNKTIONEN_UNTERLAGEN_V6.termine.lb, { version: '2026-10-16', url: '/neu/rechtliches/leistungsbeschreibung-terminplaner-2026-10-16.html', pdfFile: 'neu/rechtliches/leistungsbeschreibung-terminplaner-2026-10-16.pdf', anhangName: 'Leistungsbeschreibung-Terminplaner-2026-10-16.pdf' })
|
||||
const slugs = Object.values(FUNKTIONEN_UNTERLAGEN_V6).flatMap((u) => [u.lb, u.avv]).filter(Boolean).map((d) => d.url.replace(/^\/neu\/rechtliches\/|-2026-10-16\.html$/g, ''))
|
||||
assert.deepEqual(slugs, ['leistungsbeschreibung-news', 'leistungsbeschreibung-terminplaner', 'avv-terminplaner', 'leistungsbeschreibung-webshop', 'avv-webshop', 'leistungsbeschreibung-unterkunft', 'avv-unterkunft', 'leistungsbeschreibung-zeiten', 'leistungsbeschreibung-website-analyse', 'avv-website-analyse'])
|
||||
assert.deepEqual([FUNKTIONEN_UNTERLAGEN_V6.news.avv, FUNKTIONEN_UNTERLAGEN_V6.zeiten.avv], [null, null])
|
||||
assert.deepEqual(Object.values(FUNKTIONEN_UNTERLAGEN_V6).flatMap((u) => [u.lb, u.avv]).filter(Boolean).map((d) => d.anhangName.replace('-2026-10-16.pdf', '')), ['Leistungsbeschreibung-News-Pinnwand', 'Leistungsbeschreibung-Terminplaner', 'AVV-Terminplaner', 'Leistungsbeschreibung-Webshop', 'AVV-Webshop', 'Leistungsbeschreibung-Buchungskalender', 'AVV-Buchungskalender', 'Leistungsbeschreibung-Oeffnungszeiten-live', 'Leistungsbeschreibung-Website-Analyse', 'AVV-Website-Analyse'])
|
||||
})
|
||||
test('Verdrahtung mit P5 (läuft erst nach dem Merge von kn/server; rot, bis der Controller verdrahtet hat)', { skip: config.aboFunktionen === undefined && 'config.aboFunktionen fehlt: P5 noch nicht gemerged' }, async () => {
|
||||
const reg = await import('../server/services/featureRegistry.js')
|
||||
assert.equal(config.aboFunktionen.fassung, FUNKTIONEN_FASSUNG_V6)
|
||||
assert.equal(config.aboFunktionen.texte.beginn, WORTLAUT_V6.beginn.einrichtung)
|
||||
for (const key of ['termine', 'shop', 'unterkunft']) assert.equal(reg.consentAbo(key).items[0].text, mitFassung(WORTLAUT_V6.linkzeile.einschaltenMitAvv, FUNKTIONEN_FASSUNG_V6), key)
|
||||
for (const key of ['news', 'zeiten']) assert.equal(reg.consentAbo(key).items[0].text, mitFassung(WORTLAUT_V6.linkzeile.einschaltenOhneAvv, FUNKTIONEN_FASSUNG_V6), key)
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
|
||||
// Rechtstexte der Funktionen in Fassung 6 (Entwurf): Leistungsbeschreibungen und AVV ohne Preise (Tasks 7 bis 10).
|
||||
|
||||
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' })
|
||||
const { render, funktionen6Dokumente } = await import('../scripts/build-legal-html.mjs')
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url))
|
||||
const lies = (rel) => readFileSync(join(ROOT, rel), 'utf8')
|
||||
const ENTWURF = true
|
||||
|
||||
const B = { // Bausteine; {N} = Nominativ, {G} = Genitiv der Partei (Kunde/Kunden, Händler/Händlers, Vermieter/Vermieters)
|
||||
geltung: 'Diese Leistungsbeschreibung gilt, wenn der {N} die Funktion im Kundenportal einschaltet und die dort angezeigte Zeile mit dem Klick auf „Einschalten“ bestätigt. Sie gilt für Funktionen, die ab ihrer Veröffentlichung eingeschaltet werden; hat der {N} die Funktion zuvor einzeln gekauft, gilt für sie weiter die Leistungsbeschreibung, unter der er sie gekauft hat.',
|
||||
voraussetzung: 'Die Funktion ist Teil des Wartungs-Abos. Sie setzt ein laufendes Wartungs-Abo voraus; ein Hosting-Abo genügt nicht.',
|
||||
einrichtung: 'Die Einrichtung der Funktion rechnet WEBklar als Arbeitszeit auf das Stundenkontingent des Wartungs-Abos an (§ 4 der Abo-Bedingungen). Reicht das verfügbare Kontingent nicht aus, führt WEBklar die Einrichtung trotzdem aus. Die fehlende Zeit ist ein Vorschuss, den die folgenden Monatskontingente ausgleichen. Eine zusätzliche Rechnung gibt es dafür nicht. Endet das Wartungs-Abo, solange noch ein Vorschuss offen ist, verfällt der Rest zugunsten des {G}.',
|
||||
projekt: 'Hat WEBklar die Funktion auf Wunsch des {G} schon bei der Erstellung seiner Website eingebaut, richtet sich die Abrechnung der Einrichtung nach der Vereinbarung zum Projekt und nicht nach dem Stundenkontingent. Auch dann läuft die Funktion nur, solange das Wartungs-Abo läuft.',
|
||||
mehraufwand: 'Arbeiten, die nicht zur Einrichtung gehören, führt WEBklar nur nach Absprache vor der Ausführung und nach Freigabe des {G} in Textform aus; es gilt § 4 der Abo-Bedingungen.',
|
||||
widerruf: ['Das Einschalten der Funktion ist Teil des Abos und kein eigener Vertrag. Für den Widerruf gilt die Widerrufsbelehrung der Abo-Bedingungen. Widerruft ein Verbraucher das Abo oder den Wechsel ins Wartungs-Abo, endet die Funktion.',
|
||||
'Hat der {N} die Funktion zuvor einzeln gekauft, bleibt die Widerrufsbelehrung maßgeblich, die er bei der Bestellung erhalten hat.'],
|
||||
ende: 'Die Funktion läuft, solange das Wartungs-Abo läuft. Endet es, auch durch einen Wechsel ins Hosting-Abo oder durch den Widerruf eines Wechsels, schaltet WEBklar die Funktion spätestens am folgenden Tag ab.', // alle Funktionen außer Öffnungszeiten live (R8: Tagesjob)
|
||||
endeZeiten: 'Die Funktion läuft, solange das Wartungs-Abo läuft. Endet es, auch durch einen Wechsel ins Hosting-Abo, läuft die Funktion noch 30 Tage unverändert weiter; danach schaltet WEBklar sie ab.', // nur Öffnungszeiten live: wie das bestehende Zeiten-Modul, P6 (Mail K40) und P4 (Text Z2); kein Behalten gegen Entgelt (R8, mit P4/P6 abgestimmt)
|
||||
}
|
||||
const PARTEI = { kunde: { N: 'Kunde', G: 'Kunden' }, haendler: { N: 'Händler', G: 'Händlers' }, vermieter: { N: 'Vermieter', G: 'Vermieters' } }
|
||||
const bau = (t, p) => (Array.isArray(t) ? t.map((x) => bau(x, p)) : t.replaceAll('{N}', PARTEI[p].N).replaceAll('{G}', PARTEI[p].G))
|
||||
// „Haken“ der Gäste im Widget (Terminplaner) und „Widerrufsbelehrung für Gäste“ bleiben erlaubt; verboten sind die Wörter der alten Bestellung
|
||||
const VERBOTEN = /€|per Haken|Haken diesem|Zahlungspflichtig|Einrichtungspreis|Gesamtpreis|Bestellung der Einrichtung|im Kundenportal bestellt|Einrichtung von Funktionen|ohne Wartungs-Abo|auch im Hosting-Abo|Hosting-Abo, Wartungs-Abo oder|Vertrag über den Betrieb (der|seiner) Website|solange WEBklar die Website|[a-zäöüß,;:] Sie\b|\bIhr(e[nmrs]?)?\b/ // „Sie“ am Satzanfang ist das Pronoun für die Funktion („Sie setzt ein laufendes Wartungs-Abo voraus“), erlaubt
|
||||
const LISTE = ['termine', 'news', 'zeiten', 'shop', 'unterkunft'] // Task 9 ergänzt analyse
|
||||
const LB = { // frist = Satzteil, der die Löschfrist nennt (C12); pflicht = funktionsspezifischer Pflichtsatz
|
||||
termine: { slug: 'leistungsbeschreibung-terminplaner', partei: 'kunde', alt: 'docs/rechtliches/leistungsbeschreibung-terminplaner.md', frist: '30 Tage nach dem Abschalten',
|
||||
pflicht: 'Der Fragebogen fragt die Branche ab; wählt der Kunde den Gesundheitsbereich, ist der Terminplaner für ihn nicht verfügbar.' },
|
||||
news: { slug: 'leistungsbeschreibung-news', partei: 'kunde', frist: 'Beiträge und Fotos bleiben nach dem Abschalten 30 Tage gespeichert und werden danach gelöscht; beginnt in dieser Zeit wieder ein Wartungs-Abo, schaltet WEBklar die Pinnwand mit den vorhandenen Beiträgen ohne weitere Kosten wieder ein.',
|
||||
pflicht: 'Der Kunde ist für die Inhalte seiner Beiträge und Fotos verantwortlich. Er versichert, dass er die nötigen Rechte an Texten und Fotos hat und dass die Inhalte keine Rechte Dritter verletzen, etwa Urheberrechte, Persönlichkeitsrechte oder das Recht am eigenen Bild abgebildeter Personen.',
|
||||
nr: ['1. Geltung', '2. Leistung', '3. Einrichtung', '4. Pflichten des Kunden', '5. Ende der Leistung', '6. Widerruf'] },
|
||||
zeiten: { slug: 'leistungsbeschreibung-zeiten', partei: 'kunde', frist: 'Die Eingaben des Kunden bleiben nach dem Abschalten 30 Tage gespeichert und werden danach gelöscht; beginnt schon in den 30 Tagen Weiterlauf wieder ein Wartungs-Abo, läuft die Funktion einfach weiter, beginnt es in den 30 Tagen danach, schaltet WEBklar die Funktion mit den vorhandenen Eingaben ohne weitere Kosten wieder ein.',
|
||||
nr: ['1. Geltung', '2. Leistung', '3. Einbau und Freischaltung', '4. Einrichtung', '5. Nicht enthalten', '6. Pflichten des Kunden', '7. Verfügbarkeit und Aktualität', '8. Haftung', '9. Ende der Leistung', '10. Änderungen und Einstellung', '11. Widerruf', '12. Hinweise'] },
|
||||
}
|
||||
LB.shop = { slug: 'leistungsbeschreibung-webshop', partei: 'haendler', alt: 'docs/rechtliches/leistungsbeschreibung-webshop.md', frist: '90 Tage nach dem Abschalten',
|
||||
pflicht: 'Der Händler verkauft in eigenem Namen. Für seine Produkte, Preise, Produktangaben und Rechtstexte (Allgemeine Geschäftsbedingungen, Widerrufsbelehrung, Impressum, Datenschutzerklärung) ist er selbst verantwortlich; WEBklar stellt Muster bereit und berät nicht rechtlich. Er verkauft keine der in Nr. 3 ausgeschlossenen Waren. Die Zahlungen seiner Käufer laufen über sein eigenes Stripe-Konto zu den Bedingungen von Stripe; WEBklar erhält und verwahrt kein Geld seiner Käufer und erhebt keine Umsatzbeteiligung.',
|
||||
nurBetrieb: 'Der Webshop steht nur Kunden zur Verfügung, deren Kundenart „Betrieb“ ist.' }
|
||||
LB.unterkunft = { slug: 'leistungsbeschreibung-unterkunft', partei: 'vermieter', alt: 'docs/rechtliches/leistungsbeschreibung-unterkunft.md', frist: '90 Tage nach dem Abschalten',
|
||||
pflicht: 'Der Vermieter vermietet in eigenem Namen; für Preise, Stornobedingungen, Rechnungen und Meldepflichten ist er verantwortlich. Online-Zahlungen seiner Gäste laufen, sofern er sie nutzt, über sein eigenes Stripe-Konto zu den Bedingungen von Stripe; WEBklar erhält und verwahrt kein Geld seiner Gäste und erhebt keine Umsatzbeteiligung. Rechnungen an Gäste stellt WEBklar nur aus, wenn der Vermieter in den Einstellungen des Buchungskalenders den Rechnungsauftrag erteilt hat (§ 14 Abs. 2 Satz 4 UStG); für die Richtigkeit von Preisen und Steuerangaben bleibt er verantwortlich.' }
|
||||
const abschnitte = (md) => md.split(/^## /m).slice(1).map((s) => ({ kopf: s.split('\n')[0], absaetze: s.split(/\n\n/).filter((b) => /^\(\d+\) /.test(b)) }))
|
||||
test('Leistungsbeschreibungen v6: Fassungszeile, Bausteine, Pflichtsatz, Frist, keine Preise', () => {
|
||||
for (const key of LISTE) {
|
||||
const e = LB[key], md = lies(`docs/rechtliches/${e.slug}-v6.md`)
|
||||
assert.match(md.split('\n')[2], new RegExp(`^\\*\\*Fassung 2026-10-16\\*\\*${ENTWURF ? ' · Entwurf – Prüfung durch Kanzlei offen' : ''} · Zusatzbedingungen zum Wartungs-Abo$`), key)
|
||||
assert.doesNotMatch(md, VERBOTEN, key)
|
||||
for (const t of ['geltung', 'voraussetzung', 'einrichtung', 'projekt', 'mehraufwand', key === 'zeiten' ? 'endeZeiten' : 'ende']) assert.ok(md.includes(bau(B[t], e.partei)), `${key}: Baustein ${t}`)
|
||||
if (key === 'zeiten') assert.doesNotMatch(md, /Karenz|Behalten|spätestens am folgenden Tag/, 'Zeiten: 30 Tage Weiterlauf wie das bestehende Modul, kein Behalten, nicht die Abschaltung am Folgetag')
|
||||
for (const t of bau(B.widerruf, e.partei)) assert.ok(md.includes(t), `${key}: Baustein widerruf`)
|
||||
if (e.pflicht) assert.ok(md.includes(e.pflicht), `${key}: Pflichtsatz`)
|
||||
assert.ok(md.includes(e.frist), `${key}: Frist`)
|
||||
if (e.nr) assert.deepEqual(abschnitte(md).map((a) => a.kopf), e.nr, key)
|
||||
}
|
||||
})
|
||||
test('Terminplaner v6: Überschriften wie Fassung 5 (Nr. 5 heißt „Einrichtung“), Absatzzahlen, auf die der AVV verweist', () => {
|
||||
const v5 = abschnitte(lies(LB.termine.alt)), v6 = abschnitte(lies('docs/rechtliches/leistungsbeschreibung-terminplaner-v6.md'))
|
||||
assert.deepEqual(v6.map((a) => a.kopf), v5.map((a) => (a.kopf === '5. Einrichtung und Preis' ? '5. Einrichtung' : a.kopf)))
|
||||
assert.deepEqual(v6.map((a) => a.absaetze.length), v5.map((a) => (a.kopf.startsWith('10. ') ? 2 : a.absaetze.length)), 'Absatzzahlen wie Fassung 5, Nr. 10 hat die zwei Absätze des Bausteins widerruf')
|
||||
assert.ok(v6[7].absaetze[0].includes(bau(B.ende, 'kunde')), 'Nr. 8 Abs. 1 ist das Ende (AVV Terminplaner Nr. 1 Abs. 2)')
|
||||
})
|
||||
test('Seiten der Gruppe sind genau render(Markdown)', () => {
|
||||
for (const key of LISTE) {
|
||||
const d = funktionen6Dokumente().find((x) => x.html.includes(`/${LB[key].slug}-2026-10-16.html`))
|
||||
assert.equal(lies(d.html), render(lies(d.md), d.meta), `${d.html}: node scripts/build-legal-html.mjs funktionen6 --nur=${d.doc}`)
|
||||
}
|
||||
})
|
||||
test('Entwurfsseiten der Gruppe: Entwurfsmarke in der Quelle und meta robots noindex in der Seite (N45)', () => {
|
||||
for (const key of LISTE) {
|
||||
const d = funktionen6Dokumente().find((x) => x.html.includes(`/${LB[key].slug}-2026-10-16.html`))
|
||||
assert.equal(lies(d.md).includes('Entwurf – Prüfung durch Kanzlei offen'), ENTWURF, d.md)
|
||||
assert.equal(lies(d.html).includes('<meta name="robots" content="noindex">'), ENTWURF, d.html)
|
||||
}
|
||||
})
|
||||
test('Webshop und Buchungskalender v6: Überschriften wie Fassung 5, Nr. 9 neu benannt, nur Wartungs-Abo, Ende mit dem Abo', () => {
|
||||
for (const key of ['shop', 'unterkunft']) {
|
||||
const e = LB[key], v5 = abschnitte(lies(e.alt)), md = lies(`docs/rechtliches/${e.slug}-v6.md`), v6 = abschnitte(md)
|
||||
assert.deepEqual(v6.map((a) => a.kopf), v5.map((a) => (a.kopf.startsWith('9. Preis') ? '9. Einrichtung, Abrechnung und Widerruf' : a.kopf)), key)
|
||||
const n9 = v6.find((a) => a.kopf.startsWith('9. '))
|
||||
assert.equal(n9.absaetze.length, 5, `${key}: Nr. 9 hat fünf Absätze (1 Einrichtung, 2 Dauer, 3 Projektstart, 4 Mehraufwand, 5 Widerruf)`)
|
||||
assert.ok(n9.absaetze[2].includes(bau(B.projekt, e.partei)) && n9.absaetze[4].includes(bau(B.widerruf, e.partei)[0]), key)
|
||||
assert.ok(n9.absaetze[0].includes('keine laufende Gebühr'), key)
|
||||
assert.ok([...md.matchAll(/Nr\. 9 Abs\. (\d+)/g)].every((m) => Number(m[1]) <= 5), `${key}: Verweise auf Nr. 9 treffen`)
|
||||
}
|
||||
assert.ok(lies(`docs/rechtliches/${LB.shop.slug}-v6.md`).includes(LB.shop.nurBetrieb))
|
||||
})
|
||||
const BA = {
|
||||
voraussetzung: 'Die Funktion ist in beiden Abos enthalten. Sie setzt ein laufendes Hosting-Abo oder Wartungs-Abo voraus.',
|
||||
einrichtung: 'Die Einrichtung der Website-Analyse ist für den Kunden kostenlos. WEBklar rechnet sie weder auf ein Stundenkontingent an noch berechnet WEBklar sie gesondert.',
|
||||
ende: 'Die Website-Analyse läuft, solange das Abo läuft, das sie enthält. Endet es, schaltet WEBklar sie spätestens am folgenden Tag ab. Wechselt der Kunde vom Wartungs- ins Hosting-Abo, bleibt die Website-Analyse; das Gleiche gilt, wenn er den Wechsel ins Wartungs-Abo widerruft.',
|
||||
widerruf: 'Das Einschalten der Website-Analyse ist Teil des Abos und kein eigener Vertrag. Für den Widerruf gilt die Widerrufsbelehrung der Abo-Bedingungen.',
|
||||
}
|
||||
test('Website-Analyse v6 (Leistungsbeschreibung): in beiden Abos, Einrichtung kostenlos, Ende mit dem Abo, Zielgruppenregel bleibt', () => {
|
||||
const md = lies('docs/rechtliches/leistungsbeschreibung-website-analyse-v6.md')
|
||||
assert.match(md.split('\n')[2], /^\*\*Fassung 2026-10-16\*\* · (Entwurf – Prüfung durch Kanzlei offen · )?Zusatzbedingungen zum Hosting-Abo und zum Wartungs-Abo$/)
|
||||
assert.doesNotMatch(md, /€|per Haken|[a-zäöüß,;:] Sie\b|Im Hosting-Abo ist die Website-Analyse nicht enthalten|Sie ist im Wartungs-Abo ohne Aufpreis|im Kundenportal bestellt/)
|
||||
assert.ok(md.includes(B.geltung.replaceAll('{N}', 'Kunde')))
|
||||
for (const t of Object.values(BA)) assert.ok(md.includes(t), t.slice(0, 40))
|
||||
for (const s of ['Sie ist im Hosting-Abo und im Wartungs-Abo ohne Aufpreis enthalten.', 'Der Kunde hat ein laufendes Hosting-Abo oder Wartungs-Abo.',
|
||||
'Der Kunde kann das Abo dann innerhalb von 30 Tagen nach der Mitteilung zum Zeitpunkt der Einstellung kündigen',
|
||||
'Weil für die Website-Analyse kein Aufpreis berechnet wird, ändert ihre Einstellung den Preis des Abos nicht.',
|
||||
'Richtet sich die Website auch gezielt an Kinder unter 16 Jahren']) assert.ok(md.includes(s), s) // letzter Satz: Zielgruppenregel (Nr. 3 Abs. 4) bleibt
|
||||
assert.deepEqual(abschnitte(md).map((a) => a.kopf), abschnitte(lies('docs/rechtliches/leistungsbeschreibung-website-analyse.md')).map((a) => a.kopf))
|
||||
})
|
||||
LB.analyse = { slug: 'leistungsbeschreibung-website-analyse', partei: 'kunde' } // nur für die Querverweise in Task 10
|
||||
const AVV = { // [alt, neu] je Datei; der Rest ab "## 1." ist sonst wortgleich mit der Bestandsfassung
|
||||
analyse: [['spätestens mit dem Ende des Wartungs-Abos; den Messdienst schaltet WEBklar dann spätestens am folgenden Tag ab', 'spätestens mit dem Ende des Abos, das die Website-Analyse enthält (Hosting-Abo oder Wartungs-Abo); endet nur das Wartungs-Abo und läuft ein Hosting-Abo weiter, läuft dieser Vertrag weiter. Den Messdienst schaltet WEBklar bei Vertragsende spätestens am folgenden Tag ab'],
|
||||
['(Ausschalten oder Ende des Wartungs-Abos)', '(Ausschalten oder Ende des Abos, das sie enthält)'],
|
||||
['Das Wartungs-Abo bleibt davon unberührt.', 'Das Abo bleibt davon unberührt.']],
|
||||
}
|
||||
const KOPF_AVV = ENTWURF ? '**Fassung 2026-10-16** · Entwurf – Prüfung durch Kanzlei offen' : '**Fassung 2026-10-16**'
|
||||
const AVV_FUNKTION = { termine: 'Terminplaner', shop: 'Webshop', unterkunft: 'Buchungskalender', analyse: 'Website-Analyse' }
|
||||
const avvVorbemerkung = (funktion, fassung) => `Der Kunde schließt diesen Vertrag, wenn er im Kundenportal die Funktion „${funktion}“ einschaltet und die dort angezeigte Zeile „Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung ${fassung})“ mit dem Klick auf „Einschalten“ bestätigt; diesem Vertrag und der Leistungsbeschreibung „${funktion}“ („Leistungsbeschreibung“) in der angezeigten Fassung stimmt er damit zu. Das Portal speichert den Wortlaut der angezeigten Zeile, die Fassungen, den Zeitpunkt, das Kundenkonto und die angemeldete Person. Anschließend sendet WEBklar dem Kunden eine E-Mail, die beide Texte in dieser Fassung als PDF enthält und auf sie verlinkt.`
|
||||
function pruefeAvv(key, slug) {
|
||||
const v5 = lies(`docs/rechtliches/${slug}.md`), v6 = lies(`docs/rechtliches/${slug}-v6.md`)
|
||||
assert.equal(v6.split('\n')[2], KOPF_AVV)
|
||||
assert.ok(v6.includes(`(1) ${avvVorbemerkung(AVV_FUNKTION[key], '2026-10-16')}`))
|
||||
assert.doesNotMatch(v6, /per Haken|Mit der Bestellung weist|Vertrag über den Betrieb der Website|keine Bestellung geworden/)
|
||||
let erwartet = v5.replace(/^\*\*Fassung \d{4}-\d{2}-\d{2}\*\*.*$/m, KOPF_AVV)
|
||||
erwartet = erwartet.replace(/^\(1\) Der Kunde schließt diesen Vertrag[^\n]*$/m, `(1) ${avvVorbemerkung(AVV_FUNKTION[key], '2026-10-16')}`)
|
||||
for (const [alt, neu] of AVV[key]) { assert.ok(erwartet.includes(alt), `Bestand enthält: ${alt.slice(0, 40)}`); erwartet = erwartet.replaceAll(alt, neu) }
|
||||
assert.equal(v6, erwartet, `${slug}-v6.md = Bestandsfassung mit genau diesen Ersetzungen`)
|
||||
}
|
||||
test('AVV Website-Analyse v6', () => { pruefeAvv('analyse', 'avv-website-analyse') })
|
||||
test('Analyse-Seiten sind genau render(Markdown)', () => {
|
||||
for (const doc of ['analyse-lb', 'analyse-avv']) {
|
||||
const d = funktionen6Dokumente().find((x) => x.doc === doc)
|
||||
assert.equal(lies(d.html), render(lies(d.md), d.meta), d.html)
|
||||
}
|
||||
})
|
||||
AVV.termine = [['Mit der Bestellung weist der Kunde WEBklar an', 'Mit dem Einschalten weist der Kunde WEBklar an'], ['aus denen keine Bestellung geworden ist', 'aus denen die Funktion nicht eingeschaltet wurde']]
|
||||
for (const [key, name] of [['shop', 'den Webshop'], ['unterkunft', 'den Buchungskalender']]) {
|
||||
AVV[key] = [['Mit der Bestellung weist der Kunde WEBklar an', 'Mit dem Einschalten weist der Kunde WEBklar an'],
|
||||
[`spätestens mit dem Ende des Vertrags, aufgrund dessen WEBklar die Website des Kunden betreibt; ${name} schaltet WEBklar dann zum Vertragsende ab`, `spätestens mit dem Ende des Wartungs-Abos; ${name} schaltet WEBklar dann spätestens am folgenden Tag ab`],
|
||||
['oder der Vertrag über den Betrieb der Website beendet', 'oder das Wartungs-Abo beendet'],
|
||||
['wieder ein Vertrag über den Betrieb der Website', 'wieder ein Wartungs-Abo']] // zweite Fundstelle in Nr. 9 Abs. 6 (Wiedereinschalten in der Frist)
|
||||
}
|
||||
test('AVV Terminplaner, Webshop, Buchungskalender v6: Bestandsfassung mit genau den Ersetzungen', () => {
|
||||
pruefeAvv('termine', 'avv-terminplaner'); pruefeAvv('shop', 'avv-webshop'); pruefeAvv('unterkunft', 'avv-unterkunft')
|
||||
})
|
||||
test('Querverweise zwischen AVV und Leistungsbeschreibung treffen', () => {
|
||||
for (const [key, avv] of [['termine', 'avv-terminplaner'], ['shop', 'avv-webshop'], ['unterkunft', 'avv-unterkunft'], ['analyse', 'avv-website-analyse']]) {
|
||||
const lb = abschnitte(lies(`docs/rechtliches/${LB[key].slug}-v6.md`)), text = lies(`docs/rechtliches/${avv}-v6.md`)
|
||||
for (const [ref, n, a] of text.matchAll(/Nr\. (\d+)(?: Abs\. (\d+))? der Leistungsbeschreibung/g)) {
|
||||
const abschnitt = lb.find((x) => x.kopf.startsWith(`${n}. `)); assert.ok(abschnitt, `${avv}: ${ref}`)
|
||||
if (a) assert.ok(Number(a) <= abschnitt.absaetze.length, `${avv}: ${ref}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
test('alle zehn Seiten sind genau render(Markdown), --check meldet nichts', () => {
|
||||
const lauf1 = spawnSync(process.execPath, ['--max-old-space-size=256', 'scripts/build-legal-html.mjs', 'funktionen6', '--check'], { cwd: ROOT, encoding: 'utf8', timeout: 60000 })
|
||||
assert.equal(lauf1.status, 0, lauf1.stdout + lauf1.stderr)
|
||||
assert.equal([...lauf1.stdout.matchAll(/^ok /gm)].length, 10)
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
// Entscheidungsliste, Kanzlei-Fragen, Ablauf nach der Freigabe und Datenschutz-Prüfpunkte der Rechtsrunde Fassung 6 (Task 11).
|
||||
const lies = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8')
|
||||
const ENTWURF = true
|
||||
const md = lies('docs/rechtliches/kundenansicht-entscheidungen.md'), dsg = lies('docs/rechtliches/datenschutz-kundenansicht-pruefpunkte.md')
|
||||
test('Entscheidungsliste: R1 bis R17, je Punkt Vorschlag, Alternative und Kanzlei-Frage', () => {
|
||||
const z = md.split('\n').filter((l) => /^\| R\d+ \|/.test(l)).map((l) => l.split('|').slice(1, -1).map((s) => s.trim()))
|
||||
assert.deepEqual(z.map((r) => r[0]), Array.from({ length: 17 }, (_, i) => `R${i + 1}`))
|
||||
for (const r of z) { assert.equal(r.length, 5); assert.ok(r.every(Boolean), r[0]); assert.match(r[4], /^(–|KQ\d+(, KQ\d+)*)$/, r[0]) }
|
||||
const kq = [...md.matchAll(/^### (KQ\d+): .+$/gm)].map((m) => m[1])
|
||||
assert.deepEqual(kq, Array.from({ length: 9 }, (_, i) => `KQ${i + 1}`))
|
||||
for (const r of z) for (const id of r[4].match(/KQ\d+/g) ?? []) assert.ok(kq.includes(id), `${r[0]} nennt ${id}`)
|
||||
assert.equal(md.includes('Entwurf zur Freigabe, keine Rechtsberatung'), ENTWURF)
|
||||
})
|
||||
test('R8 ist mit P4/P6 abgestimmt: Zeiten laufen 30 Tage weiter, die übrigen schalten am Folgetag ab, kein Behalten', () => {
|
||||
const [, punkt, vorschlag, alternative] = md.split('\n').find((l) => l.startsWith('| R8 |')).split('|').slice(1, -1).map((s) => s.trim())
|
||||
assert.match(punkt, /mit P4\/P6 abgestimmt/)
|
||||
for (const s of ['spätestens am Folgetag', 'Öffnungszeiten live läuft nach dem Ende noch 30 Tage unverändert weiter', 'kein Behalten']) assert.ok(vorschlag.includes(s), s)
|
||||
assert.doesNotMatch(vorschlag, /Karenz|entfällt/)
|
||||
assert.ok(alternative.includes('Öffnungszeiten live schalten wie die übrigen spätestens am Folgetag ab'))
|
||||
})
|
||||
test('Kanzlei-Fragen haben vier Teile', () => {
|
||||
for (const teil of md.split(/\n(?=### KQ\d+:)/).slice(1)) for (const k of ['**Frage.**', '**Fundstelle im Text.**', '**Unsere Annahme.**', '**Alternative.**']) assert.ok(teil.includes(k), `${teil.slice(0, 8)} ${k}`)
|
||||
})
|
||||
test('AVV-Struktur nennt A, B und C; Ablauf nach der Freigabe in fester Reihenfolge mit Schutzbefehlen', () => {
|
||||
const avv = md.slice(md.indexOf('## AVV-Struktur'), md.indexOf('## Ablauf nach der Freigabe'))
|
||||
for (const s of ['Option A', 'Option B', 'Option C', 'Anlage 1']) assert.ok(avv.includes(s), s)
|
||||
const a = md.slice(md.indexOf('## Ablauf nach der Freigabe'))
|
||||
const reihenfolge = ['Freigabe des Inhabers', 'ENTWURF = false', 'scripts/build-legal-html.mjs funktionen6', 'free -m', 'nice -n 10 scripts/build-legal-pdfs.sh abo6', 'nice -n 10 scripts/build-legal-pdfs.sh funktionen6', 'rechtliches-hochladen.mjs --probe', '--karten-anlegen', 'ein Aufruf je Dokument', '--freigabe=', '--pruefen']
|
||||
const pos = reihenfolge.map((s) => { const i = a.indexOf(s); assert.ok(i >= 0, s); return i })
|
||||
assert.deepEqual([...pos].sort((x, y) => x - y), pos, 'Reihenfolge')
|
||||
assert.ok(a.includes('Datum der Fassungen') && a.includes('Entwurfsmarke'), 'Datum setzen und Marken entfernen')
|
||||
})
|
||||
test('Datenschutz-Prüfpunkte D1 bis D8 mit gültiger Fundstelle in public/datenschutz.html', () => {
|
||||
assert.deepEqual([...dsg.matchAll(/^\*\*(D\d)\*\*/gm)].map((m) => m[1]), ['D1', 'D2', 'D3', 'D4', 'D5', 'D6', 'D7', 'D8'])
|
||||
const zeilen = lies('public/datenschutz.html').split('\n')
|
||||
const refs = [...dsg.matchAll(/public\/datenschutz\.html:(\d+)/g)].map((m) => Number(m[1]))
|
||||
assert.ok(refs.length >= 8 && refs.every((n) => n >= 1 && n <= zeilen.length))
|
||||
assert.ok(zeilen[119].includes('Zahlungspflichtig bestellen') && zeilen[118].includes('Leistungsbeginn') && zeilen[94].includes('Wartungs-Abo'), 'D1, D2, D5 zitieren die richtigen Zeilen')
|
||||
})
|
||||
@@ -448,6 +448,21 @@ test('Fassung 4 archiviert: HTML bis auf den PDF-Link bytegleich, PDF bytegleich
|
||||
assert.ok(archived.includes('Version 4 · Stand 30.09.2026'))
|
||||
})
|
||||
|
||||
// = `git show 7ae0ffd:public/abo-bedingungen.html | sha256sum` bzw. des PDFs (Fassung 5, Stand 30.09.2026)
|
||||
const V5 = 'public/vertragsunterlagen/v5/abo-bedingungen.html'
|
||||
const V5_SHA256 = '3b570eb2daed76d20fc5b8b9aea36a447f0481b21ac950452a82e454db45291e'
|
||||
const V5_PDF_SHA256 = 'a8344e83c4d8f5ed2610048a5ea9ca6b8d2082b5648e706ea1ef01c10087e37b'
|
||||
const V5_PDF_LINK = 'href="/WEBklar-Abo-Vertragsunterlagen.pdf"'
|
||||
const V5_ARCHIVE_PDF_LINK = 'href="/vertragsunterlagen/v5/WEBklar-Abo-Vertragsunterlagen.pdf"'
|
||||
test('Fassung 5 archiviert (vorbereitet für P10): HTML bis auf den PDF-Link bytegleich, PDF bytegleich', () => {
|
||||
const archived = read(V5)
|
||||
assert.equal(archived.split(V5_ARCHIVE_PDF_LINK).length, 2, 'genau ein Archiv-Link')
|
||||
assert.ok(!archived.includes(V5_PDF_LINK))
|
||||
assert.equal(createHash('sha256').update(archived.split(V5_ARCHIVE_PDF_LINK).join(V5_PDF_LINK)).digest('hex'), V5_SHA256)
|
||||
assert.equal(sha256('public/vertragsunterlagen/v5/WEBklar-Abo-Vertragsunterlagen.pdf'), V5_PDF_SHA256)
|
||||
assert.ok(archived.includes('Version 5 · Stand 30.09.2026'))
|
||||
})
|
||||
|
||||
// = `git show 3a0b008:public/vertragsunterlagen/funktionen-leistungsbeschreibung.html | sha256sum` (News-Leistungsbeschreibung, Stand 30.09.2026).
|
||||
// Bestellungen seit dem 30.09.2026 haben diesem Text zugestimmt; die geltende Datei (Stand 05.10.2026) ersetzt ihn nur für künftige Bestellungen.
|
||||
const NEWS_LB = 'public/vertragsunterlagen/funktionen-leistungsbeschreibung.html'
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
|
||||
// Generator der Rechtstexte: neuer Stil und Satz funktionen6 (Fassung 6, Entwurf). Ergänzt test/buildLegalHtml.test.js.
|
||||
|
||||
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' })
|
||||
const { render, lauf, funktionen6Dokumente } = await import('../scripts/build-legal-html.mjs')
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url))
|
||||
const F = '2026-10-16'
|
||||
const SLUGS = ['leistungsbeschreibung-news', 'leistungsbeschreibung-terminplaner', 'avv-terminplaner', 'leistungsbeschreibung-webshop', 'avv-webshop', 'leistungsbeschreibung-unterkunft', 'avv-unterkunft', 'leistungsbeschreibung-zeiten', 'leistungsbeschreibung-website-analyse', 'avv-website-analyse']
|
||||
test('funktionen6Dokumente: zehn Seiten unter public/neu/rechtliches, Quellen *-v6.md, Navigation im Vorschaupfad', () => {
|
||||
const d = funktionen6Dokumente()
|
||||
assert.deepEqual(d.map((x) => x.doc), ['news-lb', 'termine-lb', 'termine-avv', 'shop-lb', 'shop-avv', 'unterkunft-lb', 'unterkunft-avv', 'zeiten-lb', 'analyse-lb', 'analyse-avv'])
|
||||
assert.deepEqual(d.map((x) => [x.md, x.html]), SLUGS.map((s) => [`docs/rechtliches/${s}-v6.md`, `public/neu/rechtliches/${s}-${F}.html`]))
|
||||
for (const x of d) {
|
||||
assert.deepEqual([x.meta.stil, x.meta.fassung], ['neu', F])
|
||||
const hrefs = x.meta.nav.map((l) => l.href)
|
||||
assert.ok(hrefs.includes('/neu/rechtliches/abo-bedingungen.html') && hrefs.includes(`/${x.html.slice('public/'.length)}`), x.doc)
|
||||
assert.ok(hrefs.every((h) => !h.startsWith('/rechtliches/')), 'keine Verweise auf eingefrorene Seiten')
|
||||
}
|
||||
})
|
||||
test('render mit stil neu: weiß und schwarz ohne Lila und Verlauf; ohne stil bleibt die alte Hülle', () => {
|
||||
const meta = { fassung: F, titel: 'Prüfdokument', seitenkopf: 'Prüfdokument', nav: [{ href: '/dashboard.html', text: 'Portal' }] }
|
||||
const md = `# Titel\n\n**Fassung ${F}** · Entwurf – Prüfung durch Kanzlei offen\n\n## 1. Geltung\n\n(1) Text.\n`
|
||||
const neu = render(md, { ...meta, stil: 'neu' }), alt = render(md, meta)
|
||||
for (const t of ['#1a1f36', '#ebeef1', '#f8f9fb', '#111', '-apple-system']) assert.ok(neu.includes(t), t)
|
||||
assert.doesNotMatch(neu, /#6d1fd1|gradient|#f7f6f3/)
|
||||
assert.ok(alt.includes('#6d1fd1') && alt.includes('#f7f6f3'))
|
||||
assert.equal(neu.replace(/<style>[\s\S]*?<\/style>/, ''), alt.replace(/<style>[\s\S]*?<\/style>/, ''), 'nur die Hülle unterscheidet sich')
|
||||
assert.throws(() => render(md, { ...meta, stil: 'dunkel' }), /stil/)
|
||||
})
|
||||
test('lauf funktionen6: schreibt in neuen Ordner, --check, --nur, falscher Aufruf', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'legalv6-'))
|
||||
try {
|
||||
mkdirSync(join(dir, 'docs/rechtliches'), { recursive: true })
|
||||
for (const s of SLUGS) writeFileSync(join(dir, `docs/rechtliches/${s}-v6.md`), `# T\n\n**Fassung ${F}** · Entwurf\n\nText.\n`)
|
||||
const z = []; const ruf = (...a) => lauf(a, { root: dir, log: (x) => z.push(x), fehler: (x) => z.push(x) })
|
||||
assert.equal(ruf('funktionen6', '--check'), 1)
|
||||
assert.equal(ruf('funktionen6', '--nur=avv-webshop,news-lb'), 0)
|
||||
assert.deepEqual(z.filter((x) => x.startsWith('->')), [`-> public/neu/rechtliches/leistungsbeschreibung-news-${F}.html`, `-> public/neu/rechtliches/avv-webshop-${F}.html`])
|
||||
assert.equal(ruf('funktionen6'), 0); assert.equal(ruf('funktionen6', '--check'), 0)
|
||||
writeFileSync(join(dir, 'docs/rechtliches/avv-webshop-v6.md'), `# T\n\n**Fassung ${F}** · Entwurf\n\nGeändert.\n`)
|
||||
z.length = 0; assert.equal(ruf('funktionen6', '--check'), 1)
|
||||
assert.deepEqual(z.filter((x) => x.startsWith('ABWEICHT')), [`ABWEICHT public/neu/rechtliches/avv-webshop-${F}.html`])
|
||||
assert.equal(ruf('funktionen6', '--schreiben'), 2); assert.equal(ruf('shop', '--nur=x'), 2); assert.equal(ruf('funktionen6', '--nur=gibt-es-nicht'), 2)
|
||||
} finally { rmSync(dir, { recursive: true, force: true }) }
|
||||
})
|
||||
test('build-legal-pdfs.sh: Modi abo6 und funktionen6, all und erste Usage-Zeile unverändert, nie parallel', () => {
|
||||
const sh = readFileSync(join(ROOT, 'scripts/build-legal-pdfs.sh'), 'utf8')
|
||||
assert.match(sh, /^abo6\)$/m); assert.match(sh, /^funktionen6\)$/m)
|
||||
const abo6 = sh.slice(sh.indexOf('\nabo6)'), sh.indexOf('\nfunktionen6)'))
|
||||
assert.ok(abo6.includes('public/neu/rechtliches/abo-bedingungen.html') && abo6.includes('public/neu/rechtliches/WEBklar-Abo-Vertragsunterlagen.pdf') && abo6.includes('.sha256'))
|
||||
for (const s of SLUGS) assert.ok(sh.includes(s), s)
|
||||
assert.ok(sh.includes('FUNKTIONEN_FASSUNG_V6') && sh.includes('zuerst node scripts/build-legal-html.mjs funktionen6'))
|
||||
const all = sh.slice(sh.indexOf('\nall)'), sh.indexOf('\n*)'))
|
||||
assert.ok(!all.includes('neu/rechtliches') && all.includes(' pdf public/abo-bedingungen.html public/WEBklar-Abo-Vertragsunterlagen.pdf\n'), 'all druckt die Entwürfe nicht')
|
||||
assert.ok(sh.includes('Usage: $0 [all|analyse|termine|shop|unterkunft|datenschutz]') && sh.includes('abo6|funktionen6'))
|
||||
assert.match(sh, /^ {2}timeout 120 "\$CHROME" /m)
|
||||
assert.ok(!/ &$|^\s*wait\b|xargs -P/m.test(sh))
|
||||
assert.equal(spawnSync('bash', ['-n', join(ROOT, 'scripts/build-legal-pdfs.sh')], { encoding: 'utf8' }).status, 0)
|
||||
})
|
||||
test('PDFs der Fassung 6: erst nach der Freigabe vorhanden; wenn vorhanden, PDF, Prüfsumme der Seite daneben', { skip: !existsSync(join(ROOT, 'public/neu/rechtliches/WEBklar-Abo-Vertragsunterlagen.pdf')) && 'PDFs entstehen erst nach der Freigabe (Task 11, Ablauf)' }, () => {
|
||||
for (const p of ['abo-bedingungen.html', ...SLUGS.map((s) => `${s}-2026-10-16.html`)]) {
|
||||
const pdf = join(ROOT, 'public/neu/rechtliches', p === 'abo-bedingungen.html' ? 'WEBklar-Abo-Vertragsunterlagen.pdf' : p.replace('.html', '.pdf'))
|
||||
assert.equal(readFileSync(pdf).subarray(0, 5).toString(), '%PDF-', pdf)
|
||||
assert.equal(readFileSync(`${pdf}.sha256`, 'utf8').trim(), createHash('sha256').update(readFileSync(join(ROOT, 'public/neu/rechtliches', p))).digest('hex'), p)
|
||||
}
|
||||
})
|
||||
@@ -14,6 +14,9 @@ const {
|
||||
productView,
|
||||
contractAttachmentFor,
|
||||
regularStorageLine,
|
||||
FASSUNGEN,
|
||||
aktuelleAboFassung,
|
||||
isTermsV4OrNewer,
|
||||
} = await import('../server/services/products.js')
|
||||
|
||||
const line = (price, amount, proration = false) => ({
|
||||
@@ -136,6 +139,48 @@ test('Vertragsunterlagen je Version', () => {
|
||||
assert.equal(termsDocFor('2026-09-25-v2').version, '2026-09-25-v2', 'aeltere Fassung bleibt benannt')
|
||||
})
|
||||
|
||||
test('Fassungstabelle: Reihenfolge, Muster nur am Ende, v6 im Vorschaupfad', () => {
|
||||
assert.deepEqual(FASSUNGEN.map((f) => f.muster?.source ?? 'sonst'), ['-v6$', '-v5$', '-v4$', 'sonst'])
|
||||
assert.deepEqual(termsDocFor('2026-10-16-v6'), {
|
||||
version: '2026-10-16-v6', url: '/neu/rechtliches/abo-bedingungen.html',
|
||||
pdfFile: 'neu/rechtliches/WEBklar-Abo-Vertragsunterlagen.pdf', endParagraph: '§ 11',
|
||||
})
|
||||
for (const v of ['2026-10-16-v60', '2026-10-16-v6x', 'v6-2026-10-16', '2026-10-16-v66']) {
|
||||
assert.equal(termsDocFor(v).url, '/vertragsunterlagen/v3/abo-bedingungen.html', v)
|
||||
}
|
||||
const a = contractAttachmentFor('2026-10-16-v6')
|
||||
assert.equal(a.filename, 'WEBklar-Abo-Vertragsunterlagen.pdf')
|
||||
assert.ok(path.isAbsolute(a.path) && a.path.endsWith(path.join('public', 'neu', 'rechtliches', 'WEBklar-Abo-Vertragsunterlagen.pdf')))
|
||||
})
|
||||
test('Fassung 5 bleibt bis P10 an den heutigen Adressen, das Archiv ist vorbereitet', () => {
|
||||
const v5 = FASSUNGEN.find((f) => f.muster?.source === '-v5$')
|
||||
assert.equal(termsDocFor('2026-09-30-v5').url, '/abo-bedingungen.html')
|
||||
assert.deepEqual(Object.keys(termsDocFor('2026-09-30-v5')).sort(), ['endParagraph', 'pdfFile', 'url', 'version'])
|
||||
assert.deepEqual(v5.archiv, { url: '/vertragsunterlagen/v5/abo-bedingungen.html', pdfFile: 'vertragsunterlagen/v5/WEBklar-Abo-Vertragsunterlagen.pdf' })
|
||||
})
|
||||
test('isTermsV4OrNewer: v4, v5, v6 ja; v3, fehlende und fremde Endungen nein', () => {
|
||||
for (const v of ['2026-09-30-v4', '2026-09-30-v5', '2026-10-16-v6']) assert.equal(isTermsV4OrNewer(v), true, v)
|
||||
for (const v of ['2026-09-25-v3', '2026-09-25', null, undefined, '', '2026-10-16-v60', 7]) assert.equal(isTermsV4OrNewer(v), false, String(v))
|
||||
})
|
||||
test('aktuelleAboFassung: v6 nur mit neuer Ansicht', () => {
|
||||
// appwriteUserId gehört zur gültigen Kunden-Sitzung (N1, schalter.js istKundenSitzung)
|
||||
const k = (email, extra = {}) => ({ session: { customerId: 'c1', appwriteUserId: 'u1', email, ...extra } })
|
||||
const vorher = { ...config.kundenansichtNeu }
|
||||
try {
|
||||
Object.assign(config.kundenansichtNeu, { mode: 'off', pilotEmails: ['*'] })
|
||||
assert.equal(aktuelleAboFassung(k('a@example.invalid')), '2026-09-30-v5', 'off schlägt die Liste')
|
||||
Object.assign(config.kundenansichtNeu, { mode: 'pilot', pilotEmails: [] })
|
||||
assert.equal(aktuelleAboFassung(k('a@example.invalid')), '2026-09-30-v5', 'leere Liste lässt niemanden durch')
|
||||
config.kundenansichtNeu.pilotEmails = ['a@example.invalid']
|
||||
assert.equal(aktuelleAboFassung(k(' A@Example.INVALID ')), '2026-10-16-v6', 'Großschreibung und Leerraum')
|
||||
assert.equal(aktuelleAboFassung(k('b@example.invalid')), '2026-09-30-v5')
|
||||
assert.equal(aktuelleAboFassung(k('b@example.invalid', { viewAs: true, viewId: 'v1' })), '2026-10-16-v6', 'Mitarbeiter-Kundenansicht')
|
||||
Object.assign(config.kundenansichtNeu, { mode: 'on', pilotEmails: [] })
|
||||
assert.equal(aktuelleAboFassung(k('b@example.invalid')), '2026-10-16-v6')
|
||||
} finally { Object.assign(config.kundenansichtNeu, vorher) }
|
||||
assert.deepEqual([config.abo.termsVersion, config.abo.termsVersionNeu], ['2026-09-30-v5', '2026-10-16-v6'])
|
||||
})
|
||||
|
||||
test('Produktansicht enthält Leistungen ohne 1-Postfach-Regel bei Wartung', () => {
|
||||
const w = productView('wartung').features.flat().join(' ')
|
||||
assert.ok(w.includes('beliebig viele Postfächer') && !w.includes('1 E-Mail-Postfach'))
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
// Hochladen der Fassung 6 nach WOMS "Rechtliches" (scripts/rechtliches-hochladen.mjs): die Tests rufen main nur mit Nachbau-deps auf,
|
||||
// nie mit dem echten Schlüssel oder dem Netz.
|
||||
const { FUNKTIONEN_UNTERLAGEN_V6, FUNKTIONEN_FASSUNG_V6 } = await import('../server/services/products.js')
|
||||
const m = await import('../scripts/rechtliches-hochladen.mjs') // der Import selbst darf weder Schlüssel lesen noch das Netz nutzen
|
||||
test('parseArgs: ein Dokument je Aufruf, Hochladen nur mit --freigabe=Datum, --probe und --pruefen lesen nur, --karten-anlegen legt nur Karten an', () => {
|
||||
assert.deepEqual(m.parseArgs(['--probe']), { modus: 'probe', documentKey: null, freigabe: null })
|
||||
assert.deepEqual(m.parseArgs(['--karten-anlegen']), { modus: 'karten', documentKey: null, freigabe: null })
|
||||
assert.deepEqual(m.parseArgs(['--pruefen']), { modus: 'pruefen', documentKey: null, freigabe: null })
|
||||
assert.deepEqual(m.parseArgs(['avv-webshop', '--freigabe=2026-10-20']), { modus: 'hochladen', documentKey: 'avv-webshop', freigabe: '2026-10-20' })
|
||||
for (const bad of [[], ['avv-webshop'], ['--freigabe=2026-10-20'], ['avv-webshop', 'avv-terminplaner', '--freigabe=2026-10-20'], ['avv-webshop', '--freigabe=20.10.2026'], ['avv-webshop', '--freigabe=2026-13-40'], ['datenschutz', '--freigabe=2026-10-20'], ['--probe', 'avv-webshop'], ['--probe', '--freigabe=2026-10-20'], ['--karten-anlegen', 'avv-webshop'], ['--karten-anlegen', '--freigabe=2026-10-20'], ['--pruefen', '--probe'], ['--pruefen', 'avv-webshop']]) {
|
||||
assert.throws(() => m.parseArgs(bad), /Aufruf|Freigabe|Dokument/, JSON.stringify(bad))
|
||||
}
|
||||
assert.equal(typeof m.main, 'function')
|
||||
})
|
||||
test('Tabelle: elf Dokumente, Dateien im Vorschaupfad, passend zu FUNKTIONEN_UNTERLAGEN_V6', () => {
|
||||
assert.deepEqual(m.DOKUMENTE_V6.map((d) => d.documentKey), ['abo-bedingungen', 'leistungsbeschreibung-news', 'leistungsbeschreibung-terminplaner', 'avv-terminplaner', 'leistungsbeschreibung-webshop', 'avv-webshop', 'leistungsbeschreibung-unterkunft', 'avv-unterkunft', 'leistungsbeschreibung-zeiten', 'leistungsbeschreibung-website-analyse', 'avv-website-analyse'])
|
||||
assert.deepEqual(m.dokumentFuer('abo-bedingungen').dateien, ['public/neu/rechtliches/WEBklar-Abo-Vertragsunterlagen.pdf', 'public/neu/rechtliches/abo-bedingungen.html'])
|
||||
const unterlagen = Object.values(FUNKTIONEN_UNTERLAGEN_V6).flatMap((u) => [u.lb, u.avv]).filter(Boolean)
|
||||
assert.deepEqual(m.DOKUMENTE_V6.slice(1).map((d) => d.dateien), unterlagen.map((u) => [`public/${u.pdfFile}`, `public${u.url}`]))
|
||||
assert.deepEqual(m.DOKUMENTE_V6.map((d) => d.quelle), m.DOKUMENTE_V6.map((d) => d.dateien[1]), 'quelle = HTML-Pfad, der in source steht')
|
||||
assert.throws(() => m.dokumentFuer('datenschutz'), /Unbekanntes Dokument/)
|
||||
})
|
||||
test('planVersion: neue Fassung als vN, bisherige gültige wird abgelöst, doppelter Upload wird erkannt', () => {
|
||||
const alle = [{ $id: 'a', version: 'v1', validFrom: '2026-09-30', status: 'current', source: 'x' }, { $id: 'b', version: 'v0', validFrom: '2026-09-25', status: 'superseded', source: 'y' }]
|
||||
assert.deepEqual(m.planVersion({ alle, source: 's', validFrom: '2026-10-20' }), { schon: false, nummer: 'v3', aktuellId: 'a', validToAltes: '2026-10-19' })
|
||||
assert.equal(m.planVersion({ alle, source: 'x', validFrom: '2026-10-20' }).schon, true)
|
||||
assert.deepEqual(m.planVersion({ alle: [], source: 's', validFrom: '2026-10-20' }), { schon: false, nummer: 'v1', aktuellId: null, validToAltes: null })
|
||||
assert.equal(m.planVersion({ alle, source: 's', validFrom: '2026-09-30' }).validToAltes, '2026-09-30', 'gleicher Tag: validTo = validFrom wie in der Vorlage')
|
||||
assert.equal(m.tagVorher('2026-03-01'), '2026-02-28')
|
||||
})
|
||||
// Nachbau des WOMS-Backends für main(): Karten als Dokumente { $id, key, sort }, Versionen und Dateien leer; zählt alle Aufrufe
|
||||
function backend(karten) {
|
||||
const aufrufe = [], log = [], antwort = (obj) => ({ ok: true, status: 200, text: async () => JSON.stringify(obj) })
|
||||
const fetch = async (url, opt = {}) => {
|
||||
const methode = opt.method ?? 'GET', pfad = new URL(url).pathname.replace(/^\/v1/, ''); aufrufe.push(`${methode} ${pfad}`)
|
||||
if (methode === 'GET' && pfad.endsWith('/collections/legalDocuments/documents')) return antwort({ total: karten.length, documents: karten })
|
||||
if (methode === 'GET' && pfad.endsWith('/collections/legalVersions/documents')) return antwort({ total: 0, documents: [] })
|
||||
if (methode === 'GET' && pfad.startsWith('/storage/buckets/legal-files/files')) return antwort({ total: 0, files: [] })
|
||||
if (methode === 'POST' && pfad.endsWith('/collections/legalDocuments/documents')) { const b = JSON.parse(opt.body); karten.push({ $id: b.documentId, ...b.data }); return antwort({ $id: b.documentId }) }
|
||||
throw new Error(`unerwartet: ${methode} ${pfad}`)
|
||||
}
|
||||
return { karten, aufrufe, log, deps: { fetch, schluessel: () => 'test-schluessel', log: (z) => log.push(z) } }
|
||||
}
|
||||
const NUR_LESEN = (b) => b.aufrufe.every((a) => a.startsWith('GET '))
|
||||
const FEHLEN = ['leistungsbeschreibung-news', 'leistungsbeschreibung-website-analyse', 'avv-website-analyse'] // „leistungsbeschreibung-website-analyse“ hat 37 Zeichen, eine Dokument-ID darf höchstens 36 haben
|
||||
const kartenDoc = (key, i) => ({ $id: key.slice(0, 36), key, sort: i + 1 })
|
||||
const VORHANDEN = m.DOKUMENTE_V6.map((d) => d.documentKey).filter((k) => !FEHLEN.includes(k)) // die Karten laut WOMS scripts/setup-legal.mjs
|
||||
test('kartenPlan: genau die drei fehlenden Karten in Tabellenreihenfolge, mit zulässiger Dokument-ID', () => {
|
||||
const plan = m.kartenPlan({ vorhandeneKeys: VORHANDEN, hoechsteSort: 14 })
|
||||
assert.deepEqual(plan.map((k) => [k.daten.key, k.daten.sort]), [[FEHLEN[0], 15], [FEHLEN[1], 16], [FEHLEN[2], 17]])
|
||||
assert.deepEqual(plan.map((k) => k.daten.title), ['Leistungsbeschreibung News-Pinnwand', 'Leistungsbeschreibung Website-Analyse', 'AVV Website-Analyse'])
|
||||
assert.ok(plan.every((k) => k.daten.liveUrl.startsWith('https://project.webklar.com/neu/rechtliches/') && k.daten.liveUrl.endsWith(`-${FUNKTIONEN_FASSUNG_V6}.html`)))
|
||||
assert.ok(plan.every((k) => /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/.test(k.id)), 'Dokument-ID höchstens 36 Zeichen')
|
||||
assert.equal(plan[0].id, FEHLEN[0], 'kurze Schlüssel bleiben die ID, wie in setup-legal.mjs')
|
||||
assert.notEqual(plan[1].id, FEHLEN[1]); assert.equal(m.kartenId(FEHLEN[1]), plan[1].id, 'lange Schlüssel: abgeleitete ID, immer dieselbe')
|
||||
assert.deepEqual(m.kartenPlan({ vorhandeneKeys: m.DOKUMENTE_V6.map((d) => d.documentKey), hoechsteSort: 20 }), [])
|
||||
})
|
||||
test('--probe: eine fehlende Karte ist ein Stopp (Exit 1), mit allen Karten Exit 0; schreibt nie', async () => {
|
||||
const b = backend(VORHANDEN.map(kartenDoc))
|
||||
assert.equal(await m.main(['--probe'], b.deps), 1)
|
||||
for (const k of FEHLEN) assert.ok(b.log.includes(`STOPP: Karte fehlt: ${k}`), k)
|
||||
assert.ok(NUR_LESEN(b), b.aufrufe.join(', '))
|
||||
assert.equal(await m.main(['--probe'], backend(m.DOKUMENTE_V6.map((d, i) => kartenDoc(d.documentKey, i))).deps), 0)
|
||||
})
|
||||
test('--karten-anlegen: legt nur die drei fehlenden Karten an, ein zweiter Lauf tut nichts, danach ist --probe grün', async () => {
|
||||
const b = backend(VORHANDEN.map(kartenDoc))
|
||||
assert.equal(await m.main(['--karten-anlegen'], b.deps), 0)
|
||||
assert.deepEqual(b.aufrufe.filter((a) => !a.startsWith('GET ')), FEHLEN.map(() => 'POST /databases/woms-database/collections/legalDocuments/documents'))
|
||||
assert.deepEqual(b.karten.slice(VORHANDEN.length).map((k) => k.key), FEHLEN)
|
||||
b.aufrufe.length = 0
|
||||
assert.equal(await m.main(['--karten-anlegen'], b.deps), 0); assert.ok(NUR_LESEN(b))
|
||||
assert.equal(await m.main(['--probe'], b.deps), 0)
|
||||
})
|
||||
test('Hochladen ohne Karte bricht ab, bevor etwas geschrieben wird', async () => {
|
||||
const b = backend(VORHANDEN.map(kartenDoc))
|
||||
assert.equal(await m.main([FEHLEN[0], '--freigabe=2026-10-20'], b.deps), 1)
|
||||
assert.ok(b.log.some((z) => z.includes(`Karte fehlt: ${FEHLEN[0]}`)) && NUR_LESEN(b))
|
||||
})
|
||||
test('pruefeHochgeladen und --pruefen: alle elf Dokumente brauchen eine aktuelle Version mit passender Quelle', async () => {
|
||||
const ok = (d) => [{ $id: 'v', version: 'v2', status: 'current', source: `kundenbereich@abc1234:${d.quelle}` }]
|
||||
const alle = Object.fromEntries(m.DOKUMENTE_V6.map((d) => [d.documentKey, ok(d)]))
|
||||
assert.deepEqual(m.pruefeHochgeladen(alle), { fehlt: [] })
|
||||
assert.deepEqual(m.pruefeHochgeladen({ ...alle, 'avv-website-analyse': [] }).fehlt, ['avv-website-analyse'])
|
||||
assert.deepEqual(m.pruefeHochgeladen({ ...alle, 'abo-bedingungen': [{ ...alle['abo-bedingungen'][0], source: 'kundenbereich@7ae0ffd:public/abo-bedingungen.html' }] }).fehlt, ['abo-bedingungen'], 'Fassung 5 zählt nicht')
|
||||
assert.deepEqual(m.pruefeHochgeladen({ ...alle, 'leistungsbeschreibung-news': [{ ...alle['leistungsbeschreibung-news'][0], status: 'superseded' }] }).fehlt, ['leistungsbeschreibung-news'])
|
||||
assert.equal(m.pruefeHochgeladen({}).fehlt.length, 11)
|
||||
const b = backend([]) // Versionen leer: alle elf fehlen
|
||||
assert.equal(await m.main(['--pruefen'], b.deps), 1)
|
||||
assert.equal(b.log.filter((z) => z.startsWith('FEHLT ')).length, 11); assert.ok(NUR_LESEN(b))
|
||||
})
|
||||
Reference in new issue
Block a user