- 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>
704 lines
39 KiB
JavaScript
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(/ /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'))
|
|
})
|
|
|
|
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]}`)
|
|
})
|