Kanzlei-Paket: Prüfbefunde (genauere Aussagen, Zitate zu Q4/Q8/Q21, Fundstellen)

Genauere Aussagen: Widerrufsfrist je Bestellung mit spätestem Fristende für
"rechtzeitig", Textform beim Wiedereinschalten nur ohne Bestellung, Storno
und kein_widerruf in G02 getrennt beschrieben, keine Ablehnung nach dem
Klick außer aus den Gründen der Leistungsbeschreibung Nr. 4 Abs. 3, Preis
nur mit echten Wortlauten.

Zitate: Q4 mit dem früheren News-Haken und der früheren Leistungsbeschreibung
(Stand 3a0b008, per SHA-256 gepinnt und mit Git abgeglichen), Q8 mit
Leistungsbeschreibung Nr. 9 Abs. 2 und 3, Q19 mit Nr. 5 Abs. 2 und der Preiszeile
von K15, Q21 mit dem gespeicherten Nachweis und der Chat-Vorlage des
Ticketsystems. Begriffe (Betriebsart, Pilot, Kulanz) erklärt, interne
Entscheidungsnummern entfernt, Anlage C (gerenderte Mails K15 und K16) angekündigt.

Tests: Zitate unter 25 Zeichen zählen nicht mehr als geprüft (benannte Liste,
Belehrung und Knopftexte werden streng geprüft), schärfere Fundstellen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-05 05:53:24 +00:00
1 parent 7050b63a37
commit 4dc447ed33
2 files changed
+212 -40

No files matched your search

+136 -4
View File
@@ -1,7 +1,11 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { existsSync, readFileSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { config } from '../server/config.js'
import { textformNachweisText } from '../server/services/siteFeatures.js'
import {
RECHTSTEXTE_VERSION,
GESUNDHEIT_HINWEIS,
@@ -60,7 +64,10 @@ function zitatBloecke(text) {
zeilen.forEach((zeile, i) => {
if (/^>/.test(zeile)) {
if (!aktuell) {
aktuell = { zeilen: [], start: i, ende: i }
// Zeile vor dem Block (die letzte nicht leere Zeile, die kein Zitat ist): Überschrift des Zitats
let k = i - 1
while (k >= 0 && zeilen[k].trim() === '') k--
aktuell = { zeilen: [], start: i, ende: i, ueberschrift: k >= 0 ? zeilen[k] : '' }
bloecke.push(aktuell)
}
aktuell.zeilen.push(zeile.replace(/^> ?/, ''))
@@ -84,7 +91,8 @@ function zitatBloecke(text) {
return {
start: b.start,
ende: b.ende,
absaetze: absaetze.map((a) => norm(a.join(' ').replace(/\\\*/g, '*'))),
ueberschrift: b.ueberschrift,
absaetze: absaetze.map((a) => norm(a.join(' ').replace(/\\([*<])/g, '$1'))),
}
})
}
@@ -112,6 +120,10 @@ test('paket_aufbau: sieben Überschriften, beide Fassungen und alle Teile von Ab
// Abschnitt 1: Kernpaket, Hausrunde, keine Rechtsberatung, Fassungen
const eins = abschnitt(1)
for (const wort of ['Kernpaket', 'Hausrunde', 'Keine Rechtsberatung durch WEBklar', 'Fassungen']) assert.ok(eins.includes(wort), wort)
// Begriffe, die die Kanzlei sonst nicht kennt: Betriebsart (Modus), Pilot, Kulanz; und die nachgereichte Anlage C
for (const wort of ['**Begriffe.**', 'Betriebsart', '`pilot`', '`on`', 'Kulanz', 'Anlage C']) assert.ok(eins.includes(wort), wort)
assert.ok(abschnitt(7).includes('**Anlage C**'), 'Checkliste nennt Anlage C (K15 und K16 gerendert)')
for (const n of [3, 20, 21]) assert.ok(PAKET.includes(`### Q${n}:`))
// Abschnitt 2: die Schritte des Ablaufs in der vorgegebenen Reihenfolge
const zwei = abschnitt(2)
const reihenfolge = ['| 1. Abo-Prüfung |', '| 2. Fragebogen-Entwurf |', '| 3. Bestellung mit vier Haken |', '| 4. Nachweis |', '| 5. Zahlung |', '| 6. Ticket und Bestätigung |', '| 7. Einbau und Freischaltung |', '| 8. Betrieb |']
@@ -160,6 +172,16 @@ const PLATZHALTER_EINSTELLUNG = (anrede) => ({
erinnerung: { anbieten: false },
})
/**
* Chat-Vorlage des Ticketsystems (WOMS, src/lib/funktionenView.js, termineChatTemplate), mit Platzhaltern statt Fassung und Adressen.
* Das WOMS liegt in einem eigenen Repository; test woms_chat_vorlage_gleich gleicht diese Kopie mit dem Quelltext ab, wenn er erreichbar ist.
*/
const WOMS_CHAT_VORLAGE =
'Damit wir den Terminplaner für Dich einschalten können, brauchen wir Deine Zustimmung in Textform. ' +
'Es gelten die Leistungsbeschreibung Terminplaner und der Auftragsverarbeitungsvertrag Terminplaner in der Fassung <Fassung>: ' +
'<Adresse der Leistungsbeschreibung> · <Adresse des AVV>. ' +
'Bitte antworte hier mit „Ich stimme zu“ und bestätige, dass Dein Angebot nicht im Gesundheitsbereich liegt.'
/** Alle Quellen, in denen ein Zitat stehen darf (je Quelle whitespace-normalisiert). */
function quellen() {
const t = config.features.termine
@@ -198,8 +220,13 @@ function quellen() {
mailText('server/mail-templates/G02 Termin bestaetigt.html'),
// Datenschutzerklärung von WEBklar (Absatz zur Funktionsbestellung in § 7, zitiert in Q23)
mailText('public/datenschutz.html'),
// früherer Text des News-Hakens (Korrekturvermerk in der Spezifikation)
lies('docs/superpowers/specs/2026-09-30-funktionen-news-design.md'),
// Zusatz zur Preiszeile der Bestellbestätigung K15 (Q19)
t.mails.preisHinweis,
// Nachweis der Zustimmung in Textform bei Freischaltung durch das Team (Q21), mit Platzhaltern für Fassung, Datum und Weg
textformNachweisText({ date: '<JJJJ>-<MM>-<TT>', channel: 'chat' }, '<Fassung>'),
textformNachweisText({ date: '<JJJJ>-<MM>-<TT>', channel: 'email' }, '<Fassung>'),
// Chat-Vorlage des Ticketsystems (Q21): Kopie von termineChatTemplate im WOMS, siehe woms_chat_vorlage_gleich
WOMS_CHAT_VORLAGE,
// Hinweise im Kundenportal und Storno-Vorschläge der Vorlagen
WIDERRUF_HINWEIS,
FREIZEIT_HINWEIS,
@@ -209,18 +236,101 @@ function quellen() {
return roh.map(norm)
}
// Zitate des früheren Programmstands (3a0b008, vor der Korrektur der News-Texte): sie stehen in keiner Datei des heutigen Stands und werden
// deshalb über ihren SHA-256 festgehalten (Absätze normalisiert, mit einem Leerzeichen verbunden). Quelle: `git show 3a0b008:server/config.js`
// (consentText der News) und `git show 3a0b008:public/vertragsunterlagen/funktionen-leistungsbeschreibung.html` (Abschnitt 2 und 6).
// Ist der Git-Verlauf vorhanden, vergleicht alte_fassung_gleich_git die Zitate zusätzlich mit dem Stand 3a0b008.
const ALTE_FASSUNG = 'Alte Fassung (Stand 3a0b008)'
const ALTE_STAENDE = {
haken: '2631c05fd14f38d311b5189752a3d7942734c4f8ba66f016036dc178b17d6630',
lb6: '932f542f081299c68e7e46fb3a3375641c435e83125ebf48f9d85d8b38c4843b',
preis: '7b4e2bbdd8686b8837b7e6a52e5ae0235a55b04c525b7ad50d11a26bfb2ab000',
}
const sha256 = (text) => createHash('sha256').update(text, 'utf8').digest('hex')
const MIN_ZITAT = 25
/**
* Zitatabsätze, die kürzer als MIN_ZITAT sind, passen in fast jeden Text und beweisen daher nichts. Sie sind einzeln benannt und werden
* von eigenen Tests streng geprüft: die vier Überschriften der Belehrung (Gleichheit mit der Funktion in 3.2 und 3.6), die Knopftexte (3.10).
*/
const KURZE_ZITATE = new Set([
'Widerrufsbelehrung', 'Widerrufsrecht', 'Folgen des Widerrufs', 'Muster-Widerrufsformular',
'Zahlungspflichtig buchen', 'Zahlungspflichtig bestätigen', 'Termin buchen', 'Termin bestätigen',
'Termin anfragen', 'Anfrage absenden', 'Vertrag widerrufen', 'Widerruf bestätigen',
])
test('paket_zitate_woertlich: jeder Zitatabsatz steht wortgleich in einer Quelle', () => {
const alle = quellen()
const bloecke = zitatBloecke(PAKET)
assert.ok(bloecke.length >= 30, `erwartet viele Zitatblöcke, gefunden ${bloecke.length}`)
const fehlend = []
const kurz = new Set()
let geprueft = 0
for (const b of bloecke) {
if (b.ueberschrift.includes(ALTE_FASSUNG) || /Alte Fassung \(Stand 3a0b008\)/.test(b.ueberschrift)) continue // alte_fassung_gepinnt
for (const absatz of b.absaetze) {
assert.ok(absatz.length > 0)
if (absatz.length < MIN_ZITAT) {
kurz.add(absatz)
continue
}
geprueft++
if (!alle.some((q) => q.includes(absatz))) fehlend.push(absatz.slice(0, 120))
}
}
assert.deepEqual(fehlend, [], 'Zitate ohne Quelle (Programm oder Dokument geändert, Paket nachziehen)')
assert.ok(geprueft >= 60, `erwartet viele geprüfte Absätze, gefunden ${geprueft}`)
// kurze Absätze nur aus der benannten Liste (sie prüfen eigene Tests streng)
assert.deepEqual([...kurz].filter((k) => !KURZE_ZITATE.has(k)), [], 'kurzes Zitat ohne eigene Prüfung')
})
test('alte_fassung_gepinnt: Zitate des früheren Programmstands sind unverändert (SHA-256)', () => {
const bloecke = zitatBloecke(PAKET).filter((b) => b.ueberschrift.includes(ALTE_FASSUNG))
assert.ok(bloecke.length >= 4, `erwartet die alten Zitate in 3.3 und Q4, gefunden ${bloecke.length}`)
const bekannt = new Set(Object.values(ALTE_STAENDE))
const gesehen = new Set()
for (const b of bloecke) {
const h = sha256(b.absaetze.join(' '))
assert.ok(bekannt.has(h), `altes Zitat ist nicht gepinnt (${h}): ${b.absaetze[0].slice(0, 80)}`)
gesehen.add(h)
}
assert.equal(gesehen.size, bekannt.size, 'alle drei alten Texte kommen im Paket vor')
})
test('alte_fassung_gleich_git: die alten Zitate entsprechen dem Stand 3a0b008 (nur mit Git-Verlauf)', (t) => {
const wurzel = fileURLToPath(new URL('..', import.meta.url))
const git = (pfad) => {
const r = spawnSync('git', ['show', `3a0b008:${pfad}`], { cwd: wurzel, encoding: 'utf8', timeout: 20000 })
return r.status === 0 ? r.stdout : null
}
const config3 = git('server/config.js')
const lb3 = git('public/vertragsunterlagen/funktionen-leistungsbeschreibung.html')
if (config3 === null || lb3 === null) {
t.skip('Git-Verlauf mit 3a0b008 nicht vorhanden')
return
}
const haken = /consentText:\s*\n?\s*'([^']+)'/.exec(config3)?.[1]
assert.ok(haken, 'consentText im alten Stand')
const lbText = norm(lb3.replace(/<style[\s\S]*?<\/style>/g, ' ').replace(/<[^>]+>/g, ''))
const bloecke = zitatBloecke(PAKET).filter((b) => b.ueberschrift.includes(ALTE_FASSUNG))
const hakenBloecke = bloecke.filter((b) => sha256(b.absaetze.join(' ')) === ALTE_STAENDE.haken)
assert.ok(hakenBloecke.length >= 2)
for (const b of hakenBloecke) assert.equal(b.absaetze.join(' '), norm(haken))
for (const b of bloecke.filter((x) => sha256(x.absaetze.join(' ')) !== ALTE_STAENDE.haken)) {
for (const absatz of b.absaetze) assert.ok(lbText.includes(absatz), `nicht im alten Stand: ${absatz.slice(0, 80)}`)
}
})
test('woms_chat_vorlage_gleich: die Kopie der Chat-Vorlage entspricht dem Ticketsystem (wenn erreichbar)', (t) => {
const pfad = '/home/webklar/apps/ticket.webklar.com/dev-termine/src/lib/funktionenView.js'
if (!existsSync(pfad)) {
t.skip('WOMS-Repository nicht erreichbar')
return
}
const quelle = readFileSync(pfad, 'utf8')
const kopf = 'Damit wir den Terminplaner für Dich einschalten können, brauchen wir Deine Zustimmung in Textform. Es gelten die Leistungsbeschreibung Terminplaner und der Auftragsverarbeitungsvertrag Terminplaner in der Fassung '
assert.ok(quelle.includes(`${kopf}\${version}: \${links.lb} · \${links.avv}. Bitte antworte hier mit „Ich stimme zu“ und bestätige, dass Dein Angebot nicht im Gesundheitsbereich liegt.`))
assert.equal(WOMS_CHAT_VORLAGE, `${kopf}<Fassung>: <Adresse der Leistungsbeschreibung> · <Adresse des AVV>. Bitte antworte hier mit „Ich stimme zu“ und bestätige, dass Dein Angebot nicht im Gesundheitsbereich liegt.`)
})
test('paket_zitate_woertlich: Bestelltexte und Belehrung der Funktionen vollständig und in der Reihenfolge des Programms', () => {
@@ -238,6 +348,20 @@ test('paket_zitate_woertlich: Bestelltexte und Belehrung der Funktionen vollstä
// 3.2: der erste Block ist die Belehrung samt Muster-Widerrufsformular in voller Länge
const erster = zitatBloecke(teil('3.2'))[0]
assert.equal(erster.absaetze.join(' '), norm(widerrufsbelehrungFunktion()))
// 3.6: die Belehrung für Gäste (mit Platzhaltern für die Betriebsangaben) ebenfalls vollständig und gleich der Funktion
assert.equal(zitatBloecke(teil('3.6'))[0].absaetze.join(' '), norm(widerrufsbelehrung(PLATZHALTER_BETRIEB)))
// 3.10: die Knopftexte sind die der Funktion knopfText; die Knöpfe der Widerrufsfunktion stehen im Widget und im Hinweise-Dokument
const paare = zitatBloecke(teil('3.10')).map((b) => b.absaetze)
const mitPreis = { preisArt: 'fest', preisCent: 5000 }
assert.deepEqual(paare, [
[knopfText(mitPreis), knopfText(mitPreis, { bestaetigen: true })],
[knopfText({ preisArt: 'keiner' }), knopfText({ preisArt: 'keiner' }, { bestaetigen: true })],
[knopfText({ buchungsart: 'anfrage' }), knopfText({ buchungsart: 'anfrage' }, { bestaetigen: true })],
['Vertrag widerrufen', 'Widerruf bestätigen'],
])
const widget = lies('public/embed/termine.js')
assert.ok(widget.includes("'Vertrag widerrufen'") && widget.includes('Widerruf bestätigen'))
assert.ok(lies('docs/rechtliches/termine-hinweise.md').includes('| Widerruf (Funktion) | Vertrag widerrufen | Widerruf bestätigen |'))
})
test('paket_zitate_woertlich: hinter jedem Zitat in Abschnitt 3 steht ein Marker ⚖ Q…', () => {
@@ -496,3 +620,11 @@ test('paket_offene_punkte_abgedeckt: die Hinweise der Reviews (Kanzlei-Quellen)
hat(25, 'Mindestlaufzeit')
hat(26, 'Hash', 'Archiv', 'Haltezeit')
})
test('paket_ohne_interne_verweise: nichts, was die Kanzlei nicht auflösen kann', () => {
// Entscheidungsnummern der Spezifikation, Aufgabenkürzel und Projektphasen sind für Außenstehende unverständlich
assert.deepEqual(PAKET.match(/\bD[1-8]\b/g), null, 'Entscheidungsnummer D1 bis D8')
assert.deepEqual(PAKET.match(/\bTP[0-9]\b|\bTask F\d+|Spezifikation|Ledger|\bR\d{2,3}\b/g), null, 'internes Kürzel')
// alle Q-Verweise im Fließtext (Q3, Q20) zeigen auf vorhandene Fragen
for (const m of PAKET.matchAll(/\bQ(\d+)\b/g)) assert.ok(Number(m[1]) >= 1 && Number(m[1]) <= 26, `Q${m[1]}`)
})