Bestelltexte des Terminplaners (vier Zustimmungen, Belehrung im Nachweis, Fassung)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-05 03:10:29 +00:00
1 parent 49acd13f84
commit 03bdeb4461
7 files changed
+408 -40

No files matched your search

+308
View File
@@ -0,0 +1,308 @@
import { test, after, beforeEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
// Bestelltexte des Terminplaners (Task F7): die vier Zustimmungen (Haken), der Knopf, die Fassung, die Widerrufsbelehrung im
// Register und der Zustimmungsnachweis beim Checkout (Review Focus 5: genau die gezeigten Texte, Fassungen und der Zeitpunkt
// sind gespeichert, bevor die URL an den Browser geht).
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
subscriptions: schemaKeys('subscriptions'),
featureConsents: schemaKeys('featureConsents'),
termineConfig: schemaKeys('termineConfig'),
termineBookings: schemaKeys('termineBookings'),
termineClaims: schemaKeys('termineClaims'),
termineOutbox: schemaKeys('termineOutbox'),
contractEvents: schemaKeys('contractEvents'),
customerViewLog: schemaKeys('customerViewLog'),
},
datetimes: TERMINE_DATETIMES,
defaultLimit: 25,
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, post, send } = await import('./support/testApp.js')
const { customerCookie, seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js')
const { default: funktionenRoutes } = await import('../server/routes/funktionen.js')
const { default: termineRoutes } = await import('../server/routes/termine.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { featureConfig, featureHaken, oeffentlicheAnsicht } = await import('../server/services/featureRegistry.js')
const { nachweisTexte, zustimmungsfassungen } = await import('../server/services/featureConsents.js')
const { widerrufsbelehrungFunktion } = await import('../server/services/termine/rechtstexte.js')
const { legalPaths } = await import('../server/services/legalDocs.js')
const app = await startTestApp({ '/api/funktionen': funktionenRoutes, '/api/termine': termineRoutes })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
const T = config.features.termine
const NEWS = config.features.news
const V = config.termine.avvVersion
const STANDARD = { mode: config.termine.mode, pilotEmails: [...config.termine.pilotEmails] }
const sha256 = (text) => createHash('sha256').update(text).digest('hex')
beforeEach(async () => {
for (const col of ['subscriptions', 'portalFeatures', 'featureConsents', 'termineConfig', 'contractEvents']) {
for (const id of aw.docs(col).keys()) await deleteDocument(col, id)
}
seedFixtures(aw)
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Salon' })
aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc }))
st.checkoutSessions.clear()
config.termine.mode = 'pilot'
config.termine.pilotEmails = ['*']
delete T.bestellpruefung
})
after(() => {
config.termine.mode = STANDARD.mode
config.termine.pilotEmails = STANDARD.pilotEmails
})
// ------------------------------------------------------------------------------------------------ die Texte
test('termine_haken_woertlich: vier Haken in fester Reihenfolge mit dem Wortlaut aus dem Brief', () => {
assert.deepEqual(T.consent.items.map((i) => i.id), ['unterlagen', 'gesundheit', 'beginn', 'information'])
assert.deepEqual(T.consent.items.map((i) => i.haken), [true, true, true, true])
assert.deepEqual(T.consent.items.map((i) => i.text), [
`Ich stimme der Leistungsbeschreibung Terminplaner und dem Auftragsverarbeitungsvertrag Terminplaner in der Fassung ${V} zu.`,
'Mein Angebot liegt nicht im Gesundheitsbereich (zum Beispiel keine Arzt-, Zahnarzt-, Psychotherapie-, Heilpraktiker- oder Physiotherapiepraxis).',
'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Einrichtung des Terminplaners beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Einrichtung 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).',
'Ich habe die Pflichtinformationen zu dieser Bestellung gelesen: Leistung (Einrichtung des Terminplaners für diese Website: ein Widget an einer Stelle, gestaltet nach dem Fragebogen, mit Einbau und Datenschutz-Absatz), Gesamtpreis 149 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG), voraussichtliche Dauer meist innerhalb von 1–2 Wochen, der Terminplaner endet mit dem Wartungs-Abo. Die Widerrufsbelehrung mit Muster-Widerrufsformular habe ich gelesen.',
])
assert.equal(T.consent.knopf, 'Zahlungspflichtig bestellen · 149 €')
for (const item of T.consent.items) assert.deepEqual(Object.keys(item).sort(), ['haken', 'id', 'text'], 'ohne url')
// die Bestellseite bekommt dieselben Texte
const ansicht = oeffentlicheAnsicht('termine').consent
assert.deepEqual(ansicht.items, T.consent.items.map((i) => ({ id: i.id, text: i.text, haken: true })))
assert.equal(ansicht.knopf, T.consent.knopf)
// das Zitat für das Erlöschen des Widerrufsrechts bei der Einrichtung (Dienstleistung): § 356 Abs. 5 Nr. 2 BGB, nicht Abs. 4
assert.match(T.consent.items[2].text, /\(§ 356 Abs\. 5 Nr\. 2 BGB\)/)
assert.doesNotMatch(T.consent.items[2].text, /Abs\. 4/)
})
test('termine_version_gleich_dokumente: Fassung des Registers = Fassung aller Dokumente und im Haken 1', () => {
assert.equal(T.consent.version, config.termine.avvVersion)
assert.ok(T.consent.items[0].text.includes(`Fassung ${T.consent.version} zu.`))
const adressen = legalPaths('termine', V)
assert.deepEqual(T.leistungsbeschreibung, { version: V, url: adressen.lb })
assert.deepEqual(T.avv, { version: V, url: adressen.avv })
assert.deepEqual(T.widerruf, { version: V, url: adressen.widerruf })
assert.equal(T.widerruf.url, `/rechtliches/widerrufsbelehrung-funktionen-${V}.html`)
assert.deepEqual(zustimmungsfassungen(T), { zustimmung: V, leistungsbeschreibung: V, avv: V, widerruf: V })
// News behält ihre Leistungsbeschreibung, hat aber keine Widerrufsbelehrung der Funktionen
assert.equal(NEWS.widerruf, null)
assert.equal(NEWS.consent.version, V)
// folgt die Konstante, folgen Fassung und Text (die Haken werden je Fassung gebaut)
config.termine.avvVersion = '2099-01-01'
try {
assert.equal(T.consent.version, '2099-01-01')
assert.ok(T.consent.items[0].text.includes('in der Fassung 2099-01-01 zu.'))
assert.equal(T.widerruf.version, '2099-01-01')
assert.equal(T.widerruf.url, '/rechtliches/widerrufsbelehrung-funktionen-2099-01-01.html')
} finally {
config.termine.avvVersion = V
}
assert.ok(T.consent.items[0].text.includes(`in der Fassung ${V} zu.`))
// Tests dürfen einzelne Texte ändern und zurückstellen: dasselbe Objekt, solange die Fassung gleich bleibt
const text = T.consent.items[1].text
T.consent.items[1].text = 'anders'
assert.equal(T.consent.items[1].text, 'anders')
T.consent.items[1].text = text
})
test('information_nennt_register_werte: Preis, Umsatzsteuer-Hinweis und Dauer kommen aus dem Register', () => {
const info = T.consent.items.find((i) => i.id === 'information').text
assert.ok(info.includes(`Gesamtpreis ${T.priceLabel} einmalig (${config.ustHinweis})`))
assert.ok(info.includes('149 €'))
assert.ok(info.includes('ohne Umsatzsteuer gemäß § 19 UStG'))
assert.ok(info.includes(`voraussichtliche Dauer ${T.setupEstimate}`))
assert.ok(info.includes('der Terminplaner endet mit dem Wartungs-Abo'))
assert.ok(info.endsWith('Die Widerrufsbelehrung mit Muster-Widerrufsformular habe ich gelesen.'))
// Kleinunternehmer: nie "inkl. MwSt." oder "brutto"
for (const text of [...T.consent.items.map((i) => i.text), T.consent.knopf]) assert.doesNotMatch(text, /brutto|inkl\.|inklusive|Mehrwertsteuer|MwSt|zzgl/i)
assert.equal(T.consent.knopf, `Zahlungspflichtig bestellen · ${T.priceLabel}`)
})
test('keine_urls_im_text: kein Link, keine Adresse und kein Markup in den Haken und im Knopf', () => {
for (const text of [...T.consent.items.map((i) => i.text), T.consent.knopf, ...NEWS.consent.items.map((i) => i.text), NEWS.consent.knopf]) {
assert.doesNotMatch(text, /https?:\/\/|www\.|\/rechtliches|\.html|\.pdf|<|>|ENTWURF|Entwurf|Prüfpunkt|⚖/, text.slice(0, 40))
}
})
/**
* Pin auf die Bestelltexte beider Funktionen (Haken und Knopf in Registerreihenfolge, durch eine Leerzeile verbunden).
* Bei Textänderung neues Fassungsdatum (config.termine.avvVersion) UND neuen Hash: eine Textänderung ohne Fassungswechsel
* fällt hier auf, denn gespeicherte Nachweise tragen Text und Fassung.
*/
const TEXTSTAND = { fassung: '2026-10-05', sha256: 'f7c3adf183030d817e2a23f26e8e01dbcd90e35a375b58e2c3b3a33b6bf99b6c' }
test('textstand_pin: Hash über die verbundenen Bestelltexte beider Funktionen steht im Test', () => {
const texte = [...NEWS.consent.items.map((i) => i.text), NEWS.consent.knopf, ...T.consent.items.map((i) => i.text), T.consent.knopf].join('\n\n')
assert.equal(config.termine.avvVersion, TEXTSTAND.fassung, 'neue Fassung: Pin und Hash hier, in featureRegistry.test.js und in den Rechtstext-Tests nachziehen')
assert.equal(sha256(texte), TEXTSTAND.sha256, 'Bestelltexte geändert: neues Fassungsdatum und neuen Hash eintragen')
})
// ------------------------------------------------------------------------------------------------ Belehrung im Haken
test('belehrung_haken: der Standard-Haken liefert die Widerrufsbelehrung der Funktionen, News hat keinen', async () => {
const belehrung = await featureHaken('termine').belehrung()
assert.equal(belehrung, widerrufsbelehrungFunktion())
assert.doesNotMatch(belehrung, /ENTWURF|Block F/)
assert.match(belehrung, /Muster-Widerrufsformular/)
assert.match(belehrung, /An WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler/)
assert.equal(featureHaken('news').belehrung, undefined)
// der Nachweis führt Belehrung und Knopf hinter den Haken
const texte = nachweisTexte(T, { belehrung })
assert.deepEqual(texte.map((t) => t.id), ['unterlagen', 'gesundheit', 'beginn', 'information', 'belehrung', 'knopf'])
assert.deepEqual(texte.slice(-2), [
{ id: 'belehrung', text: belehrung, haken: false },
{ id: 'knopf', text: T.consent.knopf, haken: false },
])
})
// ------------------------------------------------------------------------------------------------ Checkout und Nachweis
const c1 = customerCookie('c1')
const checkout = (body = {}) =>
post('/api/funktionen/termine/checkout', { projectId: 'p1', zustimmung: { version: T.consent.version, haken: T.consent.items.map((i) => i.id) }, ...body }, { cookie: c1 })
const bereit = () => {
T.bestellpruefung = async () => []
}
const nachweise = () => [...aw.docs('featureConsents').values()]
const sitzungen = () => [...st.checkoutSessions.values()]
const termineDoc = () => [...aw.docs('portalFeatures').values()].find((d) => d.featureKey === 'termine')
test('nachweis_vor_umleitung: Stripe-Sitzung, dann Nachweis mit Texten, Fassungen und Klickzeit, dann ordered, dann erst die URL', async () => {
bereit()
// Reihenfolge der Schreibzugriffe auf featureConsents und portalFeatures und der Antwort an den Browser
const ereignisse = []
const echt = globalThis.fetch
globalThis.fetch = async (url, init) => {
const adresse = String(url?.url ?? url)
const methode = init?.method ?? 'GET'
const antwort = await echt(url, init)
const sammlung = /\/collections\/(featureConsents|portalFeatures)\/documents/.exec(adresse)?.[1]
if (sammlung && methode !== 'GET') ereignisse.push(`${methode} ${sammlung}`)
else if (adresse === `${app.url}/api/funktionen/termine/checkout`) ereignisse.push('antwort')
return antwort
}
const vor = new Date().toISOString()
let res
try {
res = await checkout()
} finally {
globalThis.fetch = echt
}
const nach = new Date().toISOString()
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(Object.keys(res.body), ['url'])
// genau eine Sitzung (zuerst angelegt, ihre ID steht im Nachweis) und genau ein Nachweis
assert.equal(sitzungen().length, 1)
const [session] = sitzungen()
assert.equal(res.body.url, session.url)
assert.equal(nachweise().length, 1)
const [n] = nachweise()
assert.equal(n.stripeSessionId, session.id, 'der Nachweis trägt die ID der Stripe-Sitzung')
// erst der Nachweis, dann jede Änderung am Funktionsdokument (ordered), dann die Antwort mit der URL
const nachweisPosition = ereignisse.indexOf('POST featureConsents')
assert.ok(nachweisPosition >= 0, `Nachweis nicht geschrieben: ${ereignisse}`)
assert.equal(ereignisse.filter((e) => e === 'POST featureConsents').length, 1)
assert.equal(ereignisse.at(-1), 'antwort', 'die Antwort kommt zuletzt')
const dokumentSchreiben = ereignisse.map((e, i) => (e.endsWith('portalFeatures') ? i : -1)).filter((i) => i >= 0)
assert.ok(dokumentSchreiben.length >= 1)
for (const i of dokumentSchreiben) assert.ok(i > nachweisPosition, `portalFeatures wird vor dem Nachweis geschrieben: ${ereignisse}`)
assert.ok(nachweisPosition < ereignisse.indexOf('antwort'))
// gespeichert: genau die Registertexte (Haken), dann Belehrung, dann Knopf; die Fassungen mit widerruf; Hash; Klickzeit
assert.deepEqual(JSON.parse(n.texte), [...T.consent.items.map((i) => ({ id: i.id, text: i.text, haken: true })), { id: 'belehrung', text: widerrufsbelehrungFunktion(), haken: false }, { id: 'knopf', text: T.consent.knopf, haken: false }])
assert.deepEqual(JSON.parse(n.fassungen), { zustimmung: V, leistungsbeschreibung: V, avv: V, widerruf: V })
assert.equal(n.textHash, sha256(n.texte))
assert.equal(n.acceptedAt, session.metadata.consentAt, 'acceptedAt = Klickzeit = consentAt der Stripe-Sitzung')
assert.ok(vor <= n.acceptedAt && n.acceptedAt <= nach, `${vor} <= ${n.acceptedAt} <= ${nach}`)
assert.match(n.acceptedAt, /Z$/)
const doc = termineDoc()
assert.equal(doc.state, 'ordered')
assert.equal(doc.consentAt, n.acceptedAt, 'das Dokument trägt dieselbe Klickzeit (Anker der Widerrufsfrist)')
})
test('nachweis_vor_umleitung: schlägt das Speichern fehl, gibt es 500 ohne url und kein ordered; die Sitzung läuft nach 24 h ab', async () => {
bereit()
const log = mock.method(console, 'error', () => {})
try {
aw.failNext({ collection: 'featureConsents', method: 'POST', status: 500, message: 'GEHEIMER-ROHTEXT' })
const res = await checkout()
assert.equal(res.status, 500)
assert.equal(res.body.url, undefined)
assert.doesNotMatch(JSON.stringify(res.body), /GEHEIMER-ROHTEXT|https?:/)
assert.equal(nachweise().length, 0)
const doc = termineDoc()
assert.ok(!doc || doc.state === 'off', 'nicht ordered')
assert.equal(sitzungen().length, 1, 'die Stripe-Sitzung entstand zuerst, ihre URL ging nie an den Browser')
assert.equal(sitzungen()[0].status, 'open')
} finally {
log.mock.restore()
}
// die verwaiste Sitzung läuft nach 24 h ab (Stripe: expires_at beim Anlegen)
const quelle = readFileSync(new URL('../server/services/stripeBilling.js', import.meta.url), 'utf8')
assert.match(quelle, /expires_at: Math\.floor\(Date\.now\(\) \/ 1000\) \+ 24 \* 60 \* 60 - 5 \* 60/)
})
test('belehrung_gezeigt_gleich_gespeichert: gespeicherter Text = bestellung.belehrung der Bestellseite = widerrufsbelehrungFunktion()', async () => {
// gezeigt: GET /api/termine/einstellungen (Bestellseite liest bestellung.belehrung)
const einstellungen = await send('GET', '/api/termine/einstellungen?projectId=p1', { cookie: c1 })
assert.equal(einstellungen.status, 200, JSON.stringify(einstellungen.body))
const gezeigt = einstellungen.body.bestellung.belehrung
assert.equal(typeof gezeigt, 'string')
assert.equal(gezeigt, widerrufsbelehrungFunktion())
assert.deepEqual(einstellungen.body.bestellung.dokumente.map((d) => [d.id, d.version, d.url]), [
['leistungsbeschreibung', V, legalPaths('termine', V).lb],
['avv', V, legalPaths('termine', V).avv],
['widerruf', V, legalPaths('termine', V).widerruf],
])
// gespeichert: der Nachweis des Checkouts
bereit()
assert.equal((await checkout()).status, 200)
const gespeichert = JSON.parse(nachweise()[0].texte).find((t) => t.id === 'belehrung')
assert.equal(gespeichert.text, gezeigt, 'gezeigter und gespeicherter Text sind dieselbe Zeichenkette')
})
test('knopf_gleich_register_und_nachweis: bestellung.consent.knopf = Registertext = gespeicherter knopf-Eintrag', async () => {
const einstellungen = await send('GET', '/api/termine/einstellungen?projectId=p1', { cookie: c1 })
const knopf = einstellungen.body.bestellung.consent.knopf
assert.equal(knopf, T.consent.knopf)
assert.equal(knopf, featureConfig('termine').consent.knopf)
assert.equal(knopf, 'Zahlungspflichtig bestellen · 149 €')
bereit()
assert.equal((await checkout()).status, 200)
const gespeichert = JSON.parse(nachweise()[0].texte).find((t) => t.id === 'knopf')
assert.deepEqual(gespeichert, { id: 'knopf', text: knopf, haken: false })
// die Haken der Bestellseite sind die Haken des Nachweises
assert.deepEqual(einstellungen.body.bestellung.consent.items.map((i) => i.text), JSON.parse(nachweise()[0].texte).filter((t) => t.haken).map((t) => t.text))
})
test('alte_fassung_409: ein Body mit alter Fassung wird mit der neuen Fassung und allen Texten abgewiesen, ohne Spur', async () => {
bereit()
const res = await checkout({ zustimmung: { version: '2026-10-02', haken: T.consent.items.map((i) => i.id) } })
assert.equal(res.status, 409)
assert.equal(res.body.code, 'fassung_veraltet')
assert.equal(res.body.consent.version, V)
assert.deepEqual(res.body.consent.items.map((i) => i.id), ['unterlagen', 'gesundheit', 'beginn', 'information'])
assert.equal(res.body.consent.items[0].text, T.consent.items[0].text)
assert.equal(res.body.consent.knopf, T.consent.knopf)
assert.equal(sitzungen().length, 0)
assert.equal(nachweise().length, 0)
assert.equal(termineDoc(), undefined)
// fehlt ein Haken, ist es 400 (die Fassung ist die aktuelle)
const zuWenig = await checkout({ zustimmung: { version: V, haken: ['unterlagen', 'gesundheit', 'beginn'] } })
assert.equal(zuWenig.status, 400)
assert.equal(zuWenig.body.code, 'zustimmung_fehlt')
})