Files
Webklar-Kundenbereich/test/termineRechtstexteArchiv.test.js
T

127 lines
8.1 KiB
JavaScript

import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PLATZHALTER_BETRIEB, archivPfad, baueArchiv, lauf } from '../scripts/termine-texte-archiv.mjs'
import {
GESUNDHEIT_HINWEIS,
RECHTSTEXTE_VERSION,
ZUSTIMMUNG_ERINNERUNG,
ZUSTIMMUNG_STORNO,
ZUSTIMMUNG_VORZEITIG,
widerrufsbelehrung,
} from '../server/services/termine/rechtstexte.js'
// Archiv der Gästetexte (AVV Terminplaner Nr. 8 Abs. 5): jede Fassung steht als Datei unter docs/rechtliches/archiv/, erzeugt von
// scripts/termine-texte-archiv.mjs. Die Datei einer früheren Fassung wird nie neu erzeugt; ihr Hash ist hier festgenagelt.
const lies = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8')
const sha256 = (text) => createHash('sha256').update(text).digest('hex')
const ARCHIV_DIR = 'docs/rechtliches/archiv'
/** Temporäres Projektverzeichnis mit dem Archiv-Ordner; räumt nach dem Test auf. */
function mitTempRoot(t) {
const root = mkdtempSync(join(tmpdir(), 'termine-archiv-'))
mkdirSync(join(root, ARCHIV_DIR), { recursive: true })
t.after(() => rmSync(root, { recursive: true, force: true }))
return root
}
const leise = { log: () => {}, fehler: () => {} }
test('archiv_aktuelle_fassung: Skriptausgabe ist die eingecheckte Datei der aktuellen Fassung; Datei und README nennen die Fassung', () => {
assert.equal(archivPfad(), `${ARCHIV_DIR}/termine-gaestetexte-${RECHTSTEXTE_VERSION}.md`)
const datei = lies(archivPfad())
assert.equal(baueArchiv(), datei, 'Archivdatei weicht von rechtstexte.js ab: node scripts/termine-texte-archiv.mjs ausführen (nur bei neuer Fassung)')
assert.equal(baueArchiv(), baueArchiv(), 'deterministisch')
assert.ok(datei.startsWith(`# Gästetexte des Terminplaners, Fassung ${RECHTSTEXTE_VERSION}\n`))
const readme = lies(`${ARCHIV_DIR}/README.md`)
const zeile = readme.split('\n').find((z) => z.startsWith('|') && z.includes(RECHTSTEXTE_VERSION) && z.includes(`termine-gaestetexte-${RECHTSTEXTE_VERSION}.md`))
assert.ok(zeile, 'README-Tabelle „Terminplaner-Gästetexte“ nennt die Fassung nicht')
assert.ok(readme.includes('## Terminplaner-Gästetexte'))
})
test('archiv_aktuelle_fassung: --check vergleicht mit der eingecheckten Datei (Exit 0 / 1 / 2), ohne --check wird geschrieben', (t) => {
assert.equal(lauf(['--check'], leise), 0, 'die eingecheckte Datei stimmt')
const root = mitTempRoot(t)
const ziel = join(root, archivPfad())
assert.equal(lauf(['--check'], { ...leise, root }), 1, 'fehlende Datei')
assert.equal(lauf([], { ...leise, root }), 0)
assert.equal(readFileSync(ziel, 'utf8'), baueArchiv())
assert.equal(lauf(['--check'], { ...leise, root }), 0)
writeFileSync(ziel, readFileSync(ziel, 'utf8').replace('Gesundheit', 'Gesundheits'))
const zeilen = []
assert.equal(lauf(['--check'], { root, log: (z) => zeilen.push(z), fehler: () => {} }), 1, 'geänderte Datei')
assert.match(zeilen[0], /^ABWEICHT /)
assert.equal(readFileSync(ziel, 'utf8').includes('Gesundheits'), true, '--check schreibt nichts')
const meldungen = []
assert.equal(lauf(['--prüfen'], { ...leise, root, fehler: (m) => meldungen.push(m) }), 2)
assert.match(meldungen[0], /Aufruf: node scripts\/termine-texte-archiv\.mjs \[--check\]/)
})
test('archiv_inhalt: alle Gästetexte stehen wörtlich im Archiv, die Betriebsangaben als Platzhalter', () => {
const text = baueArchiv()
const zitat = (s) => s.split('\n').map((z) => (z === '' ? '>' : `> ${z}`)).join('\n')
// Formular-Hinweis, Sie und Du
assert.ok(text.includes('> Datenschutz. Wir verwenden Ihre Angaben, um Ihren Termin zu verwalten'))
assert.ok(text.includes('> Datenschutz. Wir verwenden deine Angaben, um deinen Termin zu verwalten'))
assert.ok(text.includes('Verantwortlich: {{Name des Betriebs}}, {{Anschrift}}.'))
// Haken, Gesundheitshinweis, Belehrung
assert.ok(text.includes(zitat(ZUSTIMMUNG_VORZEITIG('{{Name des Betriebs}}'))))
assert.ok(text.includes(zitat(ZUSTIMMUNG_STORNO)))
assert.ok(text.includes(zitat(ZUSTIMMUNG_ERINNERUNG('{{Stunden}}'))))
assert.ok(text.includes(zitat(GESUNDHEIT_HINWEIS)))
assert.ok(text.includes(zitat(widerrufsbelehrung(PLATZHALTER_BETRIEB))))
assert.ok(text.includes('Telefon: {{Telefon}}, E-Mail: {{E-Mail}}'))
// Knopftexte
for (const zeile of [
'| Sofort buchbar, ohne Preis | Termin buchen | Termin bestätigen |',
'| Sofort buchbar, mit Preis | Zahlungspflichtig buchen | Zahlungspflichtig bestätigen |',
'| Terminanfrage | Termin anfragen | Anfrage absenden |',
]) {
assert.ok(text.includes(`${zeile}\n`), `Knopftext-Zeile fehlt: ${zeile}`)
}
// Datenschutz-Absatz der Website, mit den bedingten Sätzen
assert.ok(text.includes('> Online-Terminbuchung'))
assert.ok(text.includes('Auf unserer Website können Sie Termine online buchen.'))
assert.ok(text.includes('Auf unserer Website kannst du Termine online buchen.'))
assert.ok(text.includes('Vertrag widerrufen'))
assert.ok(text.includes('Wenn Sie eine Erinnerung vor dem Termin wünschen'))
// Fassung 2026-10-05: Widerrufssatz am Erinnerungshaken und Abo-Ende-Satz im Datenschutz-Absatz (Sie und Du)
assert.ok(text.includes('> Ich möchte {{Stunden}} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen.\n'))
assert.equal(text.split('zum Beispiel weil unser Vertrag mit WEBklar endet, werden alle Termindaten 30 Tage nach dem Abschalten gelöscht.').length, 3, 'Abo-Ende-Satz in Sie- und Du-Fassung')
assert.ok(!text.includes('löschen wir alle Termindaten 30 Tage'))
// Nachweis-Hinweis (⚖ Q26)
assert.ok(text.includes('Der Nachweis speichert Hashes der Texte mit den Betriebsangaben zum Buchungszeitpunkt (⚖ Q26).'))
// kein Rest, keine echten Betriebsdaten
assert.ok(!/undefined|\bnull\b|NaN|\[object/.test(text))
assert.ok(!text.includes('\r'))
assert.ok(text.endsWith('\n') && !text.endsWith('\n\n'))
})
// SHA-256 der eingefrorenen Datei der Fassung 2026-10-02 (aus den unveränderten Texten erzeugt, vor jeder Textänderung eingecheckt)
const HASH_2026_10_02 = '920633e2e23c9c98aded0c1509db5cac1465058a025aeddc76a9a1282041a6a9'
// SHA-256 der Archivdatei der aktuellen Fassung: ändert sich ein Gästetext, ohne dass die Fassung (RECHTSTEXTE_VERSION) ein neues Datum
// und diese Datei eine neue Archivdatei bekommt, schlägt der Test an. Bei einer neuen Fassung: neue Datei, neues Datum, neuer Pin.
const HASH_AKTUELL = { '2026-10-05': '6bcad4c4a02ea5e39b3ec690d40617fdfb3cb1290aadd3304f0ecabab14c4a59' }
test('textstand_pin: Hash der aktuellen Archivdatei ist festgenagelt (Textänderung ohne neues Datum fällt auf)', () => {
assert.ok(Object.hasOwn(HASH_AKTUELL, RECHTSTEXTE_VERSION), `kein Hash-Pin für die Fassung ${RECHTSTEXTE_VERSION}`)
const datei = lies(archivPfad())
assert.equal(sha256(datei), HASH_AKTUELL[RECHTSTEXTE_VERSION], 'Gästetext geändert, aber die Fassung ist dieselbe: neues Datum, neue Archivdatei und neuer Pin nötig')
assert.equal(sha256(baueArchiv()), HASH_AKTUELL[RECHTSTEXTE_VERSION])
assert.notEqual(HASH_AKTUELL[RECHTSTEXTE_VERSION], HASH_2026_10_02, 'die neue Fassung unterscheidet sich von der eingefrorenen')
})
test('archiv_altfassung_eingefroren: Datei 2026-10-02 hat den alten Erinnerungstext ohne den Widerrufssatz; ihr Hash ist festgenagelt', () => {
const alt = lies(`${ARCHIV_DIR}/termine-gaestetexte-2026-10-02.md`)
assert.ok(alt.startsWith('# Gästetexte des Terminplaners, Fassung 2026-10-02\n'))
assert.ok(alt.includes('> Ich möchte {{Stunden}} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten.\n'), 'alter Erinnerungstext')
assert.ok(!alt.includes('Diese Einwilligung kann ich jederzeit widerrufen.'), 'der Widerrufssatz gehört erst zur nächsten Fassung')
assert.ok(alt.includes('Wird die Online-Terminbuchung abgeschaltet, löschen wir alle Termindaten 30 Tage nach dem Abschalten.'), 'alter Datenschutz-Absatz')
assert.equal(sha256(alt), HASH_2026_10_02, 'Die Datei der Fassung 2026-10-02 darf nie verändert werden; Textänderungen bekommen eine neue Fassung')
})