Rechtstexte und K15: Prüfbefunde F5–F8 (AVV genauer, PDF-Prüfsumme, Unterlagen der vereinbarten 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:42:27 +00:00
1 parent 2c059cb3d6
commit 90098726b9
13 files changed
+345 -63

No files matched your search

+147 -5
View File
@@ -1,4 +1,4 @@
import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'
import { readFileSync, writeFileSync, mkdtempSync, mkdirSync, copyFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
@@ -376,11 +376,153 @@ test('k15_anhang_nicht_lesbar: nicht lesbare PDFs => K15 ohne Anhang, T16 mit Bl
} finally {
log2.mock.restore()
}
// Kunde ohne E-Mail-Adresse: keine K15, aber T16 sagt es dem Team
// Kunde ohne E-Mail-Adresse: keine K15, T16 sagt es dem Team, und das Log nennt den Grund (T16: "Der Grund steht im Log des Portals")
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'))
const log3 = stumm()
try {
const ohneAdresse = await bezahleTermine({ customerId: 'c9' })
assert.equal(ohneAdresse.k15Anzahl, 0)
assert.ok(ohneAdresse.t16.text.includes('Unterlagen von Hand nachsenden'))
const zeilen = log3.mock.calls.map((c) => c.arguments.join(' '))
assert.ok(zeilen.some((z) => z.includes(`K15 (Funktion ${ohneAdresse.doc.$id}) nicht gesendet: beim Kunden ist keine E-Mail-Adresse hinterlegt`)), zeilen.join('\n'))
assert.doesNotMatch(zeilen.join('\n'), /Ohne Adresse/, 'keine Personendaten im Log')
} finally {
log3.mock.restore()
}
})
/** Verzeichnis mit Kopien der ausgelieferten Dateien unter dem Namen einer anderen Fassung (HTML und PDF je Dokument). */
function dateienDerFassung(fassung) {
const dir = mkdtempSync(join(tmpdir(), 'k15-fassung-'))
mkdirSync(join(dir, 'rechtliches'))
const echt = legalPdfFiles('termine', V)
for (const f of Object.values(echt)) {
const slug = f.path.split('/').at(-1).replace(`-${V}.pdf`, '')
copyFileSync(f.path, join(dir, 'rechtliches', `${slug}-${fassung}.pdf`))
copyFileSync(f.path.replace(/\.pdf$/, '.html'), join(dir, 'rechtliches', `${slug}-${fassung}.html`))
}
return dir
}
test('k15_vereinbarte_fassung: wurde die Fassung zwischen Klick und Zahlung angehoben, gehen PDFs und Links der vereinbarten Fassung raus, wenn es sie gibt', async () => {
const alt = '2026-10-01'
const dir = dateienDerFassung(alt)
const log = stumm()
try {
setLegalDirForTests(dir)
const vereinbart = Object.fromEntries(Object.keys(consents.zustimmungsfassungen(T)).map((k) => [k, alt]))
const { k15, t16, doc } = await bezahleTermine({ fassungen: vereinbart })
assert.deepEqual(k15.attachments.map((a) => a.filename), [`Leistungsbeschreibung-Terminplaner-${alt}.pdf`, `AVV-Terminplaner-${alt}.pdf`, `Widerrufsbelehrung-Muster-Widerrufsformular-${alt}.pdf`])
for (const a of k15.attachments) assert.equal(Buffer.from(a.content, 'base64').subarray(0, 5).toString('latin1'), '%PDF-')
for (const slug of ['leistungsbeschreibung-terminplaner', 'avv-terminplaner', 'widerrufsbelehrung-funktionen']) {
assert.ok(k15.text.includes(`${BASE}/rechtliches/${slug}-${alt}.html`), `${slug}: Link der vereinbarten Fassung`)
assert.ok(!k15.text.includes(`${slug}-${V}.html`), `${slug}: kein Link der aktuellen Fassung`)
}
assert.ok(k15.text.includes(`Fassung ${alt}: Leistungsbeschreibung`))
assert.match(k15.text, /Alle drei hängen als PDF an dieser E-Mail\./)
assert.doesNotMatch(t16.text, /Unterlagen von Hand nachsenden/)
assert.equal(doc.state, 'setup')
assert.ok(log.mock.calls.some((c) => String(c.arguments[0]).includes(`zugestimmt, aktuell ist ${V}`)), 'das Log nennt den Fassungswechsel')
} finally {
setLegalDirForTests(null)
log.mock.restore()
rmSync(dir, { recursive: true, force: true })
}
// gibt es die Dateien der vereinbarten Fassung nicht, geht K15 ohne PDFs und ohne Links raus (Dateien der aktuellen Fassung bleiben ungenutzt)
const log2 = stumm()
try {
const fehlend = await bezahleTermine({ fassungen: { ...consents.zustimmungsfassungen(T), zustimmung: '2026-09-30' } })
assert.ok(!fehlend.k15.attachments || fehlend.k15.attachments.length === 0)
assert.doesNotMatch(fehlend.k15.text, /Deine Vertragsunterlagen|\/rechtliches\//)
assert.match(fehlend.k15.text, /Deine Zustimmungen/)
assert.ok(fehlend.t16.text.includes('Unterlagen von Hand nachsenden'))
} finally {
log2.mock.restore()
}
})
test('k15_nachweis_nicht_lesbar: ohne lesbaren Nachweis ist die vereinbarte Fassung unbekannt: keine PDFs, keine Links, T16 anhang_fehlt, Log', async () => {
// (a) Lesefehler beim Nachweis
zaehler += 1
const projectId = `ptermine${zaehler}`
const sessionId = `cs_ptermine${zaehler}`
aw.seed('websiteProjects', { $id: projectId, customerId: 'c1', projectName: `Salon ${zaehler}` })
await sf.markOrdered({ customerId: 'c1', projectId, key: 'termine', sessionId, consent: {} })
await consents.speichereZustimmung({
customerId: 'c1', projectId, featureKey: 'termine', stripeSessionId: sessionId, fassungen: consents.zustimmungsfassungen(T),
texte: consents.nachweisTexte(T, { belehrung: widerrufsbelehrungFunktion() }), at: KLICK,
})
const log = stumm()
try {
aw.failNext({ collection: 'featureConsents', method: 'GET', status: 500, message: 'GEHEIMER-ROHTEXT' })
const vor = mails.sent.length
await sf.handleFeatureCheckoutEvent(paidEvent(sessionId, projectId, 'termine'))
const neu = mails.sent.slice(vor)
const k15 = neu.find((m) => m.subject.startsWith('Deine Bestellung'))
const t16 = neu.find((m) => m.subject.startsWith('Neue Funktion bestellt'))
assert.ok(k15, 'die Bestätigung geht trotzdem raus')
assert.ok(!k15.attachments || k15.attachments.length === 0, 'nie die aktuelle Fassung ungeprüft anhängen')
assert.doesNotMatch(k15.text, /Deine Vertragsunterlagen|Deine Zustimmungen|\/rechtliches\//)
assert.ok(t16.text.includes('Unterlagen von Hand nachsenden'))
assert.equal((await sf.getFeatureDoc('c1', projectId, 'termine')).state, 'setup')
assert.ok(log.mock.calls.some((c) => String(c.arguments[0]).includes('die vereinbarte Fassung ist unbekannt')), 'das Log nennt den Grund')
} finally {
log.mock.restore()
}
})
test('k15_nachweis_ohne_fassung: ein Nachweis ohne lesbare Fassung zählt wie ein unlesbarer', async () => {
zaehler += 1
const projectId = `ptermine${zaehler}`
const sessionId = `cs_ptermine${zaehler}`
aw.seed('websiteProjects', { $id: projectId, customerId: 'c1', projectName: `Salon ${zaehler}` })
await sf.markOrdered({ customerId: 'c1', projectId, key: 'termine', sessionId, consent: {} })
aw.seed('featureConsents', {
$id: `fc_kaputt_${zaehler}`, customerId: 'c1', projectId, featureKey: 'termine', stripeSessionId: sessionId, acceptedAt: KLICK,
fassungen: 'kein json', texte: JSON.stringify(consents.nachweisTexte(T, { belehrung: widerrufsbelehrungFunktion() })), textHash: 'x',
})
const log = stumm()
try {
const vor = mails.sent.length
await sf.handleFeatureCheckoutEvent(paidEvent(sessionId, projectId, 'termine'))
const neu = mails.sent.slice(vor)
const k15 = neu.find((m) => m.subject.startsWith('Deine Bestellung'))
assert.ok(!k15.attachments || k15.attachments.length === 0)
assert.doesNotMatch(k15.text, /Deine Vertragsunterlagen|\/rechtliches\//)
assert.match(k15.text, /Deine Zustimmungen/, 'der Wortlaut der Zustimmungen steht trotzdem in der Mail')
assert.ok(neu.find((m) => m.subject.startsWith('Neue Funktion bestellt')).text.includes('Unterlagen von Hand nachsenden'))
assert.ok(log.mock.calls.some((c) => String(c.arguments[0]).includes('Fassung im Zustimmungsnachweis nicht bestimmbar')))
} finally {
log.mock.restore()
}
})
test('k15_frisches_array: der Mailer bekommt für attachments immer ein eigenes, nicht eingefrorenes Array', async () => {
const roh = []
setMailTransportForTests({
async sendMail(mail) {
roh.push(mail)
return mails.sendMail(mail)
},
})
try {
aw.seed('websiteProjects', { $id: 'pnews12', customerId: 'c1', projectName: 'Frisch' })
await sf.markOrdered({ customerId: 'c1', projectId: 'pnews12', key: 'news', sessionId: 'cs_news12', consent: {} })
await sf.handleFeatureCheckoutEvent(paidEvent('cs_news12', 'pnews12'))
await bezahleTermine()
} finally {
setMailTransportForTests(mails)
}
const k15 = roh.filter((m) => String(m.subject).startsWith('Deine Bestellung'))
assert.equal(k15.length, 2)
for (const mail of k15) {
assert.ok(Array.isArray(mail.attachments))
assert.equal(Object.isFrozen(mail.attachments), false)
}
assert.notEqual(k15[0].attachments, k15[1].attachments)
k15[0].attachments.push('x') // ein Mailer darf das Array verändern, ohne das gemeinsame Muster zu treffen
const nochmal = await bezahleTermine()
assert.equal(nochmal.k15.attachments.length, 3)
})
test('checkout_503_ohne_unterlagen: fehlen die PDFs der aktuellen Fassung, 503 unterlagen_fehlen vor jeder Stripe-Sitzung, ohne ordered-Dokument', async () => {