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_schutz: eine abweichende Datei der aktuellen Fassung wird nur mit --force überschrieben; gleiche oder neue Dateien bleiben erlaubt', (t) => { const root = mitTempRoot(t) const ziel = join(root, archivPfad()) const meldungen = [] const mitFehler = { log: () => {}, fehler: (m) => meldungen.push(m) } // neue Datei und identische Datei: erlaubt assert.equal(lauf([], { ...mitFehler, root }), 0) assert.equal(readFileSync(ziel, 'utf8'), baueArchiv()) assert.equal(lauf([], { ...mitFehler, root }), 0, 'derselbe Inhalt darf erneut geschrieben werden') assert.deepEqual(meldungen, []) // abweichende Datei (zum Beispiel die ausgelieferte Fassung mit einem nachträglich geänderten Text): Exit 1, Datei unverändert const ausgeliefert = `${baueArchiv()}Nachträglich geändert.\n` writeFileSync(ziel, ausgeliefert) assert.equal(lauf([], { ...mitFehler, root }), 1) assert.equal(readFileSync(ziel, 'utf8'), ausgeliefert, 'ohne --force bleibt die Datei, wie sie war') assert.match(meldungen[0], /weicht ab/) assert.match(meldungen[0], /--force/) assert.ok(meldungen[0].includes(archivPfad()), 'die Meldung nennt die Datei') // mit --force wird überschrieben assert.equal(lauf(['--force'], { ...mitFehler, root }), 0) assert.equal(readFileSync(ziel, 'utf8'), baueArchiv()) // --check bleibt schreibfrei; --force zusammen mit --check ist ein falscher Aufruf writeFileSync(ziel, ausgeliefert) assert.equal(lauf(['--check'], { ...mitFehler, root }), 1) assert.equal(readFileSync(ziel, 'utf8'), ausgeliefert) assert.equal(lauf(['--check', '--force'], { ...mitFehler, root }), 2) assert.equal(readFileSync(ziel, 'utf8'), ausgeliefert) }) 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': '830bc7364afc852755ca66d2213866160812f35ba7221f59198acf37588a8ada' } test('archiv_storno_regel: die Fassung 2026-10-05 nennt den Storno-Haken nur für Leistungen mit Preis, die eingefrorene Fassung 2026-10-02 die alte Regel', () => { const neu = lies(archivPfad()) assert.ok(neu.includes('### Stornobedingungen (Pflicht bei einer Leistung mit Preis, wenn der Betrieb Stornobedingungen hat; bei kostenlosen Leistungen fragt der Terminplaner nicht danach;')) assert.ok(!neu.includes('### Stornobedingungen (Pflicht, wenn der Betrieb Stornobedingungen hat;')) const alt = lies(`${ARCHIV_DIR}/termine-gaestetexte-2026-10-02.md`) assert.ok(alt.includes('### Stornobedingungen (Pflicht, wenn der Betrieb Stornobedingungen hat;'), 'die Altfassung beschreibt die alte Regel') assert.ok(lies('docs/rechtliches/termine-hinweise.md').includes('| Stornobedingungen | der Betrieb hat Stornobedingungen hinterlegt und die gebuchte Leistung hat einen Preis')) assert.ok(lies(`${ARCHIV_DIR}/README.md`).includes('Pflicht ist er nur noch bei einer Leistung mit Preis')) }) 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') })