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>
631 lines
33 KiB
JavaScript
631 lines
33 KiB
JavaScript
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,
|
|
ZUSTIMMUNG_ERINNERUNG,
|
|
ZUSTIMMUNG_STORNO,
|
|
ZUSTIMMUNG_VORZEITIG,
|
|
datenschutzAbsatz,
|
|
formularHinweis,
|
|
widerrufsbelehrung,
|
|
widerrufsbelehrungFunktion,
|
|
} from '../server/services/termine/rechtstexte.js'
|
|
import { knopfText } from '../server/services/termine/einstellungen.js'
|
|
import { VORLAGEN } from '../server/services/termine/vorlagen.js'
|
|
import { LOESCHFRIST_TAGE } from '../server/services/termine/frist.js'
|
|
import { FREIZEIT_HINWEIS, STORNO_MUSTER_HINWEIS, WIDERRUF_HINWEIS } from '../public/termine-tab-einstellungen.js'
|
|
|
|
// Kanzlei-Paket Terminplaner (Tasks F15a und F15b): docs/rechtliches/termine-kanzlei-paket.md. Das Paket zitiert Texte, die im
|
|
// Programm und in den Dokumenten stehen; dieser Test prueft, dass jedes Zitat wortgleich in einer Quelle steht, dass die Zahlen
|
|
// zum Code passen und dass die Fundstellen existieren. Aendert sich ein Text (neues Fassungsdatum), muss das Paket nachgezogen werden.
|
|
|
|
const lies = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8').replace(/\r/g, '')
|
|
const norm = (text) => text.replace(/\s+/g, ' ').trim()
|
|
|
|
const PAKET_PFAD = 'docs/rechtliches/termine-kanzlei-paket.md'
|
|
const PAKET = lies(PAKET_PFAD)
|
|
const F = config.termine.avvVersion
|
|
const V = RECHTSTEXTE_VERSION
|
|
|
|
// ------------------------------------------------------------------------------------------------ Aufbau des Pakets
|
|
|
|
const UEBERSCHRIFTEN = [
|
|
'## 1. Zweck und Umfang',
|
|
'## 2. Produktablauf in Kurzform',
|
|
'## 3. Texte in Endfassung',
|
|
'## 4. Fragen an die Kanzlei',
|
|
'## 5. Fundstellen im Code',
|
|
'## 6. Nicht Teil der Kanzleiprüfung',
|
|
'## 7. Checkliste für den Inhaber',
|
|
]
|
|
|
|
/** Text eines Abschnitts (## n. …) ohne die Überschrift; bis zur nächsten ##-Überschrift. */
|
|
function abschnitt(nr) {
|
|
const zeilen = PAKET.split('\n')
|
|
const start = zeilen.findIndex((z) => z.startsWith(`## ${nr}. `))
|
|
assert.ok(start >= 0, `Abschnitt ${nr} fehlt`)
|
|
let ende = zeilen.findIndex((z, i) => i > start && /^## /.test(z))
|
|
if (ende < 0) ende = zeilen.length
|
|
return zeilen.slice(start + 1, ende).join('\n')
|
|
}
|
|
|
|
/** Zitatblöcke eines Textes: zusammenhängende Zeilen mit „>“; Absätze durch eine Zeile „>“ getrennt. Das maskierte Sternchen „\*“ gilt als „*“. */
|
|
function zitatBloecke(text) {
|
|
const zeilen = text.split('\n')
|
|
const bloecke = []
|
|
let aktuell = null
|
|
zeilen.forEach((zeile, i) => {
|
|
if (/^>/.test(zeile)) {
|
|
if (!aktuell) {
|
|
// 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(/^> ?/, ''))
|
|
aktuell.ende = i
|
|
} else {
|
|
aktuell = null
|
|
}
|
|
})
|
|
return bloecke.map((b) => {
|
|
const absaetze = []
|
|
let teil = []
|
|
for (const z of b.zeilen) {
|
|
if (z.trim() === '') {
|
|
if (teil.length) absaetze.push(teil)
|
|
teil = []
|
|
} else {
|
|
teil.push(z)
|
|
}
|
|
}
|
|
if (teil.length) absaetze.push(teil)
|
|
return {
|
|
start: b.start,
|
|
ende: b.ende,
|
|
ueberschrift: b.ueberschrift,
|
|
absaetze: absaetze.map((a) => norm(a.join(' ').replace(/\\([*<])/g, '$1'))),
|
|
}
|
|
})
|
|
}
|
|
|
|
/** Alle Fragenummern hinter einem ⚖ (Marker „⚖ Q3, Q5“ oder Zellen einer Tabelle). */
|
|
function marker(text) {
|
|
const nummern = []
|
|
for (const m of text.matchAll(/⚖((?:\s*(?:Q\d+)(?:\s*[,·]\s*|\s+und\s+)?)+)/g)) {
|
|
for (const q of m[1].matchAll(/Q(\d+)/g)) nummern.push(Number(q[1]))
|
|
}
|
|
return nummern
|
|
}
|
|
|
|
test('paket_aufbau: sieben Überschriften, beide Fassungen und alle Teile von Abschnitt 3', () => {
|
|
const ueberschriften = PAKET.split('\n').filter((z) => /^## /.test(z))
|
|
assert.deepEqual(ueberschriften, UEBERSCHRIFTEN)
|
|
assert.equal(PAKET.split('\n').filter((z) => /^# /.test(z)).length, 1, 'genau eine Titelzeile')
|
|
|
|
assert.match(F, /^\d{4}-\d{2}-\d{2}$/)
|
|
assert.match(V, /^\d{4}-\d{2}-\d{2}$/)
|
|
const kopf = PAKET.split('\n## 1.')[0]
|
|
assert.ok(kopf.includes('`config.termine.avvVersion`') && kopf.includes(`**${F}**`), 'Fassung der Vertragsunterlagen im Kopf')
|
|
assert.ok(kopf.includes('`RECHTSTEXTE_VERSION`') && kopf.includes(`**${V}**`), 'Fassung der Gästetexte im Kopf')
|
|
|
|
// 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 |']
|
|
let pos = -1
|
|
for (const schritt of reihenfolge) {
|
|
const i = zwei.indexOf(schritt)
|
|
assert.ok(i > pos, `Schritt „${schritt}“ fehlt oder steht an der falschen Stelle`)
|
|
pos = i
|
|
}
|
|
for (const wort of ['30 Minuten', 'Bestätigungslink', 'Vertrag widerrufen', 'spätestens am folgenden Tag', '30 Tage', 'Rückkehr', 'vollständig']) assert.ok(zwei.includes(wort), wort)
|
|
// Abschnitt 3: die Unterabschnitte 3.1 bis 3.11
|
|
const drei = abschnitt(3)
|
|
const teile = [...drei.matchAll(/^### (3\.\d+) /gm)].map((m) => m[1])
|
|
assert.deepEqual(teile, Array.from({ length: 11 }, (_, i) => `3.${i + 1}`))
|
|
// Abschnitt 4 existiert (die Fragen selbst prüft paket_marker_und_fragen)
|
|
assert.ok(abschnitt(4).trim().length > 0)
|
|
assert.ok(abschnitt(7).includes('- [ ]'), 'Checkliste als Aufgabenliste')
|
|
})
|
|
|
|
// ------------------------------------------------------------------------------------------------ Zitate
|
|
|
|
/** Text einer Mailvorlage ohne Markup (nur für den Wortlautvergleich); Inline-Auszeichnungen fallen ohne Leerzeichen weg. */
|
|
function mailText(rel) {
|
|
return norm(
|
|
lies(rel)
|
|
.replace(/<!--[\s\S]*?-->/g, ' ')
|
|
.replace(/<style[\s\S]*?<\/style>/g, ' ')
|
|
.replace(/<script[\s\S]*?<\/script>/g, ' ')
|
|
.replace(/<\/?(?:strong|b|em|i|u|span|a)(?:\s[^>]*)?>/g, '')
|
|
.replace(/<[^>]+>/g, ' ')
|
|
.replace(/ /g, ' ')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/&/g, '&'),
|
|
)
|
|
}
|
|
|
|
const PLATZHALTER_BETRIEB = { name: '{{Name des Betriebs}}', adresse: '{{Anschrift}}', telefon: '{{Telefon}}', email: '{{E-Mail}}' }
|
|
const PLATZHALTER_EINSTELLUNG = (anrede) => ({
|
|
anrede,
|
|
aufbewahrungTage: 90,
|
|
betrieb: { name: '{{Firma}}', adresse: '{{Anschrift}}', email: '{{E-Mail}}', telefon: '{{Telefon}}' },
|
|
gastFelder: { telefon: 'aus', nachricht: 'aus', zusatz: [] },
|
|
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
|
|
const n = config.features.news
|
|
const roh = [
|
|
// Register: Haken und Knöpfe der Bestellung, Umsatzsteuerhinweis
|
|
...t.consent.items.map((i) => i.text),
|
|
t.consent.knopf,
|
|
...n.consent.items.map((i) => i.text),
|
|
n.consent.knopf,
|
|
config.ustHinweis,
|
|
// Ausgaben von rechtstexte.js
|
|
widerrufsbelehrungFunktion(),
|
|
widerrufsbelehrung(PLATZHALTER_BETRIEB),
|
|
formularHinweis(PLATZHALTER_EINSTELLUNG('sie')),
|
|
formularHinweis(PLATZHALTER_EINSTELLUNG('du')),
|
|
datenschutzAbsatz(PLATZHALTER_EINSTELLUNG('sie'), 'sie'),
|
|
datenschutzAbsatz(PLATZHALTER_EINSTELLUNG('du'), 'du'),
|
|
ZUSTIMMUNG_VORZEITIG('{{Firma}}'),
|
|
ZUSTIMMUNG_STORNO,
|
|
ZUSTIMMUNG_ERINNERUNG('{{n}}'),
|
|
GESUNDHEIT_HINWEIS,
|
|
knopfText({ buchungsart: 'anfrage' }),
|
|
knopfText({ buchungsart: 'anfrage' }, { bestaetigen: true }),
|
|
knopfText({ preisArt: 'fest', preisCent: 5000 }),
|
|
knopfText({ preisArt: 'fest', preisCent: 5000 }, { bestaetigen: true }),
|
|
knopfText({ preisArt: 'keiner' }),
|
|
knopfText({ preisArt: 'keiner' }, { bestaetigen: true }),
|
|
// die drei Markdown-Dokumente der Vertragsunterlagen
|
|
lies('docs/rechtliches/leistungsbeschreibung-terminplaner.md'),
|
|
lies('docs/rechtliches/avv-terminplaner.md'),
|
|
lies('docs/rechtliches/widerrufsbelehrung-funktionen.md'),
|
|
// Hinweise-Dokument der Gäste (Knopf „Vertrag widerrufen“ und „Widerruf bestätigen“ der Widerrufsfunktion)
|
|
lies('docs/rechtliches/termine-hinweise.md'),
|
|
// Mailvorlage G02 (Hinweise zum Widerrufsrecht in der Bestätigung)
|
|
mailText('server/mail-templates/G02 Termin bestaetigt.html'),
|
|
// Datenschutzerklärung von WEBklar (Absatz zur Funktionsbestellung in § 7, zitiert in Q23)
|
|
mailText('public/datenschutz.html'),
|
|
// 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,
|
|
STORNO_MUSTER_HINWEIS,
|
|
...Object.values(VORLAGEN).map((v) => v.storno),
|
|
]
|
|
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', () => {
|
|
const t = config.features.termine
|
|
const drei = abschnitt(3)
|
|
const teil = (nr) => {
|
|
const m = new RegExp(`^### ${nr.replace('.', '\\.')} [^\\n]*\\n([\\s\\S]*?)(?=^### |(?![\\s\\S]))`, 'm').exec(drei)
|
|
assert.ok(m, `Unterabschnitt ${nr}`)
|
|
return m[1]
|
|
}
|
|
// 3.1: die vier Haken und der Knopf, genau in dieser Reihenfolge
|
|
const absaetze31 = zitatBloecke(teil('3.1')).flatMap((b) => b.absaetze)
|
|
assert.deepEqual(absaetze31, [...t.consent.items.map((i) => norm(i.text)), norm(t.consent.knopf)])
|
|
assert.deepEqual(t.consent.items.map((i) => i.id), ['unterlagen', 'gesundheit', 'beginn', 'information'])
|
|
// 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…', () => {
|
|
const zeilen = PAKET.split('\n')
|
|
const dreiStart = zeilen.findIndex((z) => z.startsWith('## 3. '))
|
|
const dreiEnde = zeilen.findIndex((z, i) => i > dreiStart && /^## /.test(z))
|
|
const bloecke = zitatBloecke(abschnitt(3))
|
|
assert.ok(bloecke.length >= 30)
|
|
const ohne = []
|
|
for (const b of bloecke) {
|
|
const abschnittZeilen = abschnitt(3).split('\n')
|
|
let i = b.ende + 1
|
|
while (i < abschnittZeilen.length && abschnittZeilen[i].trim() === '') i++
|
|
const folge = abschnittZeilen[i] || ''
|
|
if (!/^⚖ Q\d+/.test(folge)) ohne.push(abschnittZeilen[b.start].slice(0, 80))
|
|
}
|
|
assert.deepEqual(ohne, [])
|
|
assert.ok(dreiEnde > dreiStart)
|
|
// alle Marker nennen nur Fragen von 1 bis 26
|
|
for (const q of marker(PAKET)) assert.ok(q >= 1 && q <= 26, `Q${q} außerhalb von Q1 bis Q26`)
|
|
})
|
|
|
|
// ------------------------------------------------------------------------------------------------ Fundstellen
|
|
|
|
/** Zeilen der Tabelle in Abschnitt 5: { thema, datei, bezeichner[] }. */
|
|
function fundstellen() {
|
|
const zeilen = abschnitt(5).split('\n').filter((z) => z.startsWith('|'))
|
|
assert.match(zeilen[0], /^\| Thema \| Datei \| Funktion\/Konstante \|$/)
|
|
return zeilen.slice(2).map((z) => {
|
|
const zellen = z.replace(/^\|\s*|\s*\|$/g, '').split(' | ')
|
|
assert.equal(zellen.length, 3, z)
|
|
const datei = /^`([^`]+)`$/.exec(zellen[1])
|
|
assert.ok(datei, `Datei in Backticks: ${z}`)
|
|
const bezeichner = [...zellen[2].matchAll(/`([^`]+)`/g)].map((m) => m[1])
|
|
assert.ok(bezeichner.length > 0, `Bezeichner in Backticks: ${z}`)
|
|
return { thema: zellen[0], datei: datei[1], bezeichner }
|
|
})
|
|
}
|
|
|
|
test('paket_fundstellen: jede Datei der Tabelle existiert und enthält den genannten Bezeichner', () => {
|
|
const zeilen = fundstellen()
|
|
assert.ok(zeilen.length >= 30, `erwartet viele Fundstellen, gefunden ${zeilen.length}`)
|
|
const themen = new Set()
|
|
for (const { thema, datei, bezeichner } of zeilen) {
|
|
assert.ok(!themen.has(thema), `Thema doppelt: ${thema}`)
|
|
themen.add(thema)
|
|
assert.ok(!/^\/|\.\./.test(datei), `nur Pfade im Repository: ${datei}`)
|
|
assert.ok(existsSync(new URL(`../${datei}`, import.meta.url)), `Datei fehlt: ${datei}`)
|
|
const inhalt = lies(datei)
|
|
for (const id of bezeichner) assert.ok(inhalt.includes(id), `${datei}: „${id}“ steht nicht in der Datei`)
|
|
}
|
|
// keine Zeilennummern in der Tabelle
|
|
assert.ok(!/\.(js|mjs|md|html|sh|txt):\d+/.test(abschnitt(5)), 'Fundstellen ohne Zeilennummern')
|
|
// die Anlagen A und B (PDF) und die Quellen der Dokumente existieren
|
|
for (const datei of [
|
|
`public/rechtliches/avv-terminplaner-${F}.pdf`,
|
|
`public/rechtliches/leistungsbeschreibung-terminplaner-${F}.pdf`,
|
|
`public/rechtliches/widerrufsbelehrung-funktionen-${F}.pdf`,
|
|
'docs/rechtliches/avv-terminplaner.md',
|
|
'docs/rechtliches/leistungsbeschreibung-terminplaner.md',
|
|
'docs/rechtliches/widerrufsbelehrung-funktionen.md',
|
|
]) {
|
|
assert.ok(existsSync(new URL(`../${datei}`, import.meta.url)), `Anlage fehlt: ${datei}`)
|
|
assert.ok(PAKET.includes(`\`${datei}\``), `Paket nennt ${datei}`)
|
|
}
|
|
})
|
|
|
|
// ------------------------------------------------------------------------------------------------ Zahlen
|
|
|
|
/** `const NAME = n * STUNDE_MS | TAG_MS` oder `const NAME = n` aus einem Quelltext: { n, einheit }. */
|
|
function konstante(quelle, name) {
|
|
const m = new RegExp(`const ${name} = (\\d+)(?: \\* (STUNDE|TAG)_MS)?`).exec(quelle)
|
|
assert.ok(m, `${name} fehlt im Quelltext`)
|
|
return { n: Number(m[1]), einheit: m[2] === 'STUNDE' ? 'Stunden' : m[2] === 'TAG' ? 'Tage' : '' }
|
|
}
|
|
|
|
test('paket_zahlen_gleich_code: die Zahlen in Abschnitt 2.5 sind die des Codes', () => {
|
|
const tabelle = abschnitt(2).split('### 2.5 Zahlen')[1]
|
|
assert.ok(tabelle, 'Tabelle „2.5 Zahlen“')
|
|
const zeilen = tabelle.split('\n').filter((z) => z.startsWith('|')).slice(2)
|
|
const wert = Object.fromEntries(zeilen.map((z) => [z.split(' | ')[0].replace(/^\|\s*/, ''), z]))
|
|
assert.deepEqual(Object.keys(wert), ['149 €', '14 Tage', '30 Minuten', '48 Stunden', '90 Tage', '7 Tage', '30 Tage'])
|
|
|
|
const JOBS = lies('server/services/termine/jobs.js')
|
|
const OUTBOX = lies('server/services/termine/outbox.js')
|
|
const AVV = norm(lies('docs/rechtliches/avv-terminplaner.md'))
|
|
|
|
// 149 €: Preis des Terminplaners im Register und im Produktkatalog (14 900 Cent)
|
|
assert.equal(config.features.termine.priceLabel, '149 €')
|
|
assert.equal(config.termineSetup.amountCents, 14900)
|
|
assert.ok(wert['149 €'].includes('ohne Umsatzsteuer gemäß § 19 UStG') && wert['149 €'].includes('14900'))
|
|
assert.ok(wert['149 €'].includes(config.ustHinweis))
|
|
// 14 Tage: Widerrufsfrist
|
|
assert.equal(konstante(lies('server/services/deadlines.js'), 'WITHDRAWAL_DAYS').n, 14)
|
|
assert.ok(norm(widerrufsbelehrungFunktion()).includes('binnen vierzehn Tagen'))
|
|
// 30 Minuten: Haltezeit
|
|
assert.equal(config.termine.holdMinutes, 30)
|
|
assert.ok(AVV.includes('Bestätigungsfrist von 30 Minuten'))
|
|
// 48 Stunden: Löschung nicht bestätigter Buchungen
|
|
assert.deepEqual(konstante(JOBS, 'VERFALLEN_BEHALTEN_MS'), { n: 48, einheit: 'Stunden' })
|
|
// 90 Tage: Aufbewahrung der Termine (Vorgabe) und der Entwürfe
|
|
assert.equal(konstante(JOBS, 'AUFBEWAHRUNG_VORGABE').n, 90)
|
|
assert.equal(konstante(lies('server/services/termine/entwuerfe.js'), 'ENTWURF_TAGE').n, 90)
|
|
assert.ok(AVV.includes('die Vorgabe sind 90 Tage') && AVV.includes('löscht WEBklar 90 Tage nach der letzten Änderung'))
|
|
// 7 Tage: Versandwarteschlange nach dem Versand; Sicherungen
|
|
assert.deepEqual(konstante(OUTBOX, 'GESENDET_BEHALTEN_MS'), { n: 7, einheit: 'Tage' })
|
|
assert.ok(AVV.includes('bis zu sieben Tage erhalten'))
|
|
assert.ok(lies('public/abo-bedingungen.html').includes('die letzten sieben Tagessicherungen'))
|
|
// 30 Tage: Löschung nach dem Abschalten und abgesagter Termine
|
|
assert.equal(LOESCHFRIST_TAGE, 30)
|
|
assert.deepEqual(konstante(JOBS, 'ABGESAGT_BEHALTEN_MS'), { n: 30, einheit: 'Tage' })
|
|
assert.ok(JOBS.includes('LOESCHFRIST_TAGE * TAG_MS'))
|
|
})
|
|
|
|
// ------------------------------------------------------------------------------------------------ Ende des Abos
|
|
|
|
test('paket_konsistenz_abo_ende: LB Nr. 8, AVV Nr. 9, 6c und K22 nennen 30 Tage und die Rückkehr', () => {
|
|
const abschnittVon = (md, prefix) => {
|
|
const zeilen = md.split('\n')
|
|
const start = zeilen.findIndex((z) => z.startsWith(prefix))
|
|
assert.ok(start >= 0, prefix)
|
|
const ende = zeilen.findIndex((z, i) => i > start && /^## /.test(z))
|
|
return norm(zeilen.slice(start, ende < 0 ? undefined : ende).join('\n'))
|
|
}
|
|
const lb8 = abschnittVon(lies('docs/rechtliches/leistungsbeschreibung-terminplaner.md'), '## 8. ')
|
|
const avv9 = abschnittVon(lies('docs/rechtliches/avv-terminplaner.md'), '## 9. ')
|
|
const sechsC = norm(lies('docs/rechtliches/termine-datenschutz-6c.md'))
|
|
const k22 = mailText('server/mail-templates/K22 Terminplaner beendet.html')
|
|
const quellenAbo = { 'LB Nr. 8': lb8, 'AVV Nr. 9': avv9, '6c': sechsC, K22: k22 }
|
|
for (const [name, text] of Object.entries(quellenAbo)) {
|
|
assert.ok(/\b30 Tage\b/.test(text), `${name} nennt „30 Tage“`)
|
|
assert.ok(/wieder ein\b|Rückkehr/.test(text), `${name} nennt die Rückkehr`)
|
|
}
|
|
// die Rückkehr gilt in allen vier Texten für ein neues Wartungs-Abo
|
|
for (const [name, text] of Object.entries(quellenAbo)) assert.ok(text.includes('Wartungs-Abo'), `${name} nennt das Wartungs-Abo`)
|
|
// dieselbe Zahl wie im Code (frist.js) und im Abgleich
|
|
assert.equal(LOESCHFRIST_TAGE, 30)
|
|
assert.ok(lb8.includes('30 Tage nach dem Abschalten') && avv9.includes('30 Tage danach'))
|
|
// das Paket sagt dasselbe (Abschnitt 2.3)
|
|
const drei = norm(abschnitt(2).split('### 2.3 ')[1].split('### 2.4 ')[0])
|
|
assert.ok(drei.includes('30 Tage') && drei.includes('Rückkehr') && drei.includes('spätestens am folgenden Tag'))
|
|
})
|
|
|
|
// ------------------------------------------------------------------------------------------------ Fragen Q1 bis Q26 (Task F15b)
|
|
|
|
const FRAGE_TEILE = ['**Frage.**', '**Fundstelle im Text.**', '**Unsere Annahme.**', '**Alternative.**']
|
|
|
|
/** Fragen aus Abschnitt 4: [{ nr, titel, text, teile: { frage, fundstelle, annahme, alternative } }] in der Reihenfolge des Pakets. */
|
|
function fragen() {
|
|
const text = abschnitt(4)
|
|
const treffer = [...text.matchAll(/^### Q(\d+): (.+)$/gm)]
|
|
return treffer.map((m, i) => {
|
|
const ende = i + 1 < treffer.length ? treffer[i + 1].index : text.length
|
|
const rumpf = text.slice(m.index, ende)
|
|
const stellen = FRAGE_TEILE.map((marke) => rumpf.indexOf(marke))
|
|
const teile = {}
|
|
;['frage', 'fundstelle', 'annahme', 'alternative'].forEach((name, k) => {
|
|
if (stellen[k] < 0) return
|
|
const bis = k + 1 < stellen.length ? stellen[k + 1] : rumpf.length
|
|
teile[name] = rumpf.slice(stellen[k] + FRAGE_TEILE[k].length, bis).trim()
|
|
})
|
|
return { nr: Number(m[1]), titel: m[2], text: rumpf, teile, stellen }
|
|
})
|
|
}
|
|
|
|
test('paket_marker_und_fragen: Q1 bis Q26 lückenlos, je vier Teile, jede Frage verwiesen, jeder Marker hat seine Frage', () => {
|
|
const alle = fragen()
|
|
assert.deepEqual(alle.map((f) => f.nr), Array.from({ length: 26 }, (_, i) => i + 1), 'lückenlos Q1 bis Q26 in dieser Reihenfolge')
|
|
assert.ok(!abschnitt(4).includes('Task F15b'), 'der Platzhalter von F15a ist ersetzt')
|
|
for (const f of alle) {
|
|
assert.ok(f.titel.trim().length >= 5, `Q${f.nr}: Titel`)
|
|
assert.ok(f.stellen.every((p) => p >= 0) && f.stellen.every((p, k) => k === 0 || p > f.stellen[k - 1]), `Q${f.nr}: vier Teile in der Reihenfolge Frage, Fundstelle, Annahme, Alternative`)
|
|
for (const [name, inhalt] of Object.entries(f.teile)) assert.ok(inhalt.length >= 20, `Q${f.nr}: ${name} ist leer`)
|
|
assert.ok(!/Task F15|TODO|\?\?\?/.test(f.text), `Q${f.nr}: kein Platzhalter`)
|
|
}
|
|
// jeder Marker nennt eine vorhandene Frage, und jede Frage wird mindestens einmal verwiesen
|
|
const verwiesen = new Set(marker(PAKET))
|
|
for (const q of verwiesen) assert.ok(q >= 1 && q <= 26 && alle.some((f) => f.nr === q), `⚖ Q${q} ohne ### Q${q}`)
|
|
for (const f of alle) assert.ok(verwiesen.has(f.nr), `Q${f.nr} wird nirgends mit ⚖ verwiesen`)
|
|
// Q4 (D7): News-Altkunden
|
|
const q4 = alle[3]
|
|
for (const wort of ['News', '§ 356 Abs. 3', 'Altkunden']) assert.ok(q4.text.includes(wort), `Q4 nennt „${wort}“`)
|
|
assert.ok(q4.teile.frage.includes('[Anzahl'), 'Q4: die Zahl der Fälle liefert der Inhaber')
|
|
})
|
|
|
|
/** Zeilen der Zuordnungstabelle in Abschnitt 6.3: Kennung -> { punkt, wo }. */
|
|
function zuordnung() {
|
|
const text = abschnitt(6).split('### 6.3 ')[1]
|
|
assert.ok(text, '6.3')
|
|
const zeilen = text.split('\n').filter((z) => /^\| [HABC]\d /.test(z))
|
|
const tabelle = {}
|
|
for (const z of zeilen) {
|
|
const zellen = z.replace(/^\|\s*|\s*\|$/g, '').split(' | ')
|
|
assert.equal(zellen.length, 3, z)
|
|
assert.ok(!(zellen[0] in tabelle), `Kennung doppelt: ${zellen[0]}`)
|
|
tabelle[zellen[0]] = { punkt: zellen[1], wo: zellen[2] }
|
|
}
|
|
return tabelle
|
|
}
|
|
|
|
test('paket_offene_punkte_abgedeckt: jeder Punkt der entfernten Listen steht in einer Frage oder hat in Abschnitt 6 einen Grund', () => {
|
|
const erwartet = [
|
|
...[1, 2, 3, 4].map((n) => `H${n}`),
|
|
...[1, 2, 3, 4, 5, 6].map((n) => `A${n}`),
|
|
...[1, 2, 3, 4, 5, 6].map((n) => `B${n}`),
|
|
...[1, 2, 3, 4, 5, 6, 7, 8].map((n) => `C${n}`),
|
|
]
|
|
const tabelle = zuordnung()
|
|
assert.deepEqual(Object.keys(tabelle), erwartet, 'jede Kennung genau einmal, in dieser Reihenfolge')
|
|
const alle = fragen()
|
|
for (const kennung of erwartet) {
|
|
const { punkt, wo } = tabelle[kennung]
|
|
assert.ok(punkt.length >= 10, `${kennung}: Punkt benannt`)
|
|
const frage = /^Q(\d+)$/.exec(wo)
|
|
if (frage) {
|
|
// Zuordnung zu einer Frage: die Kennung steht in der „Fundstelle im Text“ genau dieser Frage
|
|
const f = alle.find((x) => x.nr === Number(frage[1]))
|
|
assert.ok(f, `${kennung}: Frage ${wo} fehlt`)
|
|
assert.ok(new RegExp(`\\b${kennung}\\b`).test(f.teile.fundstelle), `${kennung} steht nicht in der Fundstelle von ${wo}`)
|
|
} else {
|
|
// sonst nur mit Begründung (hier) und nur die drei dokumentierten Fälle
|
|
assert.ok(wo.startsWith('hier: ') && wo.length > 40, `${kennung}: Grund fehlt`)
|
|
assert.ok(['B3', 'C5', 'C6'].includes(kennung), `${kennung} muss in einer Frage stehen`)
|
|
for (const f of alle) assert.ok(!new RegExp(`\\b${kennung}\\b`).test(f.teile.fundstelle), `${kennung} steht schon in Q${f.nr}`)
|
|
}
|
|
}
|
|
// jede Kennung in den Fragen ist in der Tabelle bekannt
|
|
for (const f of alle) {
|
|
for (const m of f.teile.fundstelle.matchAll(/\b([HABC]\d)\b/g)) assert.ok(erwartet.includes(m[1]), `Q${f.nr}: unbekannte Kennung ${m[1]}`)
|
|
}
|
|
})
|
|
|
|
test('paket_offene_punkte_abgedeckt: die Hinweise der Reviews (Kanzlei-Quellen) stehen in der passenden Frage', () => {
|
|
const alle = fragen()
|
|
const frage = (n) => alle[n - 1].text
|
|
const hat = (n, ...woerter) => { for (const w of woerter) assert.ok(frage(n).includes(w), `Q${n} nennt „${w}“`) }
|
|
hat(1, 'Verbraucher', 'Unternehmer')
|
|
hat(2, 'Zahlungseingang', 'K15')
|
|
hat(3, '§ 356 Abs. 5 Nr. 2', '§ 357a Abs. 2', 'dauerhaften Datenträger')
|
|
hat(5, 'Gesamtpreis', 'Arbeitsblatt', 'Stundenaufwand')
|
|
hat(6, 'abo_ende_vor_einrichtung', 'abo_ende', 'Nr. 8 Abs. 4', '§ 307')
|
|
hat(7, 'unlockedByPurchase', 'ausgenommen', 'Pilot', 'Kulanz')
|
|
hat(8, '§ 309 Nr. 7', 'Höchstbetrag', 'Einstellung')
|
|
hat(9, 'disabledAt', 'Terminanfragen', 'Rückkehr', 'Sicherung')
|
|
hat(10, 'unverzüglich', 'Absagegrund', 'Zählungen', 'Drittländer', 'Kalender')
|
|
hat(11, 'Erstattung', 'ablehnt', 'Art. 9')
|
|
hat(12, 'Lieferkosten', 'Gesetzlichkeitsfiktion')
|
|
hat(14, '§ 312j Abs. 3')
|
|
hat(16, 'G02', '§ 307', 'kostenlosen')
|
|
hat(19, '§ 19 UStG', '· 149 €')
|
|
hat(20, 'vereinbarten Fassung', 'Art. 246a § 1')
|
|
hat(21, 'Textform', 'K16', 'Art. 28 Abs. 9')
|
|
hat(22, 'BFSG')
|
|
hat(23, 'drei Jahre', 'Browserkennung')
|
|
hat(24, '90 Tage')
|
|
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]}`)
|
|
})
|