import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { test, after } 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("'/analyse-view.js'", `'${fileUrl('analyse-view.js')}'`) .replace("'/geraete.js'", `'${fileUrl('geraete.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) => ({ id: `evt_${sessionId}`, object: 'event', type: 'checkout.session.completed', data: { object: { id: sessionId, object: 'checkout.session', mode: 'payment', payment_status: 'paid', metadata: { customerId: 'c1', projectId, featureKey: 'news' } } }, }) 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) })