import { readFileSync, writeFileSync, mkdtempSync, mkdirSync, copyFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { test, after, mock } from 'node:test' import assert from 'node:assert/strict' import { startAppwriteMock } from './support/appwriteMock.js' import { startFakeStripe } from './support/fakeStripe.js' import { useTestEnv } from './support/testEnv.js' import { schemaKeys } from './support/schema.js' // Rechtstext-Korrekturen der News-Pinnwand (Task F2): Zitat § 356 Abs. 5 Nr. 2, Wertersatz-Satz, § 19 UStG statt "brutto". // Die Wortlaute sind hier als Literale eingefroren (Wortlaut-Pin); die Fassung folgt config.termine.avvVersion. const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions'), featureConsents: schemaKeys('featureConsents') }, }) const st = await startFakeStripe() useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) const { config } = await import('../server/config.js') const { startTestApp, get, post } = await import('./support/testApp.js') const { customerCookie, seedFixtures } = await import('./support/fixtures.js') const { default: funktionenRoutes } = await import('../server/routes/funktionen.js') const sf = await import('../server/services/siteFeatures.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const { mailSink } = await import('./support/mailSink.js') const app = await startTestApp({ '/api/funktionen': funktionenRoutes }) const mails = mailSink() setMailTransportForTests(mails) after(() => { setMailTransportForTests(null) return Promise.all([app.close(), aw.close(), st.close()]) }) seedFixtures(aw) aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei', liveDomain: 'baeckerei.de' }) aw.seed('websiteProjects', { $id: 'p2', customerId: 'c1', projectName: 'Café' }) const cookie = customerCookie('c1') const root = (rel) => new URL(`../${rel}`, import.meta.url) const lesen = (rel) => readFileSync(root(rel), 'utf8') const NEWS_SATZ = 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung des Einbaus erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).' const ALTER_SATZ = 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht erlischt, sobald der Einbau vollständig erbracht ist (§ 356 Abs. 4 BGB).' const USTHINWEIS = 'ohne Umsatzsteuer gemäß § 19 UStG' test('news_zustimmung_wortlaut: Register = Alias = exakter Text mit Abs. 5 Nr. 2 und § 357a Abs. 2, Fassung = ', () => { const news = config.features.news assert.deepEqual(news.consent.items, [{ id: 'beginn', haken: true, text: NEWS_SATZ }]) assert.equal(config.funktionen.consentText, NEWS_SATZ) assert.equal(news.consent.items[0].text, config.funktionen.consentText) assert.ok(NEWS_SATZ.includes('Abs. 5 Nr. 2') && NEWS_SATZ.includes('§ 357a Abs. 2')) assert.ok(!NEWS_SATZ.includes('Abs. 4')) assert.equal(news.consent.knopf, 'Zahlungspflichtig bestellen · 30 €') // die Fassung folgt der Konstante (zur Laufzeit), auch die der Leistungsbeschreibung, deren Stand sich mit dem Text ändert assert.equal(news.consent.version, config.termine.avvVersion) assert.equal(news.leistungsbeschreibung.version, config.termine.avvVersion) assert.ok(news.consent.version > '2026-10-02') const alt = config.termine.avvVersion config.termine.avvVersion = '2099-01-02' try { assert.equal(news.consent.version, '2099-01-02') assert.equal(news.leistungsbeschreibung.version, '2099-01-02') } finally { config.termine.avvVersion = alt } assert.equal(news.leistungsbeschreibung.url, '/vertragsunterlagen/funktionen-leistungsbeschreibung.html') assert.equal(news.mails.preisHinweis, `einmalig, ${USTHINWEIS}; die Nutzung ist kostenlos, es läuft kein Abo`) }) test('waechter_zitat_und_preistext: kein altes Zitat, keine Mehrwertsteuer-Angabe, kein "brutto" in den Kundentexten', () => { const dateien = [ 'server/config.js', 'public/vertragsunterlagen/funktionen-leistungsbeschreibung.html', 'public/funktionen.js', 'server/mail-templates/K15 Bestellung Funktion.html', 'server/services/stripeBilling.js', ] for (const datei of dateien) { const text = lesen(datei) assert.doesNotMatch(text, /§ 356 Abs\. 4/, `${datei}: altes Zitat`) assert.doesNotMatch(text, /inklusive (Mehr|Umsatz)steuer/i, `${datei}: inklusive Steuer`) assert.doesNotMatch(text, /inklusive Mehrwertsteuer/i, `${datei}: inklusive Mehrwertsteuer`) assert.doesNotMatch(text, /\bbrutto\b/i, `${datei}: brutto`) } const lb = lesen('public/vertragsunterlagen/funktionen-leistungsbeschreibung.html') assert.equal(lb.split(USTHINWEIS).length - 1, 2, 'LB nennt den Hinweis in Kopf-Tabelle und Abschnitt 2') assert.ok(lb.includes('30,00 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG), keine laufenden Kosten')) assert.ok(lb.includes('Der Einbau kostet 30,00 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG).')) assert.ok(lb.includes('erlischt nach § 356 Abs. 5 Nr. 2 BGB')) assert.ok(lb.includes('(§ 357a Abs. 2 BGB)')) assert.ok(lb.includes(''), 'die Freigabe bleibt Sache des Inhabers') // der Stand der LB ist der Tag der Fassung const [j, m, t] = config.termine.avvVersion.split('-') assert.ok(lb.includes(`

Stand ${t}.${m}.${j}

`), 'Stand der LB = config.termine.avvVersion') }) test('bestellseite_hinweis: GET /api/funktionen liefert ustHinweis', async () => { const body = await get('/api/funktionen', { cookie }) assert.equal(body.ustHinweis, config.ustHinweis) assert.equal(config.ustHinweis, USTHINWEIS) assert.equal(body.consentText, NEWS_SATZ) assert.equal(body.features.news.consent.items[0].text, NEWS_SATZ) assert.equal(body.features.news.consent.version, config.termine.avvVersion) }) // ---- Client: public/funktionen.js wirklich laden (absolute Importe auf die Dateien umleiten) und die Buchungsseite rendern const dir = mkdtempSync(join(tmpdir(), 'fn-recht-')) const fileUrl = (name) => pathToFileURL(new URL(`../public/${name}`, import.meta.url).pathname).href const clientQuelle = lesen('public/funktionen.js') .replace("'/fn-util.js'", `'${fileUrl('fn-util.js')}'`) .replace("'/analyse-view.js'", `'${fileUrl('analyse-view.js')}'`) .replace("'./vertraege-link.js'", `'${fileUrl('vertraege-link.js')}'`) const clientDatei = join(dir, 'funktionen.mjs') writeFileSync(clientDatei, clientQuelle) const { initNews } = await import(pathToFileURL(clientDatei).href) function fakeEl() { const handlers = {} return { innerHTML: '', textContent: '', disabled: false, checked: false, value: '', dataset: {}, classList: { add() {}, remove() {}, toggle() {} }, handlers, addEventListener(ev, fn) { handlers[ev] = fn }, querySelector: () => null, querySelectorAll: () => [] } } async function renderBuchung(antwort) { const echtesFetch = globalThis.fetch globalThis.window = { location: { hash: '#news', pathname: '/dashboard.html', search: '', href: '' }, history: { replaceState() {} } } globalThis.fetch = async () => ({ ok: true, status: 200, json: async () => antwort }) const els = {} const stage = { innerHTML: '', els, querySelector: (sel) => (els[sel] ||= fakeEl()), querySelectorAll: () => [] } const rootEl = fakeEl() rootEl.querySelector = (sel) => (sel === '#news-stage' ? stage : null) try { await initNews(rootEl, { projectId: 'p1' }) return stage } finally { delete globalThis.window globalThis.fetch = echtesFetch } } const frei = { projectId: 'p1', projectName: 'Muster', features: { news: { state: 'off' } } } test('bestellseite_hinweis (Client): Preis mit Hinweis aus ustHinweis, per textContent; Rückfall = config.ustHinweis; kein "inklusive Mehrwertsteuer"', async () => { const stage = await renderBuchung({ projects: [frei], consentText: NEWS_SATZ, ustHinweis: 'Testhinweis § 19' }) assert.match(stage.innerHTML, /30 €<\/strong>/) assert.doesNotMatch(stage.innerHTML, /Mehrwertsteuer|inklusive|brutto/i) assert.doesNotMatch(stage.innerHTML, /Testhinweis/, 'der Hinweis steht nie im HTML-Text, nur als textContent') assert.equal(stage.els['#news-ust-hinweis'].textContent, 'Testhinweis § 19') assert.match(stage.innerHTML, /einmalig, <\/span>/) // ein Hinweis mit Markup bleibt Text const bose = await renderBuchung({ projects: [frei], ustHinweis: '' }) assert.doesNotMatch(bose.innerHTML, /') // fehlt der Hinweis in der Antwort, gilt der Rückfall im Client: derselbe Wortlaut wie config.ustHinweis const rueck = await renderBuchung({ projects: [frei], consentText: NEWS_SATZ }) assert.equal(rueck.els['#news-ust-hinweis'].textContent, config.ustHinweis) for (const leer of ['', ' ', 5, null]) { const r = await renderBuchung({ projects: [frei], ustHinweis: leer }) assert.equal(r.els['#news-ust-hinweis'].textContent, config.ustHinweis, String(leer)) } }) // ---- K15 (Bestellbestätigung) der News const paidEvent = (sessionId, projectId, featureKey = 'news', customerId = 'c1') => ({ id: `evt_${sessionId}`, object: 'event', type: 'checkout.session.completed', data: { object: { id: sessionId, object: 'checkout.session', mode: 'payment', payment_status: 'paid', metadata: { customerId, projectId, featureKey } } }, }) test('k15_news: Preiszeile mit § 19 UStG statt "brutto"', async () => { aw.seed('websiteProjects', { $id: 'p3', customerId: 'c1', projectName: 'Bistro' }) await sf.markOrdered({ customerId: 'c1', projectId: 'p3', key: 'news', sessionId: 'cs_k15', consent: {} }) const vor = mails.sent.length await sf.handleFeatureCheckoutEvent(paidEvent('cs_k15', 'p3')) const k15 = mails.sent.slice(vor).filter((m) => m.subject.startsWith('Deine Bestellung')) assert.equal(k15.length, 1) assert.ok(k15[0].text.includes(`Einmalig bezahlt: 30,00 € (einmalig, ${USTHINWEIS}; die Nutzung ist kostenlos, es läuft kein Abo)`), k15[0].text) assert.doesNotMatch(k15[0].text, /brutto/i) assert.doesNotMatch(k15[0].html, /brutto/i) assert.ok(k15[0].html.includes(USTHINWEIS)) }) // ---- bestehende Bestellungen behalten Text und Fassung; neue speichern die Register-Werte test('alte_bestellung_unveraendert: altes Dokument bleibt, neue Bestellung speichert Register-Text und Fassung', async () => { aw.seed('portalFeatures', { $id: 'f_p2_news', // feste ID je (Projekt, Funktion) enabled: true, unlockedByPurchase: true, customerId: 'c1', projectId: 'p2', featureKey: 'news', state: 'active', purchaseStatus: 'paid', consentText: ALTER_SATZ, consentAt: '2026-09-30T10:00:00.000Z', orderedAt: '2026-09-30T10:00:00.000Z', }) aw.seed('featureConsents', { $id: 'fc_alt', customerId: 'c1', projectId: 'p2', featureKey: 'news', stripeSessionId: 'cs_alt', acceptedAt: '2026-09-30T10:00:00.000Z', fassungen: JSON.stringify({ zustimmung: '2026-09-30', leistungsbeschreibung: '2026-09-30' }), texte: JSON.stringify([{ id: 'beginn', text: ALTER_SATZ, haken: true }]), }) const alt = await sf.getFeatureDoc('c1', 'p2', 'news') assert.equal(alt.consentText, ALTER_SATZ) const liste = await get('/api/funktionen', { cookie }) assert.equal(liste.projects.find((p) => p.projectId === 'p2').features.news.state, 'active') const res = await post('/api/funktionen/news/checkout', { projectId: 'p1', consent: true }, { cookie }) assert.equal(res.status, 200, JSON.stringify(res.body)) const neu = await sf.getFeatureDoc('c1', 'p1', 'news') assert.equal(neu.consentText, NEWS_SATZ) const nachweise = [...aw.docs('featureConsents').values()].filter((d) => d.projectId === 'p1') assert.equal(nachweise.length, 1) assert.deepEqual(JSON.parse(nachweise[0].fassungen), { zustimmung: config.termine.avvVersion, leistungsbeschreibung: config.termine.avvVersion }) const texte = JSON.parse(nachweise[0].texte) assert.equal(texte[0].text, NEWS_SATZ) // das alte Dokument und sein Nachweis sind unverändert assert.equal((await sf.getFeatureDoc('c1', 'p2', 'news')).consentText, ALTER_SATZ) assert.deepEqual(JSON.parse(aw.docs('featureConsents').get('fc_alt').fassungen), { zustimmung: '2026-09-30', leistungsbeschreibung: '2026-09-30' }) assert.equal(JSON.parse(aw.docs('featureConsents').get('fc_alt').texte)[0].text, ALTER_SATZ) }) // ======================================================================================== Vertragsunterlagen (Task F8) // K15 des Terminplaners trägt Leistungsbeschreibung, AVV und Widerrufsbelehrung als PDF (dauerhafter Datenträger, § 312f BGB); // der Checkout fragt vorher, ob die Unterlagen abrufbar sind; die Bestellseite zeigt Belehrung und Links ohne Aufklappen. const consents = await import('../server/services/featureConsents.js') const { widerrufsbelehrungFunktion } = await import('../server/services/termine/rechtstexte.js') const { featureConfig } = await import('../server/services/featureRegistry.js') const { legalPaths, legalPdfFiles, setLegalDirForTests } = await import('../server/services/legalDocs.js') const { subscriptionDoc, wartungDoc } = await import('./support/fixtures.js') const T = config.features.termine const V = config.termine.avvVersion const BASE = config.publicBaseUrl const KLICK = '2026-10-05T08:15:00.000Z' // 10:15 Uhr in Berlin const stumm = () => mock.method(console, 'error', () => {}) let zaehler = 0 /** * Terminplaner bestellen (Nachweis wie ihn der Checkout speichert) und bezahlen: liefert K15 an den Kunden, T16 an das Team und das Dokument. * nachweis false: ohne Zustimmungsnachweis; fassungen: andere Fassungen im Nachweis; customerId: anderer Kunde. */ async function bezahleTermine({ nachweis = true, fassungen = null, customerId = 'c1' } = {}) { zaehler += 1 const projectId = `ptermine${zaehler}` const sessionId = `cs_ptermine${zaehler}` aw.seed('websiteProjects', { $id: projectId, customerId, projectName: `Salon ${zaehler}` }) await sf.markOrdered({ customerId, projectId, key: 'termine', sessionId, consent: {} }) if (nachweis) { await consents.speichereZustimmung({ customerId, projectId, featureKey: 'termine', stripeSessionId: sessionId, fassungen: fassungen ?? consents.zustimmungsfassungen(T), texte: consents.nachweisTexte(T, { belehrung: widerrufsbelehrungFunktion() }), ip: '127.0.0.1', userAgent: 'test', konto: 'Kunde Eins ', at: KLICK, }) } const vor = mails.sent.length await sf.handleFeatureCheckoutEvent(paidEvent(sessionId, projectId, 'termine', customerId)) const neu = mails.sent.slice(vor) const k15 = neu.filter((m) => m.subject.startsWith('Deine Bestellung')) const t16 = neu.filter((m) => m.subject.startsWith('Neue Funktion bestellt')) return { projectId, sessionId, k15: k15[0] ?? null, k15Anzahl: k15.length, t16: t16[0] ?? null, doc: await sf.getFeatureDoc(customerId, projectId, 'termine') } } const anhangDateien = () => { const d = legalPdfFiles('termine', V) return [d.lb, d.avv, d.widerruf] } test('k15_termine_anhaenge: drei PDFs in der Reihenfolge lb, avv, widerruf, Namen aus legalPdfFiles; kein Registerhaken mails.anhaenge', async () => { assert.equal(Object.hasOwn(featureConfig('termine').mails, 'anhaenge'), false) const { k15, k15Anzahl, t16 } = await bezahleTermine() assert.equal(k15Anzahl, 1) assert.deepEqual(k15.attachments.map((a) => a.filename), anhangDateien().map((f) => f.filename)) assert.deepEqual(k15.attachments.map((a) => a.filename), [`Leistungsbeschreibung-Terminplaner-${V}.pdf`, `AVV-Terminplaner-${V}.pdf`, `Widerrufsbelehrung-Muster-Widerrufsformular-${V}.pdf`]) for (const [i, datei] of anhangDateien().entries()) { assert.equal(k15.attachments[i].contentType, 'application/pdf') const bytes = Buffer.from(k15.attachments[i].content, 'base64') assert.equal(bytes.subarray(0, 5).toString('latin1'), '%PDF-') assert.deepEqual(bytes, readFileSync(datei.path), 'genau die ausgelieferte Datei') } // Anlagen-Block mit dem Satz zu den PDFs, T16 ohne Hinweis auf fehlende Unterlagen assert.match(k15.text, /Deine Vertragsunterlagen/) assert.match(k15.text, /Alle drei hängen als PDF an dieser E-Mail\./) assert.doesNotMatch(t16.text, /Unterlagen von Hand nachsenden/) }) test('k15_news_ohne_anhaenge: die News-Bestätigung bleibt unverändert, ohne Anhang und ohne Blöcke für Vertragsunterlagen', async () => { aw.seed('websiteProjects', { $id: 'pnews8', customerId: 'c1', projectName: 'Bistro Acht' }) await sf.markOrdered({ customerId: 'c1', projectId: 'pnews8', key: 'news', sessionId: 'cs_news8', consent: {} }) const vor = mails.sent.length await sf.handleFeatureCheckoutEvent(paidEvent('cs_news8', 'pnews8')) const neu = mails.sent.slice(vor) const k15 = neu.find((m) => m.subject.startsWith('Deine Bestellung')) assert.ok(k15) assert.ok(!k15.attachments || k15.attachments.length === 0) assert.doesNotMatch(k15.text, /Vertragsunterlagen|Deine Zustimmungen|hängen als PDF|\/rechtliches\//) assert.doesNotMatch(k15.html, /\/rechtliches\//) const t16 = neu.find((m) => m.subject.startsWith('Neue Funktion bestellt')) assert.doesNotMatch(t16.text, /Unterlagen von Hand nachsenden/) }) test('k15_links_versioniert: die Adressen der drei Unterlagen tragen die Fassung, keine Adresse ohne Fassung', async () => { const { k15 } = await bezahleTermine() const adressen = legalPaths('termine', V) for (const adresse of Object.values(adressen)) { assert.ok(adresse.endsWith(`-${V}.html`)) assert.ok(k15.text.includes(`${BASE}${adresse}`), `Text enthält ${adresse}`) assert.ok(k15.html.includes(`href="${BASE}${adresse}"`), `HTML verlinkt ${adresse}`) } assert.ok(k15.text.includes(`Fassung ${V}: Leistungsbeschreibung`)) for (const slug of ['leistungsbeschreibung-terminplaner', 'avv-terminplaner', 'widerrufsbelehrung-funktionen']) { assert.doesNotMatch(k15.text, new RegExp(`/rechtliches/${slug}\\.html`), `${slug}: Adresse ohne Fassung`) assert.doesNotMatch(k15.html, new RegExp(`/rechtliches/${slug}\\.html`)) } }) test('k15_zustimmungen_text: Wortlaut der vier Haken, Zeitpunkt (Berlin) und Knopf aus dem Nachweis zur bezahlten Sitzung', async () => { const { k15 } = await bezahleTermine() assert.ok(k15.text.includes('Deine Zustimmungen')) assert.ok(k15.text.includes('Du hast am 05.10.2026 um 10:15 Uhr im Kundenportal zugestimmt und mit „Zahlungspflichtig bestellen · 149 €“ bestellt:'), k15.text) for (const item of T.consent.items) assert.ok(k15.text.includes(`– ${item.text}`), `Haken ${item.id} im Text`) assert.ok(k15.text.includes('(§ 356 Abs. 5 Nr. 2 BGB)')) assert.doesNotMatch(k15.text, /Widerrufsrecht\n\nSie haben das Recht/, 'die Belehrung selbst steht als PDF im Anhang, nicht im Text') // ohne Nachweis (Altbestand) entfällt nur der Block const ohne = await bezahleTermine({ nachweis: false }) assert.doesNotMatch(ohne.k15.text, /Deine Zustimmungen/) assert.match(ohne.k15.text, /Deine Vertragsunterlagen/) assert.equal(ohne.k15.attachments.length, 3) }) test('k15_anhang_nicht_lesbar: nicht lesbare PDFs => K15 ohne Anhang, T16 mit Block anhang_fehlt, Dokument setup mit Ticket', async () => { const leer = mkdtempSync(join(tmpdir(), 'k15-leer-')) const log = stumm() try { setLegalDirForTests(leer) const { k15, k15Anzahl, t16, doc } = await bezahleTermine() assert.equal(k15Anzahl, 1, 'die Bestätigung geht vor dem Anhang raus') assert.ok(!k15.attachments || k15.attachments.length === 0) assert.match(k15.text, /Deine Vertragsunterlagen/, 'die versionierten Links stehen trotzdem in der Mail') assert.doesNotMatch(k15.text, /hängen als PDF/, 'kein Satz über Anhänge, die es nicht gibt') assert.match(k15.text, /Deine Zustimmungen/) assert.ok(t16.text.includes('Unterlagen von Hand nachsenden'), t16.text) // die Zahlung hat Spur: Dokument in Einrichtung, Ticket angelegt, kein Wurf (der Webhook antwortet 200) assert.equal(doc.state, 'setup') assert.ok(doc.ticketId) assert.ok(aw.docs('workorders').has(doc.ticketId)) assert.ok(log.mock.calls.some((c) => String(c.arguments[0]).includes('K15-Anhaenge') || String(c.arguments).includes('nicht lesbar'))) } finally { setLegalDirForTests(null) log.mock.restore() rmSync(leer, { recursive: true, force: true }) } // Fassung zwischen Klick und Zahlung angehoben: andere Unterlagen als die bestellten gehen nie raus const log2 = stumm() try { const alt = await bezahleTermine({ fassungen: { ...consents.zustimmungsfassungen(T), zustimmung: '2026-10-02' } }) assert.ok(!alt.k15.attachments || alt.k15.attachments.length === 0) assert.doesNotMatch(alt.k15.text, /Deine Vertragsunterlagen|\/rechtliches\//) assert.match(alt.k15.text, /Deine Zustimmungen/) assert.ok(alt.t16.text.includes('Unterlagen von Hand nachsenden')) assert.equal(alt.doc.state, 'setup') } finally { log2.mock.restore() } // Kunde ohne E-Mail-Adresse: keine K15, T16 sagt es dem Team, und das Log nennt den Grund (T16: "Der Grund steht im Log des Portals") aw.seed('customers', { $id: 'c9', name: 'Ohne Adresse', email: '' }) const log3 = stumm() try { const ohneAdresse = await bezahleTermine({ customerId: 'c9' }) assert.equal(ohneAdresse.k15Anzahl, 0) assert.ok(ohneAdresse.t16.text.includes('Unterlagen von Hand nachsenden')) const zeilen = log3.mock.calls.map((c) => c.arguments.join(' ')) assert.ok(zeilen.some((z) => z.includes(`K15 (Funktion ${ohneAdresse.doc.$id}) nicht gesendet: beim Kunden ist keine E-Mail-Adresse hinterlegt`)), zeilen.join('\n')) assert.doesNotMatch(zeilen.join('\n'), /Ohne Adresse/, 'keine Personendaten im Log') } finally { log3.mock.restore() } }) /** Verzeichnis mit Kopien der ausgelieferten Dateien unter dem Namen einer anderen Fassung (HTML und PDF je Dokument). */ function dateienDerFassung(fassung) { const dir = mkdtempSync(join(tmpdir(), 'k15-fassung-')) mkdirSync(join(dir, 'rechtliches')) const echt = legalPdfFiles('termine', V) for (const f of Object.values(echt)) { const slug = f.path.split('/').at(-1).replace(`-${V}.pdf`, '') copyFileSync(f.path, join(dir, 'rechtliches', `${slug}-${fassung}.pdf`)) copyFileSync(f.path.replace(/\.pdf$/, '.html'), join(dir, 'rechtliches', `${slug}-${fassung}.html`)) } return dir } test('k15_vereinbarte_fassung: wurde die Fassung zwischen Klick und Zahlung angehoben, gehen PDFs und Links der vereinbarten Fassung raus, wenn es sie gibt', async () => { const alt = '2026-10-01' const dir = dateienDerFassung(alt) const log = stumm() try { setLegalDirForTests(dir) const vereinbart = Object.fromEntries(Object.keys(consents.zustimmungsfassungen(T)).map((k) => [k, alt])) const { k15, t16, doc } = await bezahleTermine({ fassungen: vereinbart }) assert.deepEqual(k15.attachments.map((a) => a.filename), [`Leistungsbeschreibung-Terminplaner-${alt}.pdf`, `AVV-Terminplaner-${alt}.pdf`, `Widerrufsbelehrung-Muster-Widerrufsformular-${alt}.pdf`]) for (const a of k15.attachments) assert.equal(Buffer.from(a.content, 'base64').subarray(0, 5).toString('latin1'), '%PDF-') for (const slug of ['leistungsbeschreibung-terminplaner', 'avv-terminplaner', 'widerrufsbelehrung-funktionen']) { assert.ok(k15.text.includes(`${BASE}/rechtliches/${slug}-${alt}.html`), `${slug}: Link der vereinbarten Fassung`) assert.ok(!k15.text.includes(`${slug}-${V}.html`), `${slug}: kein Link der aktuellen Fassung`) } assert.ok(k15.text.includes(`Fassung ${alt}: Leistungsbeschreibung`)) assert.match(k15.text, /Alle drei hängen als PDF an dieser E-Mail\./) assert.doesNotMatch(t16.text, /Unterlagen von Hand nachsenden/) assert.equal(doc.state, 'setup') assert.ok(log.mock.calls.some((c) => String(c.arguments[0]).includes(`zugestimmt, aktuell ist ${V}`)), 'das Log nennt den Fassungswechsel') } finally { setLegalDirForTests(null) log.mock.restore() rmSync(dir, { recursive: true, force: true }) } // gibt es die Dateien der vereinbarten Fassung nicht, geht K15 ohne PDFs und ohne Links raus (Dateien der aktuellen Fassung bleiben ungenutzt) const log2 = stumm() try { const fehlend = await bezahleTermine({ fassungen: { ...consents.zustimmungsfassungen(T), zustimmung: '2026-09-30' } }) assert.ok(!fehlend.k15.attachments || fehlend.k15.attachments.length === 0) assert.doesNotMatch(fehlend.k15.text, /Deine Vertragsunterlagen|\/rechtliches\//) assert.match(fehlend.k15.text, /Deine Zustimmungen/) assert.ok(fehlend.t16.text.includes('Unterlagen von Hand nachsenden')) } finally { log2.mock.restore() } }) test('k15_nachweis_nicht_lesbar: ohne lesbaren Nachweis ist die vereinbarte Fassung unbekannt: keine PDFs, keine Links, T16 anhang_fehlt, Log', async () => { // (a) Lesefehler beim Nachweis zaehler += 1 const projectId = `ptermine${zaehler}` const sessionId = `cs_ptermine${zaehler}` aw.seed('websiteProjects', { $id: projectId, customerId: 'c1', projectName: `Salon ${zaehler}` }) await sf.markOrdered({ customerId: 'c1', projectId, key: 'termine', sessionId, consent: {} }) await consents.speichereZustimmung({ customerId: 'c1', projectId, featureKey: 'termine', stripeSessionId: sessionId, fassungen: consents.zustimmungsfassungen(T), texte: consents.nachweisTexte(T, { belehrung: widerrufsbelehrungFunktion() }), at: KLICK, }) const log = stumm() try { aw.failNext({ collection: 'featureConsents', method: 'GET', status: 500, message: 'GEHEIMER-ROHTEXT' }) const vor = mails.sent.length await sf.handleFeatureCheckoutEvent(paidEvent(sessionId, projectId, 'termine')) const neu = mails.sent.slice(vor) const k15 = neu.find((m) => m.subject.startsWith('Deine Bestellung')) const t16 = neu.find((m) => m.subject.startsWith('Neue Funktion bestellt')) assert.ok(k15, 'die Bestätigung geht trotzdem raus') assert.ok(!k15.attachments || k15.attachments.length === 0, 'nie die aktuelle Fassung ungeprüft anhängen') assert.doesNotMatch(k15.text, /Deine Vertragsunterlagen|Deine Zustimmungen|\/rechtliches\//) assert.ok(t16.text.includes('Unterlagen von Hand nachsenden')) assert.equal((await sf.getFeatureDoc('c1', projectId, 'termine')).state, 'setup') assert.ok(log.mock.calls.some((c) => String(c.arguments[0]).includes('die vereinbarte Fassung ist unbekannt')), 'das Log nennt den Grund') } finally { log.mock.restore() } }) test('k15_nachweis_ohne_fassung: ein Nachweis ohne lesbare Fassung zählt wie ein unlesbarer', async () => { zaehler += 1 const projectId = `ptermine${zaehler}` const sessionId = `cs_ptermine${zaehler}` aw.seed('websiteProjects', { $id: projectId, customerId: 'c1', projectName: `Salon ${zaehler}` }) await sf.markOrdered({ customerId: 'c1', projectId, key: 'termine', sessionId, consent: {} }) aw.seed('featureConsents', { $id: `fc_kaputt_${zaehler}`, customerId: 'c1', projectId, featureKey: 'termine', stripeSessionId: sessionId, acceptedAt: KLICK, fassungen: 'kein json', texte: JSON.stringify(consents.nachweisTexte(T, { belehrung: widerrufsbelehrungFunktion() })), textHash: 'x', }) const log = stumm() try { const vor = mails.sent.length await sf.handleFeatureCheckoutEvent(paidEvent(sessionId, projectId, 'termine')) const neu = mails.sent.slice(vor) const k15 = neu.find((m) => m.subject.startsWith('Deine Bestellung')) assert.ok(!k15.attachments || k15.attachments.length === 0) assert.doesNotMatch(k15.text, /Deine Vertragsunterlagen|\/rechtliches\//) assert.match(k15.text, /Deine Zustimmungen/, 'der Wortlaut der Zustimmungen steht trotzdem in der Mail') assert.ok(neu.find((m) => m.subject.startsWith('Neue Funktion bestellt')).text.includes('Unterlagen von Hand nachsenden')) assert.ok(log.mock.calls.some((c) => String(c.arguments[0]).includes('Fassung im Zustimmungsnachweis nicht bestimmbar'))) } finally { log.mock.restore() } }) test('k15_frisches_array: der Mailer bekommt für attachments immer ein eigenes, nicht eingefrorenes Array', async () => { const roh = [] setMailTransportForTests({ async sendMail(mail) { roh.push(mail) return mails.sendMail(mail) }, }) try { aw.seed('websiteProjects', { $id: 'pnews12', customerId: 'c1', projectName: 'Frisch' }) await sf.markOrdered({ customerId: 'c1', projectId: 'pnews12', key: 'news', sessionId: 'cs_news12', consent: {} }) await sf.handleFeatureCheckoutEvent(paidEvent('cs_news12', 'pnews12')) await bezahleTermine() } finally { setMailTransportForTests(mails) } const k15 = roh.filter((m) => String(m.subject).startsWith('Deine Bestellung')) assert.equal(k15.length, 2) for (const mail of k15) { assert.ok(Array.isArray(mail.attachments)) assert.equal(Object.isFrozen(mail.attachments), false) } assert.notEqual(k15[0].attachments, k15[1].attachments) k15[0].attachments.push('x') // ein Mailer darf das Array verändern, ohne das gemeinsame Muster zu treffen const nochmal = await bezahleTermine() assert.equal(nochmal.k15.attachments.length, 3) }) test('checkout_503_ohne_unterlagen: fehlen die PDFs der aktuellen Fassung, 503 unterlagen_fehlen vor jeder Stripe-Sitzung, ohne ordered-Dokument', async () => { aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc })) const modus = { mode: config.termine.mode, pilotEmails: config.termine.pilotEmails } config.termine.mode = 'pilot' config.termine.pilotEmails = ['*'] T.bestellpruefung = async () => [] const body = () => ({ projectId: 'p1', zustimmung: { version: T.consent.version, haken: T.consent.items.map((i) => i.id) } }) const sitzungen = () => st.checkoutSessions.size const termineDokumente = () => [...aw.docs('portalFeatures').values()].filter((d) => d.featureKey === 'termine') const nachweise = () => [...aw.docs('featureConsents').values()].filter((d) => d.featureKey === 'termine') try { const vorher = { sitzungen: sitzungen(), docs: termineDokumente().length, nachweise: nachweise().length } // (a) neue Fassung ohne Dateien const alt = config.termine.avvVersion config.termine.avvVersion = '2099-01-01' try { const res = await post('/api/funktionen/termine/checkout', body(), { cookie }) assert.equal(res.status, 503, JSON.stringify(res.body)) assert.equal(res.body.code, 'unterlagen_fehlen') assert.equal(res.body.error, 'Die Vertragsunterlagen sind gerade nicht abrufbar. Bitte versuch es später noch einmal oder schreib uns im Chat.') assert.equal(res.body.url, undefined) } finally { config.termine.avvVersion = alt } // (b) Verzeichnis ohne Dateien const leer = mkdtempSync(join(tmpdir(), 'checkout-leer-')) try { setLegalDirForTests(leer) const res = await post('/api/funktionen/termine/checkout', body(), { cookie }) assert.equal(res.status, 503) assert.equal(res.body.code, 'unterlagen_fehlen') } finally { setLegalDirForTests(null) rmSync(leer, { recursive: true, force: true }) } assert.deepEqual({ sitzungen: sitzungen(), docs: termineDokumente().length, nachweise: nachweise().length }, vorher, 'keine Stripe-Sitzung, kein Dokument, kein Nachweis') assert.equal(termineDokumente().filter((d) => d.state === 'ordered').length, 0) // (c) mit den ausgelieferten Dateien geht dieselbe Bestellung durch const ok = await post('/api/funktionen/termine/checkout', body(), { cookie }) assert.equal(ok.status, 200, JSON.stringify(ok.body)) assert.equal(sitzungen(), vorher.sitzungen + 1) assert.equal(termineDokumente().filter((d) => d.state === 'ordered').length, 1) } finally { delete T.bestellpruefung config.termine.mode = modus.mode config.termine.pilotEmails = modus.pilotEmails } }) test('bestellseite_statisch: Belehrung als Text im nicht einklappbaren Bereich, Links mit target="_blank" und rel="noopener noreferrer"', () => { const quelle = lesen('public/funktion-bestellen.js') const code = quelle.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1') assert.doesNotMatch(code, /innerHTML|outerHTML|insertAdjacentHTML|document\.write|createContextualFragment/, 'kein Markup aus Text') assert.doesNotMatch(code, /