Files
Webklar-Kundenbereich/test/funktionenRechtV6.test.js

161 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
})