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

449 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readFileSync, writeFileSync, mkdtempSync, 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 = <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, 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 <k@example.invalid>',
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, aber T16 sagt es dem Team
aw.seed('customers', { $id: 'c9', name: 'Ohne Adresse', email: '' })
const ohneAdresse = await bezahleTermine({ customerId: 'c9' })
assert.equal(ohneAdresse.k15Anzahl, 0)
assert.ok(ohneAdresse.t16.text.includes('Unterlagen von Hand nachsenden'))
})
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, /<details|<summary|'details'|"details"|'summary'/, 'kein Aufklappen')
// die Belehrung kommt als Kind des Textbereichs (h hängt Zeichenketten als Text an), nie als Markup
assert.match(code, /h\('div', \{ class: 'tm-rechtstext', tabindex: '0', role: 'region', 'aria-label': 'Widerrufsbelehrung mit Muster-Widerrufsformular' \}, bestellung\.belehrung\)/)
assert.match(lesen('public/termine-dom.js'), /el\.append\(kind\)/, 'h hängt Kinder mit append an (Text bleibt Text)')
// Links der Unterlagen
assert.match(code, /h\('a', \{ href: sicherLink\(dokument\.url\), target: '_blank', rel: 'noopener noreferrer' \}, dokument\.titel\)/)
for (const treffer of code.matchAll(/target: '_blank'/g)) {
assert.ok(code.slice(treffer.index, treffer.index + 80).includes("rel: 'noopener noreferrer'"), 'jeder Link mit target _blank trägt rel')
}
})