import { test, after, mock } from 'node:test' import assert from 'node:assert/strict' import { closeSync, copyFileSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync, writeSync } from 'node:fs' import fsp from 'node:fs/promises' import { syncBuiltinESMExports } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Router } from 'express' import { useTestEnv } from './support/testEnv.js' useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' }) const { config } = await import('../server/config.js') const { startTestApp } = await import('./support/testApp.js') const { legalPaths, legalPdfFiles, legalPdfAttachments, featureLegalAttachments, featureLegalReady, featureLegalPaths, legalRouter, setLegalDirForTests } = await import( '../server/services/legalDocs.js' ) const analyse = await import('../server/services/analyseLegal.js') const { featureConfig } = await import('../server/services/featureRegistry.js') const V = config.termine.avvVersion const ALT = '2026-10-02' // eingefrorene Altfassung der Gaestetexte und der Analyse-Rechtstexte const root = (rel) => new URL(`../${rel}`, import.meta.url) // Router mit eigenem Verzeichnis: die Adressen ohne Fassung liefern die Datei der Fassung aus config const dir = mkdtempSync(join(tmpdir(), 'legaldocs-')) const inhalt = (name, version) => `
${name} ${version}
` const DATEIEN = ['leistungsbeschreibung-terminplaner', 'avv-terminplaner', 'widerrufsbelehrung-funktionen'] for (const slug of DATEIEN) writeFileSync(join(dir, `${slug}-${V}.html`), inhalt(slug, V)) writeFileSync(join(dir, `avv-website-analyse-${ALT}.html`), inhalt('avv-website-analyse', ALT)) const rechtliches = Router() rechtliches.use(legalRouter({ dir })) const app = await startTestApp({ '/rechtliches': rechtliches }) after(() => { app.close() rmSync(dir, { recursive: true, force: true }) }) const get = async (path) => { const r = await fetch(app.url + path) return { status: r.status, text: await r.text() } } /** Konstante und Register fuer einen Test setzen; stellt sie danach wieder her. */ async function mitFassung(fassung, fn) { const alt = config.termine.avvVersion config.termine.avvVersion = fassung try { return await fn() } finally { config.termine.avvVersion = alt } } test('legalPaths("termine", V) nennt drei versionierte Adressen', () => { assert.deepEqual(legalPaths('termine', V), { lb: `/rechtliches/leistungsbeschreibung-terminplaner-${V}.html`, avv: `/rechtliches/avv-terminplaner-${V}.html`, widerruf: `/rechtliches/widerrufsbelehrung-funktionen-${V}.html`, }) assert.deepEqual(Object.keys(legalPaths('termine', V)), ['lb', 'avv', 'widerruf']) assert.deepEqual(legalPaths('analyse', ALT), { avv: `/rechtliches/avv-website-analyse-${ALT}.html`, lb: `/rechtliches/leistungsbeschreibung-website-analyse-${ALT}.html`, }) }) test('legalPaths: ungültige Fassung und unbekannter Satz werfen', () => { for (const satz of ['termine', 'analyse']) { for (const v of ['../x', '2026-10-5', '2026-10-05x', 'ENTWURF', '', undefined, null, '2026-10-05/../x']) { assert.throws(() => legalPaths(satz, v), /Fassung/, `${satz}: ${String(v)}`) assert.throws(() => legalPdfFiles(satz, v), /Fassung/) } } for (const satz of ['news', 'unbekannt', '__proto__', 'toString', '', undefined]) { assert.throws(() => legalPaths(satz, V), /Satz/, String(satz)) assert.throws(() => legalPdfFiles(satz, V), /Satz/) } }) test('legalPdfFiles: Dateinamen und Pfade je Satz und Fassung', () => { const t = legalPdfFiles('termine', V) assert.deepEqual(Object.keys(t), ['lb', 'avv', 'widerruf']) assert.equal(t.lb.filename, `Leistungsbeschreibung-Terminplaner-${V}.pdf`) assert.equal(t.avv.filename, `AVV-Terminplaner-${V}.pdf`) assert.equal(t.widerruf.filename, `Widerrufsbelehrung-Muster-Widerrufsformular-${V}.pdf`) assert.equal(t.lb.path, new URL(`../public/rechtliches/leistungsbeschreibung-terminplaner-${V}.pdf`, import.meta.url).pathname) assert.equal(t.avv.path, new URL(`../public/rechtliches/avv-terminplaner-${V}.pdf`, import.meta.url).pathname) assert.equal(t.widerruf.path, new URL(`../public/rechtliches/widerrufsbelehrung-funktionen-${V}.pdf`, import.meta.url).pathname) const a = legalPdfFiles('analyse', ALT) assert.deepEqual(Object.keys(a), ['avv', 'lb']) assert.equal(a.avv.filename, `AVV-Website-Analyse-${ALT}.pdf`) assert.equal(a.lb.filename, `Leistungsbeschreibung-Website-Analyse-${ALT}.pdf`) }) test('legalPdfAttachments: fehlende Datei wirft, Analyse liefert avv und lb, ohne lässt Dokumente weg', async () => { await assert.rejects(legalPdfAttachments('termine', '1999-01-01')) await assert.rejects(legalPdfAttachments('analyse', '1999-01-01')) await assert.rejects(legalPdfAttachments('unbekannt', V), /Satz/) const alle = await legalPdfAttachments('analyse', ALT) assert.deepEqual(alle.map((x) => x.filename), [`AVV-Website-Analyse-${ALT}.pdf`, `Leistungsbeschreibung-Website-Analyse-${ALT}.pdf`]) for (const x of alle) { assert.equal(x.contentType, 'application/pdf') assert.equal(x.content.subarray(0, 4).toString(), '%PDF') } const ohneAvv = await legalPdfAttachments('analyse', ALT, { ohne: ['avv'] }) assert.deepEqual(ohneAvv.map((x) => x.filename), [`Leistungsbeschreibung-Website-Analyse-${ALT}.pdf`]) // eine ausgelassene Datei muss nicht vorhanden sein assert.deepEqual(await legalPdfAttachments('termine', '1999-01-01', { ohne: ['lb', 'avv', 'widerruf'] }), []) await assert.rejects(legalPdfAttachments('analyse', ALT, { ohne: ['gibtsnicht'] }), /Dokument/) }) test('featureLegalAttachments / featureLegalReady: news ohne Dokumente, unbekannte Funktion nie bereit', async () => { assert.deepEqual(await featureLegalAttachments('news'), []) assert.deepEqual(await featureLegalAttachments('news', { ohne: ['lb'] }), []) assert.equal(await featureLegalReady('news'), true) await assert.rejects(featureLegalAttachments('gibtsnicht'), /Funktion/) await assert.rejects(featureLegalAttachments('analyse'), /Funktion/) await assert.rejects(featureLegalAttachments('__proto__'), /Funktion/) assert.equal(await featureLegalReady('gibtsnicht'), false) assert.equal(await featureLegalReady('analyse'), false) }) test('featureLegalAttachments("termine") liest die Fassung aus config.termine.avvVersion; fehlt eine Datei, ist die Funktion nicht bereit', async () => { await mitFassung('1999-01-01', async () => { await assert.rejects(featureLegalAttachments('termine')) assert.equal(await featureLegalReady('termine'), false) }) }) // R114 (Task F8): featureLegalReady läuft bei jeder Bestellung (Stufe 5b des Checkouts) und prüft je PDF nur stat (Größe > 0) und die // ersten fünf Bytes "%PDF-", nie den ganzen Inhalt. Die Dateien liegen für den Test in einem Verzeichnis mit Kopien. test('featureLegalReady("termine"): je PDF stat und "%PDF-", nie den ganzen Inhalt lesen; jeder Mangel und jeder Fehler ergibt false, nie einen Wurf', async () => { const kopien = mkdtempSync(join(tmpdir(), 'legal-ready-')) mkdirSync(join(kopien, 'rechtliches')) // Quellen: die ausgelieferten Dateien (vor dem Tausch des Verzeichnisses ermittelt) const echteHtml = legalPaths('termine', V) const echtePdf = Object.fromEntries(Object.entries(legalPdfFiles('termine', V)).map(([doc, f]) => [doc, f.path])) const bereit = () => { for (const adresse of Object.values(echteHtml)) copyFileSync(root(`public${adresse}`), join(kopien, adresse)) for (const pfad of Object.values(echtePdf)) copyFileSync(pfad, join(kopien, 'rechtliches', pfad.split('/').at(-1))) } const pdfPfad = (doc) => join(kopien, 'rechtliches', echtePdf[doc].split('/').at(-1)) const htmlPfad = (doc) => join(kopien, echteHtml[doc]) const lesen = mock.method(fsp, 'readFile') syncBuiltinESMExports() try { setLegalDirForTests(kopien) bereit() assert.equal(await featureLegalReady('termine'), true) assert.equal(lesen.mock.callCount(), 0, 'readFile wird nicht aufgerufen') // ein riesiges PDF mit gültigem Kopf (nur Loch im Dateisystem): bereit, ohne es zu lesen const gross = openSync(pdfPfad('lb'), 'w') writeSync(gross, '%PDF-1.4\n') closeSync(gross) await fsp.truncate(pdfPfad('lb'), 512 * 1024 * 1024) assert.equal(await featureLegalReady('termine'), true) assert.equal(lesen.mock.callCount(), 0, 'auch ein 512-MB-PDF wird nicht gelesen') bereit() // Mängel je Datei: fehlend, leer, falscher Kopf, zu kurz, Verzeichnis statt Datei for (const doc of ['lb', 'avv', 'widerruf']) { const pfad = pdfPfad(doc) rmSync(pfad) assert.equal(await featureLegalReady('termine'), false, `${doc}: PDF fehlt`) writeFileSync(pfad, '') assert.equal(await featureLegalReady('termine'), false, `${doc}: PDF leer`) writeFileSync(pfad, 'kein PDF') assert.equal(await featureLegalReady('termine'), false, `${doc}: kein %PDF-`) writeFileSync(pfad, '%PDF') assert.equal(await featureLegalReady('termine'), false, `${doc}: zu kurz`) rmSync(pfad) mkdirSync(pfad) assert.equal(await featureLegalReady('termine'), false, `${doc}: Verzeichnis statt Datei`) rmSync(pfad, { recursive: true }) bereit() assert.equal(await featureLegalReady('termine'), true, `${doc}: wieder bereit`) // die HTML-Seite fehlt rmSync(htmlPfad(doc)) assert.equal(await featureLegalReady('termine'), false, `${doc}: HTML fehlt`) bereit() } assert.equal(lesen.mock.callCount(), 0, 'nie readFile') // ohne Verzeichnis, mit unbekannter Funktion und mit unbekannter Fassung: false statt Wurf await mitFassung('1999-01-01', async () => assert.equal(await featureLegalReady('termine'), false)) assert.equal(await featureLegalReady('gibtsnicht'), false) setLegalDirForTests(join(kopien, 'gibtsnicht')) assert.equal(await featureLegalReady('termine'), false) // die Anhänge der Mail lesen dagegen die ganzen Dateien (legalPdfAttachments) setLegalDirForTests(kopien) bereit() assert.equal((await featureLegalAttachments('termine')).length, 3) assert.ok(lesen.mock.callCount() >= 3) } finally { setLegalDirForTests(null) mock.restoreAll() syncBuiltinESMExports() rmSync(kopien, { recursive: true, force: true }) } assert.equal(await featureLegalReady('termine'), true, 'mit den ausgelieferten Dateien bereit') }) // R121: ein Anhang ist ein PDF. Eine leere oder keine PDF-Datei wird nie angehängt, sondern wirft (die Aufrufer fangen den Wurf). test('legalPdfAttachments: eine leere Datei oder eine Datei ohne "%PDF-" am Anfang wirft, ausgenommene Dokumente zählen nicht', async () => { const kopien = mkdtempSync(join(tmpdir(), 'legal-anhang-')) mkdirSync(join(kopien, 'rechtliches')) const echtePdf = Object.fromEntries(Object.entries(legalPdfFiles('termine', V)).map(([doc, f]) => [doc, f.path])) const pdfPfad = (doc) => join(kopien, 'rechtliches', echtePdf[doc].split('/').at(-1)) const bereit = () => { for (const pfad of Object.values(echtePdf)) copyFileSync(pfad, join(kopien, 'rechtliches', pfad.split('/').at(-1))) } try { setLegalDirForTests(kopien) bereit() const gut = await legalPdfAttachments('termine', V) assert.equal(gut.length, 3) for (const a of gut) assert.equal(a.content.subarray(0, 5).toString('latin1'), '%PDF-') for (const doc of ['lb', 'avv', 'widerruf']) { for (const [mangel, inhaltDatei] of [['leer', ''], ['kein PDF', 'kein PDF'], ['zu kurz', '%PDF'], ['PDF nicht am Anfang', ' %PDF-1.4\n']]) { writeFileSync(pdfPfad(doc), inhaltDatei) await assert.rejects(legalPdfAttachments('termine', V), /PDF/, `${doc}: ${mangel}`) await assert.rejects(featureLegalAttachments('termine'), /PDF/, `${doc}: ${mangel} (Funktion)`) // ein ausgenommenes Dokument wird nicht gelesen und darf kaputt sein const ohne = await legalPdfAttachments('termine', V, { ohne: [doc] }) assert.equal(ohne.length, 2, `${doc}: ${mangel}, ausgenommen`) } bereit() assert.equal((await legalPdfAttachments('termine', V)).length, 3, `${doc}: wieder in Ordnung`) } } finally { setLegalDirForTests(null) rmSync(kopien, { recursive: true, force: true }) } }) // Fassung wählbar (Task F8, Fix-Runde 1): K15 schickt die Unterlagen der Fassung, der der Kunde zugestimmt hat test('featureLegalAttachments / featureLegalReady / featureLegalPaths mit opts.fassung', async () => { assert.deepEqual(featureLegalPaths('termine'), legalPaths('termine', V)) assert.deepEqual(featureLegalPaths('termine', { fassung: '2026-10-01' }), legalPaths('termine', '2026-10-01')) assert.equal(featureLegalPaths('news'), null) assert.throws(() => featureLegalPaths('gibtsnicht'), /Funktion/) assert.throws(() => featureLegalPaths('termine', { fassung: '2026-10-5' }), /Fassung/) // ausdrücklich die aktuelle Fassung = ohne Angabe assert.deepEqual((await featureLegalAttachments('termine', { fassung: V })).map((a) => a.filename), (await featureLegalAttachments('termine')).map((a) => a.filename)) assert.equal(await featureLegalReady('termine', { fassung: V }), true) // andere Fassung ohne Dateien: Wurf bei den Anhängen, false bei der Prüfung, news weiter [] await assert.rejects(featureLegalAttachments('termine', { fassung: '1999-01-01' })) assert.equal(await featureLegalReady('termine', { fassung: '1999-01-01' }), false) assert.equal(await featureLegalReady('termine', { fassung: 'kaputt' }), false) assert.deepEqual(await featureLegalAttachments('news', { fassung: '1999-01-01' }), []) // opts.ohne wirkt weiter neben fassung assert.deepEqual((await featureLegalAttachments('termine', { fassung: V, ohne: ['avv'] })).map((a) => a.filename.split('-')[0]), ['Leistungsbeschreibung', 'Widerrufsbelehrung']) }) test('config.termine.avvVersion: Datumsmuster, nach der Altfassung der Gästetexte, echter Tag', () => { assert.match(config.termine.avvVersion, /^\d{4}-\d{2}-\d{2}$/) assert.ok(config.termine.avvVersion > ALT, `${config.termine.avvVersion} liegt nicht nach ${ALT}`) assert.ok(config.termine.avvVersion >= '2026-10-03') assert.ok(!Number.isNaN(Date.parse(`${config.termine.avvVersion}T00:00:00Z`))) assert.equal(new Date(`${config.termine.avvVersion}T00:00:00Z`).toISOString().slice(0, 10), config.termine.avvVersion) }) test('config.ustHinweis bleibt wörtlich', () => { assert.equal(config.ustHinweis, 'ohne Umsatzsteuer gemäß § 19 UStG') }) test('Register: leistungsbeschreibung und avv des Terminplaners folgen der Konstante (Getter, Drift-Wächter)', async () => { const t = featureConfig('termine') assert.deepEqual(t.leistungsbeschreibung, { version: V, url: legalPaths('termine', V).lb }) assert.deepEqual(t.avv, { version: V, url: legalPaths('termine', V).avv }) assert.doesNotMatch(JSON.stringify([t.leistungsbeschreibung, t.avv]), /ENTWURF/) // zur Laufzeit geaendert: der Getter folgt await mitFassung('2099-01-02', async () => { assert.deepEqual(t.leistungsbeschreibung, { version: '2099-01-02', url: legalPaths('termine', '2099-01-02').lb }) assert.deepEqual(t.avv, { version: '2099-01-02', url: legalPaths('termine', '2099-01-02').avv }) }) assert.deepEqual(t.leistungsbeschreibung, { version: V, url: legalPaths('termine', V).lb }) // ein Test darf den Eintrag ueberschreiben (siehe termineFragebogen.test.js) const merke = t.leistungsbeschreibung t.leistungsbeschreibung = { version: 'T1', url: '/x' } assert.deepEqual(t.leistungsbeschreibung, { version: 'T1', url: '/x' }) t.leistungsbeschreibung = merke assert.deepEqual(t.leistungsbeschreibung, merke) }) test('News: Leistungsbeschreibung folgt der Fassung