Funktionen: Vertragsunterlagen als K15-Anhang, Belehrung vor dem Bestellknopf
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
03bdeb4461
commit
2c059cb3d6
11 files changed
+574
-39
No files matched your search
@@ -1,8 +1,8 @@
|
||||
import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs'
|
||||
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 } from 'node:test'
|
||||
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'
|
||||
@@ -161,11 +161,11 @@ test('bestellseite_hinweis (Client): Preis mit Hinweis aus ustHinweis, per textC
|
||||
})
|
||||
|
||||
// ---- K15 (Bestellbestätigung) der News
|
||||
const paidEvent = (sessionId, projectId) => ({
|
||||
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: 'c1', projectId, featureKey: 'news' } } },
|
||||
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 () => {
|
||||
@@ -226,3 +226,223 @@ test('alte_bestellung_unveraendert: altes Dokument bleibt, neue Bestellung speic
|
||||
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')
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user