Files
Webklar-Kundenbereich/test/termineKanzleiPaket.test.js
T
ClaudeandClaude Sonnet 5.5 bea07e73b1 TP2 Abschlussprüfung: Rechtstexte (AVV, 6c, News-LB-Archiv, Livegang-Checkliste)
- AVV Vorbemerkung (2): K16 der Textform-Aktivierung trägt nur Leistungsbeschreibung und AVV als PDF
- AVV Nr. 9 Abs. 6, Anlage (Löschung), LB Nr. 8 Abs. 3 und Datenschutz 6c: das Einrichtungs-Ticket bleibt mit einer Kurzfassung der Einstellungen als Arbeitsnachweis (keine Daten von Gästen)
- AVV Nr. 8 Abs. 2: "Gastdaten löschen" löscht nur wartende E-Mails; fehlgeschlagene behalten Adresse und Inhalt bis Nr. 9 Abs. 4
- Datenschutz § 7: Frist für Bestellungen ohne Abo (drei Jahre nach Ablauf des Jahres der Bestellung)
- News-Leistungsbeschreibung vom 30.09.2026 unverändert archiviert (SHA-256-Pin), die geltende Datei verweist darauf
- Kanzlei-Paket: Zitate, Q4/Q9/Q23, Livegang-Checkliste (Abschnitt 7.6/7.7), K23-Hinweis
- build-legal-html: Rückwärtsschrägstrich in Navigationsadressen abgelehnt; TP1-Spezifikation E2 (ohne USt)
- HTML, PDFs und Prüfsummen neu erzeugt (Terminplaner-Unterlagen und Datenschutzerklärung)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 06:54:55 +00:00

704 lines
39 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(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/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'))
})
test('paket_checkliste_livegang: Abschnitt 7.6 nennt die Schritte vor, bei und nach dem Deploy in fester Reihenfolge; Skripte, Indizes und Fristen stimmen mit dem Code überein', () => {
const sieben = abschnitt(7)
const [, ab76] = sieben.split('### 7.6 ')
assert.ok(ab76, 'Abschnitt 7.6')
const [livegang, nachher] = ab76.split('### 7.7 ')
assert.ok(nachher, 'Abschnitt 7.7')
const punkte = (text) => text.split('\n').filter((z) => z.startsWith('- [ ] '))
const l = punkte(livegang)
const folge = [
'scripts/termine-setup.mjs', // (a) Schema des Terminplaners vor dem Code-Deploy
'scripts/feature-setup.mjs', // (b) Schema der Funktionen vor dem Code-Deploy
'scripts/build-legal-pdfs.sh datenschutz', // (c) nur das PDF der Datenschutzerklärung
'ruhigen Zeit', // (d) Deploy bei wenig Verkehr
'Uploads in WOMS', // Uploads nach dem Deploy
'scripts/feature-consent-felder-leeren.mjs', // (f) mit Freigabe des Inhabers
'TERMINE_ENABLED=on', // (e) als allerletzter Schritt
]
assert.equal(l.length, folge.length, 'sieben Schritte')
folge.forEach((wort, i) => assert.ok(l[i].includes(wort), `Schritt ${i + 1} nennt „${wort}“: ${l[i].slice(0, 80)}`))
assert.ok(l[0].includes('--check') && l[1].includes('--check'), 'beide Schema-Skripte erst mit --check')
assert.ok(l[1].includes('Code-Deploy, nicht erst der erste Löschlauf'), '(b) Zeitpunkt: vor dem Code-Deploy')
assert.ok(!PAKET.includes('vor dem ersten Löschlauf der Aufbewahrungsfristen ausführen'), 'der alte Zeitpunkt steht nirgends mehr')
assert.ok(l[2].includes('nicht mit `all`'), '(c) nicht all')
assert.ok(l[3].includes('Fassung der Gästetexte, die der Server beim Speichern hat'), '(d) der Nachweis speichert die Fassung des Servers')
assert.ok(l[5].includes('nur mit Freigabe des Inhabers') && l[5].includes('Probelauf') && l[5].includes('--dry-run') && l[5].includes('--apply'), '(f) Probelauf, dann --apply, mit Freigabe')
assert.ok(l[6].startsWith('- [ ] Als allerletzten Schritt `TERMINE_ENABLED=on`'), '(e) TERMINE_ENABLED=on zuletzt')
assert.ok(sieben.indexOf('TERMINE_ENABLED=on') > sieben.indexOf('scripts/feature-consent-felder-leeren.mjs'), 'on nach allen anderen Schritten')
assert.equal(sieben.split('TERMINE_ENABLED=on').length - 1, 1, 'on wird nur an dieser Stelle gesetzt')
// (a) die genannten Spalten und Indizes stehen im Schema; (b)/(f) die Skripte gibt es und sagen dasselbe wie die Checkliste
const schema = lies('server/services/termine/schema.js')
assert.match(schema, /str\('fragebogen', 20000\)/)
for (const index of ['idx_status_ends', 'idx_status_updated', 'idx_booking']) {
assert.ok(schema.includes(`'${index}'`), `${index} im Schema`)
assert.ok(l[0].includes(`\`${index}\``), `Schritt 1 nennt ${index}`)
}
assert.ok(l[0].includes('`fragebogen`') && l[0].includes('`termineConfig`') && l[0].includes('`termineBookings`') && l[0].includes('`termineOutbox`'))
assert.match(lies('scripts/feature-setup.mjs'), /Vor dem Code-Deploy ausfuehren/)
assert.match(lies('scripts/termine-setup.mjs'), /--check/)
const pdfs = lies('scripts/build-legal-pdfs.sh')
assert.ok(pdfs.includes('\ndatenschutz)\n') && pdfs.includes('\nall)\n'), 'build-legal-pdfs.sh kennt datenschutz und all')
const leeren = lies('scripts/feature-consent-felder-leeren.mjs')
assert.ok(leeren.includes('--dry-run') && leeren.includes('--apply') && leeren.includes('Standard ist der Probelauf'))
// (g) Kunden ohne Abo: der Löschlauf erfasst sie heute nicht, die Datenschutzerklärung nennt die Frist; die erste Frist läuft Ende 2029 ab
const g = punkte(nachher)
assert.equal(g.length, 1)
assert.ok(g[0].includes('31.12.2029') && g[0].includes('`findDueDeletions`') && g[0].includes('ohne Abo'))
assert.match(lies('server/services/retention.js'), /Kunden ohne Abo, zum Beispiel nur mit News, sind nie faellig/, 'steht das nicht mehr im Code, ist die Löschung umgesetzt: Punkt 7.7 und Q23 anpassen')
assert.ok(mailText('public/datenschutz.html').includes('Bestellen Sie ohne Abo (zum Beispiel nur die News-Pinnwand), löschen wir diese Angaben spätestens drei Jahre nach Ablauf des Jahres der Bestellung.'))
assert.ok(abschnitt(4).includes('Für Bestellungen ohne Abo (nur News) nennt die Erklärung eine eigene Frist') && abschnitt(4).includes('(31.12.2029)'))
// (h) K23 verspricht „unverändert da“; der Löschlauf löscht auch bei abgeschaltetem Terminplaner Termine nach ihren Fristen
const vier = punkte(abschnitt(7).split('### 7.4 ')[1].split('### 7.5 ')[0])
assert.ok(vier.some((z) => z.includes('K23') && z.includes('unverändert da') && z.includes('abschwächen')), '(h) K23 in 7.4')
assert.ok(mailText('server/mail-templates/K23 Terminplaner laeuft wieder.html').includes('Deine Termine und Einstellungen sind unverändert da'))
assert.match(lies('server/services/termine/jobs.js'), /portalFeatures featureKey termine, active oder disabled/)
// 7.5 verweist auf 7.6
assert.ok(abschnitt(7).split('### 7.5 ')[1].split('### 7.6 ')[0].includes('Abschnitt 7.6'))
})
test('paket_ausnahme_einrichtungs_ticket_und_archiv: Q4, Q9 und Abschnitt 2.3 nennen das archivierte News-Dokument und die Ausnahme der Löschung', () => {
const alle = abschnitt(4)
const q4 = alle.split('### Q4: ')[1].split('### Q5: ')[0]
assert.ok(q4.includes('`public/vertragsunterlagen/funktionen-leistungsbeschreibung-2026-09-30.html`'))
assert.ok(existsSync(new URL('../public/vertragsunterlagen/funktionen-leistungsbeschreibung-2026-09-30.html', import.meta.url)))
assert.ok(q4.includes('unveränderte Kopie des Standes 3a0b008') && q4.includes('Prüfsumme'))
const q9 = alle.split('### Q9: ')[1].split('### Q10: ')[0]
assert.ok(q9.includes('(g) Das Einrichtungs-Ticket') && q9.includes('Leistungsbeschreibung Nr. 8 Abs. 3 und AVV Nr. 9 Abs. 6'))
assert.ok(abschnitt(2).split('### 2.3 ')[1].split('### 2.4 ')[0].includes('das Einrichtungs-Ticket des Teams bleibt als Arbeitsnachweis'))
})
// ------------------------------------------------------------------------------------------------ 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]}`)
})