Funktionen: Vertragsunterlagen als K15-Anhang, Belehrung vor dem Bestellknopf

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-05 03:20:56 +00:00
1 parent 03bdeb4461
commit 2c059cb3d6
11 files changed
+574 -39

No files matched your search

+16 -6
View File
@@ -611,8 +611,13 @@ test('Platzhalter-/Blocklisten exakt: K15 und K16', () => {
'danach',
'portal_url',
'rechnung_url',
'fassung',
'lb_url',
'avv_url',
'widerruf_url',
'zustimmungen_text',
])
assert.deepEqual(k15.blocks, ['rechnung'])
assert.deepEqual(k15.blocks, ['rechnung', 'anlagen', 'anhang', 'zustimmungen'])
assert.equal(k15.subject, 'Deine Bestellung: {{funktion}}')
const k16 = loadTemplate('K16')
assert.deepEqual(k16.placeholders, ['kunde_name', 'projekt', 'funktion', 'funktion_dein', 'funktion_nom', 'bereit', 'knopf', 'portal_url'])
@@ -624,7 +629,7 @@ test('Platzhalter-/Blocklisten exakt: T16 und T19', () => {
const t16 = loadTemplate('T16')
assert.equal(t16.subject, 'Neue Funktion bestellt: {{funktion}}')
assert.deepEqual(t16.placeholders, ['kunde_name', 'email', 'projekt', 'funktion', 'woid', 'sitekey', 'sitzung', 'zustand', 'anleitung_url', 'gesundheit_text'])
assert.deepEqual(t16.blocks, ['zahlung_fehlgeschlagen', 'ungeklaert', 'kein_abo', 'bereits_aktiv', 'anleitung', 'gesundheit'])
assert.deepEqual(t16.blocks, ['zahlung_fehlgeschlagen', 'ungeklaert', 'kein_abo', 'bereits_aktiv', 'anleitung', 'gesundheit', 'anhang_fehlt'])
const t19 = loadTemplate('T19')
assert.equal(t19.file, 'T19 Erstattung faellig.html')
assert.equal(t19.subject, '[Abo] Erstattung fällig: {{funktion}}-Einrichtung')
@@ -643,8 +648,9 @@ test('Die Mail-Werte je Funktion decken genau die Platzhalter der Vorlagen ab',
assert.equal(k16.template, 'K16')
assert.deepEqual(Object.keys(k15.values).sort(), [...loadTemplate('K15').placeholders].sort(), `${key}: K15`)
assert.deepEqual(Object.keys(k16.values).sort(), [...loadTemplate('K16').placeholders].sort(), `${key}: K16`)
assert.deepEqual(k15.blocks, { rechnung: true })
assert.deepEqual(sf.bestellMail(feature, { ...ctx, invoiceUrl: '' }).blocks, { rechnung: false })
// ohne Rechtsunterlagen im Aufruf (F8: onFeaturePaid übergibt sie für den Terminplaner) bleiben anlagen, anhang und zustimmungen aus
assert.deepEqual(k15.blocks, { rechnung: true, anlagen: false, anhang: false, zustimmungen: false })
assert.deepEqual(sf.bestellMail(feature, { ...ctx, invoiceUrl: '' }).blocks, { rechnung: false, anlagen: false, anhang: false, zustimmungen: false })
for (const nachricht of [k15, k16]) {
const mail = renderMail(nachricht.template, nachricht.values, nachricht.blocks)
assert.deepEqual(mail.problems, [], `${key}: ${nachricht.template}`)
@@ -657,8 +663,12 @@ test('Die Mail-Werte je Funktion decken genau die Platzhalter der Vorlagen ab',
})
// ------------------------------------------------------------------------------------ Anhänge ---
test('K15 ohne Anhänge: die Termine-Bestellung ruft keinen Registerhaken', async () => {
// Die Vertragsunterlagen des Terminplaners hängen seit F8 an K15 (legalDocs.featureLegalAttachments, Tests in funktionenRecht.test.js).
// Diese Probe hält fest, dass das nicht über einen Registerhaken läuft: kein mails.anhaenge, und die Haken des Eintrags werden bei der
// Zahlung nicht gerufen.
test('K15 mit Anhängen aus legalDocs: die Termine-Bestellung ruft keinen Registerhaken, mails.anhaenge gibt es nicht', async () => {
const termine = config.features.termine
assert.equal(Object.hasOwn(termine.mails, 'anhaenge'), false)
const aufrufe = []
const alt = {}
const spion = (name) => (...args) => {
@@ -672,7 +682,7 @@ test('K15 ohne Anhänge: die Termine-Bestellung ruft keinen Registerhaken', asyn
termine.mails.anhaenge = spion('mails.anhaenge')
try {
const { mail } = await bezahle('termine', 'Anhang-Test')
assert.ok(!mail.attachments || mail.attachments.length === 0, 'K15 trägt keinen Anhang')
assert.equal(mail.attachments.length, 3, 'K15 trägt die drei PDFs der Vertragsunterlagen')
assert.deepEqual(aufrufe, [])
} finally {
for (const name of Object.keys(alt)) {
+224 -4
View File
@@ -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')
}
})
+27
View File
@@ -105,6 +105,33 @@ test('liste_zustaende', async () => {
assert.equal(body.consentText, body.features.news.consent.items[0].text)
})
// Vertragsunterlagen in der Liste (Task F8): Fassung, absolute versionierte Links und die Widerrufsbelehrung des Terminplaners
test('liste_legal: legal.termine mit Fassung, https-Links mit Fassung und der Belehrung der Funktionen; News null', async () => {
const { widerrufsbelehrungFunktion } = await import('../server/services/termine/rechtstexte.js')
const { legalPaths } = await import('../server/services/legalDocs.js')
const V = config.termine.avvVersion
const body = await get('/api/funktionen', { cookie })
assert.deepEqual(Object.keys(body.legal).sort(), ['news', 'termine'])
assert.equal(body.legal.news, null)
const termine = body.legal.termine
assert.deepEqual(Object.keys(termine).sort(), ['belehrung', 'links', 'version'])
assert.equal(termine.version, V)
assert.deepEqual(Object.keys(termine.links), ['lb', 'avv', 'widerruf'])
const adressen = legalPaths('termine', V)
for (const [doc, link] of Object.entries(termine.links)) {
assert.match(link, /^https:\/\//, `${doc}: https`)
assert.ok(link.endsWith(`-${V}.html`), `${doc}: versioniert`)
assert.equal(link, `${config.publicBaseUrl}${adressen[doc]}`)
}
assert.equal(termine.belehrung, widerrufsbelehrungFunktion())
assert.match(termine.belehrung, /Muster-Widerrufsformular/)
// dieselben Werte wie die Register-Ansicht der Bestellseite: Fassung der Widerrufsbelehrung und Adresse
assert.deepEqual(body.features.termine.widerruf, { version: V, url: adressen.widerruf })
assert.deepEqual(body.features.termine.avv, { version: V, url: adressen.avv })
assert.deepEqual(body.features.termine.leistungsbeschreibung, { version: V, url: adressen.lb })
assert.deepEqual(body.features.news.widerruf, null)
})
test('liste_register_ohne_freigabe: features.termine steht auch bei Schalter off und Pilot ohne Teilnahme', async () => {
const vorher = { mode: config.termine.mode, pilotEmails: config.termine.pilotEmails }
try {
+78 -3
View File
@@ -1,6 +1,8 @@
import { test, after } from 'node:test'
import { test, after, mock } from 'node:test'
import assert from 'node:assert/strict'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { closeSync, copyFileSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync, writeSync } from 'node:fs'
import fsp from 'node:fs/promises'
import { syncBuiltinESMExports } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Router } from 'express'
@@ -9,7 +11,7 @@ import { useTestEnv } from './support/testEnv.js'
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { startTestApp } = await import('./support/testApp.js')
const { legalPaths, legalPdfFiles, legalPdfAttachments, featureLegalAttachments, featureLegalReady, legalRouter } = await import(
const { legalPaths, legalPdfFiles, legalPdfAttachments, featureLegalAttachments, featureLegalReady, legalRouter, setLegalDirForTests } = await import(
'../server/services/legalDocs.js'
)
const analyse = await import('../server/services/analyseLegal.js')
@@ -125,6 +127,79 @@ test('featureLegalAttachments("termine") liest die Fassung aus config.termine.av
})
})
// R114 (Task F8): featureLegalReady läuft bei jeder Bestellung (Stufe 5b des Checkouts) und prüft je PDF nur stat (Größe > 0) und die
// ersten fünf Bytes "%PDF-", nie den ganzen Inhalt. Die Dateien liegen für den Test in einem Verzeichnis mit Kopien.
test('featureLegalReady("termine"): je PDF stat und "%PDF-", nie den ganzen Inhalt lesen; jeder Mangel und jeder Fehler ergibt false, nie einen Wurf', async () => {
const kopien = mkdtempSync(join(tmpdir(), 'legal-ready-'))
mkdirSync(join(kopien, 'rechtliches'))
// Quellen: die ausgelieferten Dateien (vor dem Tausch des Verzeichnisses ermittelt)
const echteHtml = legalPaths('termine', V)
const echtePdf = Object.fromEntries(Object.entries(legalPdfFiles('termine', V)).map(([doc, f]) => [doc, f.path]))
const bereit = () => {
for (const adresse of Object.values(echteHtml)) copyFileSync(root(`public${adresse}`), join(kopien, adresse))
for (const pfad of Object.values(echtePdf)) copyFileSync(pfad, join(kopien, 'rechtliches', pfad.split('/').at(-1)))
}
const pdfPfad = (doc) => join(kopien, 'rechtliches', echtePdf[doc].split('/').at(-1))
const htmlPfad = (doc) => join(kopien, echteHtml[doc])
const lesen = mock.method(fsp, 'readFile')
syncBuiltinESMExports()
try {
setLegalDirForTests(kopien)
bereit()
assert.equal(await featureLegalReady('termine'), true)
assert.equal(lesen.mock.callCount(), 0, 'readFile wird nicht aufgerufen')
// ein riesiges PDF mit gültigem Kopf (nur Loch im Dateisystem): bereit, ohne es zu lesen
const gross = openSync(pdfPfad('lb'), 'w')
writeSync(gross, '%PDF-1.4\n')
closeSync(gross)
await fsp.truncate(pdfPfad('lb'), 512 * 1024 * 1024)
assert.equal(await featureLegalReady('termine'), true)
assert.equal(lesen.mock.callCount(), 0, 'auch ein 512-MB-PDF wird nicht gelesen')
bereit()
// Mängel je Datei: fehlend, leer, falscher Kopf, zu kurz, Verzeichnis statt Datei
for (const doc of ['lb', 'avv', 'widerruf']) {
const pfad = pdfPfad(doc)
rmSync(pfad)
assert.equal(await featureLegalReady('termine'), false, `${doc}: PDF fehlt`)
writeFileSync(pfad, '')
assert.equal(await featureLegalReady('termine'), false, `${doc}: PDF leer`)
writeFileSync(pfad, '<html>kein PDF</html>')
assert.equal(await featureLegalReady('termine'), false, `${doc}: kein %PDF-`)
writeFileSync(pfad, '%PDF')
assert.equal(await featureLegalReady('termine'), false, `${doc}: zu kurz`)
rmSync(pfad)
mkdirSync(pfad)
assert.equal(await featureLegalReady('termine'), false, `${doc}: Verzeichnis statt Datei`)
rmSync(pfad, { recursive: true })
bereit()
assert.equal(await featureLegalReady('termine'), true, `${doc}: wieder bereit`)
// die HTML-Seite fehlt
rmSync(htmlPfad(doc))
assert.equal(await featureLegalReady('termine'), false, `${doc}: HTML fehlt`)
bereit()
}
assert.equal(lesen.mock.callCount(), 0, 'nie readFile')
// ohne Verzeichnis, mit unbekannter Funktion und mit unbekannter Fassung: false statt Wurf
await mitFassung('1999-01-01', async () => assert.equal(await featureLegalReady('termine'), false))
assert.equal(await featureLegalReady('gibtsnicht'), false)
setLegalDirForTests(join(kopien, 'gibtsnicht'))
assert.equal(await featureLegalReady('termine'), false)
// die Anhänge der Mail lesen dagegen die ganzen Dateien (legalPdfAttachments)
setLegalDirForTests(kopien)
bereit()
assert.equal((await featureLegalAttachments('termine')).length, 3)
assert.ok(lesen.mock.callCount() >= 3)
} finally {
setLegalDirForTests(null)
mock.restoreAll()
syncBuiltinESMExports()
rmSync(kopien, { recursive: true, force: true })
}
assert.equal(await featureLegalReady('termine'), true, 'mit den ausgelieferten Dateien bereit')
})
test('config.termine.avvVersion: Datumsmuster, nach der Altfassung der Gästetexte, echter Tag', () => {
assert.match(config.termine.avvVersion, /^\d{4}-\d{2}-\d{2}$/)
assert.ok(config.termine.avvVersion > ALT, `${config.termine.avvVersion} liegt nicht nach ${ALT}`)
+19 -3
View File
@@ -74,8 +74,9 @@ test('T20: Betreff [Terminplaner], Platzhalter vorfall/details/zeit, keine Blöc
test('K15, K16: Platzhalter und Blöcke je Funktion, keine Funktion fest im Text', () => {
const k15 = loadTemplate('K15')
assert.equal(k15.subject, 'Deine Bestellung: {{funktion}}')
assert.deepEqual(k15.placeholders, ['kunde_name', 'projekt', 'funktion', 'funktion_gen', 'funktion_akk', 'funktion_nom', 'funktion_pron', 'preis', 'preis_hinweis', 'zeit', 'danach', 'portal_url', 'rechnung_url'])
assert.deepEqual(k15.blocks, ['rechnung'])
// F8: fassung, lb_url, avv_url, widerruf_url, zustimmungen_text und die Blöcke anlagen (mit anhang) und zustimmungen für Funktionen mit Rechtsdokumenten
assert.deepEqual(k15.placeholders, ['kunde_name', 'projekt', 'funktion', 'funktion_gen', 'funktion_akk', 'funktion_nom', 'funktion_pron', 'preis', 'preis_hinweis', 'zeit', 'danach', 'portal_url', 'rechnung_url', 'fassung', 'lb_url', 'avv_url', 'widerruf_url', 'zustimmungen_text'])
assert.deepEqual(k15.blocks, ['rechnung', 'anlagen', 'anhang', 'zustimmungen'])
const k16 = loadTemplate('K16')
assert.equal(k16.subject, '{{funktion_dein}} ist bereit')
assert.deepEqual(k16.placeholders, ['kunde_name', 'projekt', 'funktion', 'funktion_dein', 'funktion_nom', 'bereit', 'knopf', 'portal_url'])
@@ -89,6 +90,21 @@ test('K15, K16: Platzhalter und Blöcke je Funktion, keine Funktion fest im Text
assert.ok(mail.text.includes(part), `K15 enthält: ${part}\n---\n${mail.text}`)
}
assert.doesNotMatch(renderMail('K15', sampleValues(k15), { rechnung: false }).text, /Rechnung ansehen/)
// Vertragsunterlagen (F8): anlagen mit den drei versionierten Adressen, der PDF-Satz nur mit anhang, die Zustimmungen mehrzeilig
const werte = { ...sampleValues(k15), fassung: '2026-10-05', lb_url: 'https://p.example/rechtliches/lb-2026-10-05.html', avv_url: 'https://p.example/rechtliches/avv-2026-10-05.html', widerruf_url: 'https://p.example/rechtliches/w-2026-10-05.html', zustimmungen_text: 'Du hast zugestimmt:\n– Erstens\n– Zweitens' }
const aus = renderMail('K15', werte, { rechnung: false, anlagen: false, anhang: false, zustimmungen: false })
assert.doesNotMatch(aus.text, /Vertragsunterlagen|Zustimmungen|Fassung 2026|hängen als PDF/)
const ohneAnhang = renderMail('K15', werte, { anlagen: true, anhang: false, zustimmungen: false })
assert.deepEqual(ohneAnhang.problems, [])
for (const part of ['Deine Vertragsunterlagen', 'Fassung 2026-10-05: Leistungsbeschreibung (https://p.example/rechtliches/lb-2026-10-05.html)', 'Auftragsverarbeitungsvertrag (https://p.example/rechtliches/avv-2026-10-05.html)', 'Widerrufsbelehrung mit Muster-Widerrufsformular (https://p.example/rechtliches/w-2026-10-05.html)']) {
assert.ok(ohneAnhang.text.includes(part), `K15 anlagen enthält: ${part}\n---\n${ohneAnhang.text}`)
}
assert.doesNotMatch(ohneAnhang.text, /hängen als PDF|Deine Zustimmungen/)
const mitAnhang = renderMail('K15', werte, { anlagen: true, anhang: true, zustimmungen: true })
assert.match(mitAnhang.text, /Alle drei hängen als PDF an dieser E-Mail\. Bitte bewahre sie auf\./)
assert.ok(mitAnhang.text.includes('Deine Zustimmungen\nDu hast zugestimmt:\n– Erstens\n– Zweitens'), mitAnhang.text)
// anhang steht nur innerhalb von anlagen
assert.doesNotMatch(renderMail('K15', werte, { anlagen: false, anhang: true }).text, /hängen als PDF/)
const bereit = renderMail('K16', sampleValues(k16), {})
assert.deepEqual(bereit.problems, [])
assert.equal(bereit.subject, 'Wert-funktion_dein ist bereit')
@@ -101,7 +117,7 @@ test('K15, K16: Platzhalter und Blöcke je Funktion, keine Funktion fest im Text
test('T16, T19: Betreff, Platzhalter und Blöcke stehen in der Vorlage', () => {
const t16 = loadTemplate('T16')
assert.equal(t16.subject, 'Neue Funktion bestellt: {{funktion}}')
assert.deepEqual(t16.blocks, ['zahlung_fehlgeschlagen', 'ungeklaert', 'kein_abo', 'bereits_aktiv', 'anleitung', 'gesundheit'])
assert.deepEqual(t16.blocks, ['zahlung_fehlgeschlagen', 'ungeklaert', 'kein_abo', 'bereits_aktiv', 'anleitung', 'gesundheit', 'anhang_fehlt'])
const t19 = loadTemplate('T19')
assert.equal(t19.file, 'T19 Erstattung faellig.html')
assert.match(t19.subject, /^\[Abo\] Erstattung fällig: \{\{funktion\}\}-Einrichtung$/)