import { test } from 'node:test' import assert from 'node:assert/strict' import { existsSync, readFileSync } from 'node:fs' import { config } from '../server/config.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) { aktuell = { zeilen: [], start: i, ende: i } 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, absaetze: absaetze.map((a) => norm(a.join(' ').replace(/\\\*/g, '*'))), } }) } /** 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) // 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(//g, ' ') .replace(//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 }, }) /** 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'), // früherer Text des News-Hakens (Korrekturvermerk in der Spezifikation) lies('docs/superpowers/specs/2026-09-30-funktionen-news-design.md'), // 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) } 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 = [] for (const b of bloecke) { for (const absatz of b.absaetze) { assert.ok(absatz.length > 0) 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)') }) 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())) }) 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')) })