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

229 lines
13 KiB
JavaScript

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 = <F>', () => {
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 <F> (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('<td>30,00 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG), keine laufenden Kosten</td>'))
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('<!-- ENTWURF, Freigabe Nutzer ausstehend -->'), 'die Freigabe bleibt Sache des Inhabers')
// der Stand der LB ist der Tag der Fassung <F>
const [j, m, t] = config.termine.avvVersion.split('-')
assert.ok(lb.includes(`<p class="meta">Stand ${t}.${m}.${j}</p>`), '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, /<strong>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 id="news-ust-hinweis"><\/span>/)
// ein Hinweis mit Markup bleibt Text
const bose = await renderBuchung({ projects: [frei], ustHinweis: '<img src=x onerror=1>' })
assert.doesNotMatch(bose.innerHTML, /<img/)
assert.equal(bose.els['#news-ust-hinweis'].textContent, '<img src=x onerror=1>')
// 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)
})