diff --git a/server/config.js b/server/config.js index 5a7e695..eafd10a 100644 --- a/server/config.js +++ b/server/config.js @@ -148,21 +148,78 @@ const ticketProjektName = (project) => project?.projectName || project?.subdomai /** * Rechtsunterlage des Terminplaners mit Fassung und Adresse aus config.termine.avvVersion (bei jedem Zugriff gelesen). * Tests duerfen sie ueberschreiben (Zuweisung); wird der aktuelle Standardwert zurueckgeschrieben (Wiederherstellen nach - * dem Test) oder null/undefined zugewiesen, gilt wieder der Standard und die Unterlage folgt der Konstante. + * dem Test) oder undefined zugewiesen, gilt wieder der Standard und die Unterlage folgt der Konstante. null gilt ebenso, + * ausser bei nullIstWert (Widerrufsbelehrung): dort heisst null "keine Unterlage" (so setzen Tests eine Bestellung ohne sie). */ -function termineRechtsunterlage(slug) { - let ueberschrieben = null +function termineRechtsunterlage(slug, { nullIstWert = false } = {}) { + let ueberschrieben // undefined = Standard const standard = () => ({ version: config.termine.avvVersion, url: `/rechtliches/${slug}-${config.termine.avvVersion}.html` }) return { - get: () => ueberschrieben ?? standard(), + get: () => (ueberschrieben === undefined ? standard() : ueberschrieben), set(wert) { const std = standard() - ueberschrieben = wert == null || (wert.version === std.version && wert.url === std.url) ? null : wert + const zurueck = wert === undefined || (wert === null && !nullIstWert) || (wert !== null && wert.version === std.version && wert.url === std.url) + ueberschrieben = zurueck ? undefined : wert }, } } const termineLb = termineRechtsunterlage('leistungsbeschreibung-terminplaner') const termineAvv = termineRechtsunterlage('avv-terminplaner') +const termineWiderruf = termineRechtsunterlage('widerrufsbelehrung-funktionen', { nullIstWert: true }) + +const TERMINE_PREIS = '149 €' +const TERMINE_DAUER = 'meist innerhalb von 1–2 Wochen' + +/** + * Bestelltexte des Terminplaners (Task F7): vier Haken, alle Besteller (D1), ohne Adressen im Text (die versionierten Links + * zeigt die Bestellseite daneben aus bestellung.dokumente). Die Fassung (consent.version) ist config.termine.avvVersion; die + * dynamischen Teile (Fassung, Preis, Umsatzsteuer-Hinweis, Dauer) stehen fertig im Text, gespeichert wird immer dieser Text. + * Die Haken werden je Fassung einmal gebaut und danach unveraendert herausgegeben (Tests aendern einzelne Texte und stellen sie + * zurueck); aendert sich die Fassung zur Laufzeit, entstehen sie neu. Bei Textaenderung neues Fassungsdatum und neuen Hash im + * Pin von test/featureConsentTexte.test.js. Zitat und Wertersatz-Satz von "beginn" und die Pflichtinformationen sind Entwurf bis + * zur Kanzlei (Kanzlei-Paket, Fragen Q3, Q5, Q19, Q20). + */ +function termineConsent() { + let versionGesetzt // undefined = config.termine.avvVersion + let gebaut = null // { version, items } + const version = () => versionGesetzt ?? config.termine.avvVersion + const items = (v) => [ + { + id: 'unterlagen', + haken: true, + text: `Ich stimme der Leistungsbeschreibung Terminplaner und dem Auftragsverarbeitungsvertrag Terminplaner in der Fassung ${v} zu.`, + }, + { + id: 'gesundheit', + haken: true, + text: 'Mein Angebot liegt nicht im Gesundheitsbereich (zum Beispiel keine Arzt-, Zahnarzt-, Psychotherapie-, Heilpraktiker- oder Physiotherapiepraxis).', + }, + { + id: 'beginn', + haken: true, + text: '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).', + }, + { + id: 'information', + haken: true, + text: `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 ${TERMINE_PREIS} einmalig (${ustHinweis}), voraussichtliche Dauer ${TERMINE_DAUER}, der Terminplaner endet mit dem Wartungs-Abo. Die Widerrufsbelehrung mit Muster-Widerrufsformular habe ich gelesen.`, + }, + ] + return { + get version() { + return version() + }, + set version(wert) { + versionGesetzt = wert === undefined || wert === null || wert === config.termine.avvVersion ? undefined : wert + }, + knopf: `Zahlungspflichtig bestellen · ${TERMINE_PREIS}`, + get items() { + const v = version() + if (gebaut?.version !== v) gebaut = { version: v, items: items(v) } + return gebaut.items + }, + } +} /** * Register der kaufbaren Funktionen (News-Pinnwand, Terminplaner): Preis, Zustimmungen, Rechtsunterlagen, @@ -243,27 +300,9 @@ const features = { key: 'termine', product: 'termineSetup', label: { name: 'Terminplaner', kurz: 'Termine', keine: 'keinen Terminplaner' }, - priceLabel: '149 €', - setupEstimate: 'meist innerhalb von 1–2 Wochen', - // Platzhalter bis Block F (Rechtsrunde): Struktur und IDs stehen, Texte und Fassung ersetzt Block F - consent: { - version: 'ENTWURF-2026-10-03', - knopf: 'Zahlungspflichtig bestellen · 149 €', - items: [ - { id: 'unterlagen', haken: true, text: 'ENTWURF (Block F): Zustimmung zu Leistungsbeschreibung und Auftragsverarbeitungsvertrag (AVV) Terminplaner.' }, - { id: 'gesundheit', haken: true, text: 'ENTWURF (Block F): Bestätigung, dass der Terminplaner nicht im Gesundheitsbereich eingesetzt wird.' }, - { - id: 'beginn', - haken: true, - text: 'ENTWURF (Block F): Ausdrückliches Verlangen, dass vor Ablauf der Widerrufsfrist mit dem Einbau begonnen wird, mit Hinweis auf das Erlöschen des Widerrufsrechts und den Wertersatz.', - }, - { - id: 'information', - haken: true, - text: 'ENTWURF (Block F): Pflichtinformationen zu Leistung, Gesamtpreis, Ende mit dem Wartungs-Abo und Einrichtungsdauer.', - }, - ], - }, + priceLabel: TERMINE_PREIS, + setupEstimate: TERMINE_DAUER, + consent: termineConsent(), // Fassung und Adresse folgen config.termine.avvVersion bei jedem Zugriff (Slugs wie in services/legalDocs.js; config.js // importiert keine Dienste, test/legalDocs.test.js gleicht beide ab) get leistungsbeschreibung() { @@ -278,7 +317,12 @@ const features = { set avv(wert) { termineAvv.set(wert) }, - widerruf: null, + get widerruf() { + return termineWiderruf.get() + }, + set widerruf(wert) { + termineWiderruf.set(wert) + }, returnHash: 'termine', returnProjekt: true, ticket: { diff --git a/server/services/featureRegistry.js b/server/services/featureRegistry.js index b8dca68..e2569f3 100644 --- a/server/services/featureRegistry.js +++ b/server/services/featureRegistry.js @@ -39,10 +39,14 @@ async function termineFreischaltbar(doc) { } /** - * Platzhalter der Widerrufsbelehrung mit Muster-Widerrufsformular, die die Bestellseite vor dem Knopf zeigt und der - * Zustimmungsnachweis speichert. Block F (Task F7) ersetzt den Haken belehrung durch widerrufsbelehrungFunktion(). + * Widerrufsbelehrung mit Muster-Widerrufsformular der Funktionen (Gesetzesmuster, Sie-Form), die die Bestellseite vor dem Knopf + * zeigt und der Zustimmungsnachweis speichert: dieselbe Zeichenkette an beiden Stellen (Task F7, Text aus Task F3). Geladen + * wird sie erst beim Aufruf (kein Importzyklus). */ -export const BELEHRUNG_PLATZHALTER = 'ENTWURF (Block F): Widerrufsbelehrung mit Muster-Widerrufsformular.' +async function termineBelehrung() { + const { widerrufsbelehrungFunktion } = await import('./termine/rechtstexte.js') + return widerrufsbelehrungFunktion() +} /** * Standard-Haken je Funktion (Funktionen, nicht Daten). B6 fuellt verfuegbar, bestellpruefung und unterlagenBereit @@ -56,7 +60,7 @@ const STANDARD_HAKEN = { verfuegbar: (email) => termineFuerKunde(email), bestellpruefung: termineBestellpruefung, unterlagenBereit: async () => true, - belehrung: async () => BELEHRUNG_PLATZHALTER, + belehrung: termineBelehrung, freischaltbar: termineFreischaltbar, }, } diff --git a/test/featureConsentTexte.test.js b/test/featureConsentTexte.test.js new file mode 100644 index 0000000..f2bf91b --- /dev/null +++ b/test/featureConsentTexte.test.js @@ -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') +}) diff --git a/test/featureConsents.test.js b/test/featureConsents.test.js index 543b228..3ff599c 100644 --- a/test/featureConsents.test.js +++ b/test/featureConsents.test.js @@ -228,12 +228,17 @@ test('Fassungen aus dem Register: Zustimmung plus die vorhandenen Fassungsfelder const news = featureConfig('news') assert.deepEqual(zustimmungsfassungen(news), { zustimmung: news.consent.version, leistungsbeschreibung: news.leistungsbeschreibung.version }) const termine = featureConfig('termine') + // Terminplaner: Zustimmung, Leistungsbeschreibung, AVV und Widerrufsbelehrung tragen alle die Fassung des Registers (Task F7) assert.deepEqual(zustimmungsfassungen(termine), { zustimmung: termine.consent.version, leistungsbeschreibung: termine.leistungsbeschreibung.version, avv: termine.avv.version, + widerruf: termine.widerruf.version, }) - // widerruf kommt dazu, sobald das Register es führt + assert.equal(termine.widerruf.version, config.termine.avvVersion) + // ohne widerruf im Register fehlt der Eintrag (News führt keine Widerrufsbelehrung) + assert.deepEqual(Object.keys(zustimmungsfassungen({ ...termine, widerruf: null })), ['zustimmung', 'leistungsbeschreibung', 'avv']) + // ein anderer Stand der Belehrung steht im Nachweis const mitWiderruf = { ...termine, widerruf: { version: 'W1', url: '/x' } } assert.deepEqual(zustimmungsfassungen(mitWiderruf), { zustimmung: termine.consent.version, diff --git a/test/featureRegistry.test.js b/test/featureRegistry.test.js index dca871f..c38a72b 100644 --- a/test/featureRegistry.test.js +++ b/test/featureRegistry.test.js @@ -234,7 +234,9 @@ test('Termine: Preis und Struktur', async () => { assert.ok(item.text.length > 0) assert.equal(item.url, undefined, 'ohne url') } - assert.equal(t.widerruf, null) + // Task F7: die Widerrufsbelehrung der Funktionen trägt die Fassung des Registers und die versionierte Adresse + assert.deepEqual(t.widerruf, { version: config.termine.avvVersion, url: `/rechtliches/widerrufsbelehrung-funktionen-${config.termine.avvVersion}.html` }) + assert.equal(t.consent.version, config.termine.avvVersion) assert.equal(t.returnHash, 'termine') assert.equal(t.returnProjekt, true) assert.equal(t.requiresWartungsAbo, true) @@ -280,7 +282,7 @@ test('Termine-Ticketdetails werden erst beim Aufruf geladen', async () => { }) // ---------------------------------------------------------------------------------- Block F --- -test('Bestelltexte ohne ENTWURF', { todo: 'Block F ersetzt die Platzhaltertexte' }, async () => { +test('Bestelltexte ohne ENTWURF', async () => { const t = config.features.termine const frei = (wert, was) => assert.doesNotMatch(JSON.stringify(wert), /ENTWURF/, `${was} enthält ENTWURF`) frei(t.consent.version, 'consent.version') @@ -289,7 +291,7 @@ test('Bestelltexte ohne ENTWURF', { todo: 'Block F ersetzt die Platzhaltertexte' frei(t.avv, 'avv') frei(t.widerruf, 'widerruf') // mails.danach und mails.bereit schreibt der Inhaber in seiner Mailrunde - // ab B7 gibt es den Haken belehrung (Platzhaltertext bis F7) + // der Haken belehrung liefert seit F7 die Widerrufsbelehrung der Funktionen const belehrung = reg.featureHaken('termine').belehrung if (belehrung) frei(await belehrung(), 'belehrung') }) diff --git a/test/funktionenCheckout.test.js b/test/funktionenCheckout.test.js index 577b6a1..dfc1252 100644 --- a/test/funktionenCheckout.test.js +++ b/test/funktionenCheckout.test.js @@ -33,6 +33,7 @@ const reg = await import('../server/services/featureRegistry.js') const order = await import('../server/services/featureOrder.js') const consents = await import('../server/services/featureConsents.js') const sf = await import('../server/services/siteFeatures.js') +const { widerrufsbelehrungFunktion } = await import('../server/services/termine/rechtstexte.js') const app = await startTestApp({ '/api/funktionen': funktionenRoutes }) after(() => Promise.all([app.close(), aw.close(), st.close()])) @@ -661,15 +662,18 @@ test('Nachweis steht vor der Weiterleitung: Serverfassungen und -texte in Reihen test('Belehrung im Nachweis: Eintrag belehrung (haken false) direkt vor dem Knopf, News hat keinen', async () => { bereit() - assert.equal(reg.BELEHRUNG_PLATZHALTER, 'ENTWURF (Block F): Widerrufsbelehrung mit Muster-Widerrufsformular.') - assert.equal(await reg.featureHaken('termine').belehrung(), reg.BELEHRUNG_PLATZHALTER) + // Task F7: der Haken liefert die Widerrufsbelehrung der Funktionen samt Muster-Widerrufsformular (Task F3) + const belehrung = widerrufsbelehrungFunktion() + assert.equal(await reg.featureHaken('termine').belehrung(), belehrung) + assert.match(belehrung, /^Widerrufsbelehrung\n\nWiderrufsrecht\n\nSie haben das Recht/) assert.equal(reg.featureHaken('news').belehrung, undefined) await checkout('termine', bodyT()) const texte = JSON.parse(nachweise()[0].texte) assert.deepEqual(texte.slice(-2), [ - { id: 'belehrung', text: reg.BELEHRUNG_PLATZHALTER, haken: false }, + { id: 'belehrung', text: belehrung, haken: false }, { id: 'knopf', text: T.consent.knopf, haken: false }, ]) + assert.equal(texte.length, 4 + 2, 'vier Haken, Belehrung, Knopf') assert.deepEqual(texte.map((x) => x.id), [...T.consent.items.map((x) => x.id), 'belehrung', 'knopf']) const news = await checkout('news', { projectId: 'p3', consent: true }, customerCookie('c3')) diff --git a/test/termineFragebogen.test.js b/test/termineFragebogen.test.js index 5f2f82e..7032699 100644 --- a/test/termineFragebogen.test.js +++ b/test/termineFragebogen.test.js @@ -7,7 +7,8 @@ import { } from '../server/services/termine/fragebogen.js' import { MELDUNG_PFLICHT, sichtbareLeistungen } from '../server/services/termine/einstellungen.js' import { aboMeldung } from '../server/services/featureOrder.js' -import { oeffentlicheAnsicht, featureHaken, BELEHRUNG_PLATZHALTER } from '../server/services/featureRegistry.js' +import { oeffentlicheAnsicht, featureHaken } from '../server/services/featureRegistry.js' +import { widerrufsbelehrungFunktion } from '../server/services/termine/rechtstexte.js' import { config } from '../server/config.js' import { bestellbarerStand, entwurfDoc } from './support/fragebogenStand.js' @@ -724,7 +725,7 @@ test('bestellungAnsicht: Abbildung von oeffentlicheAnsicht – Preis, Hinweis, E test('bestellungAnsicht: belehrung ist das Ergebnis des Hakens (derselbe Text, den der Nachweis speichert)', async () => { const ansicht = await bestellungAnsicht(T) assert.equal(ansicht.belehrung, await featureHaken('termine').belehrung()) - assert.equal(ansicht.belehrung, BELEHRUNG_PLATZHALTER) + assert.equal(ansicht.belehrung, widerrufsbelehrungFunktion()) // ein Test-Haken im Register ueberschreibt den Standard T.belehrung = async () => 'Belehrung Test' try {