Files
Webklar-Kundenbereich/test/knUnterlagenFehlen.test.js
T
ClaudeandClaude Sonnet 5.5 72b5ac5265 Kundenansicht neu E-Fix F5: Fassung 6 ohne PDF behauptet keinen Anhang, das Team bekommt den Hinweis
Die PDFs der Fassung 6 entstehen erst nach der Freigabe. Bis dahin gingen K01, K12 und K13 ohne Anhang raus, sagten aber "im Anhang" bzw.
"beigefügt", und das Team erfuhr es nicht (nur K38/T24 hatten dafür den Block anhang_fehlt).

- Neuer Helfer vertragsAnhang.js: lädt das PDF der zugestimmten Fassung und meldet fehlt nur bei Fassung 6 ohne lesbare Datei.
- K01, K12, K13: Block anhang (Anhangsaussagen) und ohne_anhang (Satz, dass die Unterlagen gesondert kommen).
- T02, T12, T13: Block anhang_fehlt "Unterlagen von Hand nachsenden" (T12 auch beim späteren Wirksamwerden, T13 auch ohne Aufgabe).
- Alte Fassungen unverändert: Hash-Pins auf die Wiedergabe der Vorlagen vor der Änderung, Anhang und Texte wie bisher.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 21:38:54 +00:00

238 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
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 { mailSink } from './support/mailSink.js'
// Abschlussprüfung F5: Fassung 6 ohne PDF. Fehlt der Anhang, behaupten K01, K12 und K13 kein „im Anhang“, und das Team bekommt in T02, T12 bzw. T13
// den Block anhang_fehlt (Muster K38/T24). Alte Fassungen bleiben unverändert (Hash-Pins auf die Wiedergabe der alten Vorlagen).
const aw = await startAppwriteMock({
schema: {
subscriptions: schemaKeys('subscriptions'),
contractEvents: schemaKeys('contractEvents'),
hourCredits: schemaKeys('hourCredits'),
featureConsents: schemaKeys('featureConsents'),
},
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
Object.assign(process.env, { KUNDENANSICHT_NEU: 'pilot', KUNDENANSICHT_NEU_PILOT_EMAILS: 'K@example.invalid, k3@example.invalid' })
const { config } = await import('../server/config.js')
const { startTestApp, postWebhook } = await import('./support/testApp.js')
const { hostingDoc, consent, seedFixtures, stripeSubscription } = await import('./support/fixtures.js')
const { default: aboRoutes } = await import('../server/routes/abo.js')
const { switchToWartung } = await import('../server/services/planSwitch.js')
const { changeStorage } = await import('../server/services/mailStorage.js')
const { listContractEvents } = await import('../server/services/contractEvents.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { loadTemplate, renderMail } = await import('../server/services/mailTemplates.js')
const app = await startTestApp({ '/api/abo': aboRoutes })
const mails = mailSink()
setMailTransportForTests(mails)
after(() => {
setMailTransportForTests(null)
return Promise.all([app.close(), aw.close(), st.close()])
})
seedFixtures(aw)
const price = (id, lookup_key, unit_amount) => ({ id, object: 'price', active: true, lookup_key, unit_amount, recurring: { interval: 'month' } })
st.prices.set('price_hosting', price('price_hosting', config.products.hosting.lookupKey, config.products.hosting.amountCents))
st.prices.set('price_wartung', price('price_wartung', config.products.wartung.lookupKey, config.products.wartung.amountCents))
st.prices.set('price_mailStorage', price('price_mailStorage', config.mailStorage.lookupKey, config.mailStorage.amountCents))
const NEU = config.abo.termsVersionNeu
const MIT_PDF = '2099-01-01-v4' // eine Fassung, deren PDF im Repo liegt (Archiv v4), um „Fassung 6 mit PDF“ nachzustellen
const nowUnix = () => Math.floor(Date.now() / 1000)
const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`]))
const sha = (html) => createHash('sha256').update(html).digest('hex')
const HINWEIS = 'Unterlagen von Hand nachsenden'
/** Fassung 6 ist die neue Fassung dieses Laufs: mit termsVersionNeu auf eine Fassung mit PDF zeigt die Neu-Fassung eine vorhandene Datei. */
async function mitNeuFassung(fassung, fn) {
const vorher = config.abo.termsVersionNeu
config.abo.termsVersionNeu = fassung
try {
return await fn()
} finally {
config.abo.termsVersionNeu = vorher
}
}
// ---------------------------------------------------------------- Vorlagen
test('Vorlagen: K01, K12 und K13 kennen anhang und ohne_anhang, T02, T12 und T13 den Block anhang_fehlt', () => {
for (const id of ['K01', 'K12', 'K13']) assert.deepEqual(['anhang', 'ohne_anhang'].filter((b) => !loadTemplate(id).blocks.includes(b)), [], id)
for (const id of ['T02', 'T12', 'T13']) assert.ok(loadTemplate(id).blocks.includes('anhang_fehlt'), id)
// ohne Anhang keine Behauptung „im Anhang“ / „beigefügt“, dafür der Satz, dass die Unterlagen gesondert kommen
const K13_BESTELLUNG = { plus: true, bestellung: true }
for (const [id, blocks] of [['K01', { wartung: true, sofortbeginn: true }], ['K12', {}], ['K13', K13_BESTELLUNG]]) {
const ohne = renderMail(id, sampleValues(loadTemplate(id)), { ...blocks, ohne_anhang: true })
assert.doesNotMatch(ohne.text, /Anhang|beigefügt/i, `${id} ohne Anhang`)
assert.match(ohne.text, /senden wir Ihnen gesondert zu/, id)
assert.deepEqual(ohne.problems, [], id)
const mit = renderMail(id, sampleValues(loadTemplate(id)), { ...blocks, anhang: true })
assert.match(mit.text, /im Anhang|beigefügten/, `${id} mit Anhang`)
assert.doesNotMatch(mit.text, /gesondert zu/, id)
}
for (const id of ['T02', 'T12', 'T13']) {
const mit = renderMail(id, sampleValues(loadTemplate(id)), { anhang_fehlt: true })
assert.match(mit.text, new RegExp(HINWEIS), id)
assert.match(mit.text, /nicht mit der Bestätigung beim Kunden angekommen/, id)
assert.doesNotMatch(renderMail(id, sampleValues(loadTemplate(id)), {}).text, new RegExp(HINWEIS), id)
}
})
// Hash der Wiedergabe der Vorlagen vor der Änderung (Stand kn-p7 plus P8-Nachträge, ohne anhang-Blöcke): die alten Fassungen bleiben bytegleich
const ALT = [
['K01', {"hosting": true, "sofortbeginn": true}, 'ea5fed3076d4bf6c4f5aa7042d20d61c1d90d11716f99811e439df09c95abd56'],
['K01', {"wartung": true, "beginn_nach_widerrufsfrist": true, "angebot": true}, '434d4945815af9e8df98039184e09058b992c32f1f7a21844b25923be2130aca'],
['K01', {"wartung_v3": true, "sofortbeginn": true}, '4a5a3252ad7ea5c5eda35b42480f95611a4cb5648e9a847afc6720692240d729'],
['K12', {"zahlung_offen": true, "zusatzspeicher": true, "sofortverlangen": true}, '8c18e9a2455e28ec5467fb68dcdfe9b66afe9cc676ef16cc84281a4b2d97caf2'],
['K12', {"lastschrift": true}, '9731b26f4043f29537e289edf7b00d4d024522c6bafb9166d3a0b6289e592963'],
['K12', {}, '76a6f5f1f350f80a938828ce2cd1d84305aca2251427e2ad0df7bb550dc18ff7'],
['K13', {"plus": true, "bestellung": true}, 'b01d4f6a30f562ccee3739f8f5b77bb9a660a61165366cd3d294a627ccfdc8c8'],
['K13', {"plus_bezahlt": true, "bestellung": true}, '7a41e74fe6bf45a33bb6e9dd35d29f6995b8995961e3b6905ed5ba45e19b0e51'],
['K13', {"minus": true}, 'a82497427b0d7af96aadd380974ce5e73930ec2c9a7f5b6357f5f13f8190d109'],
['T02', {"angebot": true, "angebot_fehler": true, "nachweis_fehlt": true}, '438a623fb53046b34930a30110fa35d3c08c6f27ff84af21466426496202a441'],
['T02', {}, '386199538e7a59eb09b41511f4cab3924c36cacdbbdba281af45b2fa86158755'],
['T12', {"wirksam": true, "nachweis_fehlt": true}, '3777c2edc646e53b5b63280132e0958e31dba5d084848244525882a9b2fb1d07'],
['T12', {"lastschrift": true, "gescheitert": true}, '56e1c70e82d260b6c62788fbed5503d2e4e6c6fff1046281b7e76261ced5f1c9'],
['T12', {}, '928f6ee5118da82fce612d77f6dab22be4c3b0aefc736e64668a9d24b69da98a'],
['T13', {"einrichten": true, "nachweis_fehlt": true}, '2f5543fec1ecdc7726931967b164d42386a9d32f0f062e8d5770002958c33a72'],
['T13', {"periodenende": true, "teilweise": true}, '44fa59476f1edb46f977500b1d8ebed7f07205ca2f6087609c2602e15f10eaeb'],
['T13', {}, '793af7c1cb98df24d914d1a5bf3330ace1751a2794713045b78a9ac2a93f1a9f'],
]
test('Alte Fassungen: die Wiedergabe der Vorlagen bleibt bytegleich (K mit anhang, T ohne anhang_fehlt)', () => {
for (const [id, blocks, soll] of ALT) {
const mitAnhang = id.startsWith('K') ? { ...blocks, anhang: true } : blocks
assert.equal(sha(renderMail(id, sampleValues(loadTemplate(id)), mitAnhang).html), soll, `${id} ${JSON.stringify(blocks)}`)
}
})
// ---------------------------------------------------------------- K01 und T02
const SUB_NEW = 'sub_1SD6vPRxAbCdEfGhIjKlMnOp'
const checkoutCompleted = (subscriptionId, metadata = {}) => ({
id: `evt_${subscriptionId}`, object: 'event', type: 'checkout.session.completed', created: nowUnix(),
data: { object: { id: `cs_${subscriptionId}`, object: 'checkout.session', mode: 'subscription', subscription: subscriptionId, customer: 'cus_c3',
customer_details: { email: 'k3@example.invalid', name: 'Kunde Drei' }, metadata: { webklarCustomerId: 'c3', productKey: 'wartung', ...metadata } } },
})
async function clearEvents(customerId) {
for (const e of await listContractEvents(customerId)) await deleteDocument(config.collections.contractEvents, e.$id)
}
async function k01(termsVersion) {
st.subscriptions.set(SUB_NEW, stripeSubscription({ id: SUB_NEW, customerId: 'c3', productKey: 'wartung', metadata: { termsVersion, startChoice: 'sofort', earlyStartConsentAt: new Date().toISOString() } }))
mails.sent.length = 0
try {
assert.equal((await postWebhook(checkoutCompleted(SUB_NEW))).status, 200)
return { kunde: mails.sent.find((m) => m.subject.includes('Vertragsbestätigung')), team: mails.sent.find((m) => m.subject.startsWith('[Abo] Neues')) }
} finally {
await clearEvents('c3')
await deleteDocument(config.collections.subscriptions, SUB_NEW).catch(() => {})
}
}
test('K01: Fassung 6 ohne PDF behauptet keinen Anhang, T02 sagt es dem Team', async () => {
const { kunde, team } = await k01(NEU)
assert.equal(kunde.attachments.length, 0)
assert.doesNotMatch(kunde.text, /Anhang|beigefügt/i)
assert.match(kunde.text, /senden wir Ihnen gesondert zu/)
assert.match(team.text, new RegExp(HINWEIS))
})
test('K01: Fassung 6 mit PDF und alte Fassung wie bisher (Anhang, kein Hinweis)', async () => {
for (const [name, fassung, neu] of [['Fassung 6 mit PDF', MIT_PDF, MIT_PDF], ['Fassung 5', config.abo.termsVersion, NEU]]) {
const { kunde, team } = await mitNeuFassung(neu, () => k01(fassung))
assert.equal(kunde.attachments.length, 1, name)
assert.match(kunde.text, /im Anhang/, name)
assert.doesNotMatch(kunde.text, /gesondert zu/, name)
assert.doesNotMatch(team.text, new RegExp(HINWEIS), name)
}
})
// ---------------------------------------------------------------- K12 und T12
const CARD = { id: 'pm_card_visa', object: 'payment_method', type: 'card' }
const switchInvoice = (overrides = {}) => ({ id: 'in_wechsel', object: 'invoice', status: 'paid', amount_due: 4100, billing_reason: 'subscription_update', hosted_invoice_url: 'https://invoice.stripe.test/i/in_wechsel', ...overrides })
async function resetHosting() {
await clearEvents('c1')
aw.seed('subscriptions', { ...hostingDoc })
st.subscriptions.set('sub_h', stripeSubscription({ id: 'sub_h', customerId: 'c1', productKey: 'hosting', default_payment_method: CARD }))
st.nextSubscriptionUpdate = null
mails.sent.length = 0
}
async function k12(termsVersion) {
await resetHosting()
st.nextSubscriptionUpdate = { latest_invoice: switchInvoice() }
await switchToWartung('c1', { ...consent, termsVersion }, nowUnix())
return { kunde: mails.sent.find((m) => m.subject === 'Ihr Wechsel ins WEBklar Wartungs-Abo – Bestätigung'), team: mails.sent.find((m) => m.subject.startsWith('[Abo] Wechsel ins Wartungs-Abo')) }
}
test('K12: Fassung 6 ohne PDF behauptet keinen Anhang, T12 sagt es dem Team; mit PDF und alte Fassung wie bisher', async () => {
const ohne = await k12(NEU)
assert.equal(ohne.kunde.attachments.length, 0)
assert.doesNotMatch(ohne.kunde.text, /Anhang|beigefügt/i)
assert.match(ohne.kunde.text, /senden wir Ihnen gesondert zu/)
assert.match(ohne.team.text, new RegExp(HINWEIS))
for (const [name, fassung, neu] of [['Fassung 6 mit PDF', MIT_PDF, MIT_PDF], ['Fassung 5', config.abo.termsVersion, NEU]]) {
const r = await mitNeuFassung(neu, () => k12(fassung))
assert.equal(r.kunde.attachments.length, 1, name)
assert.match(r.kunde.text, /im Anhang/, name)
assert.doesNotMatch(r.team.text, new RegExp(HINWEIS), name)
}
})
// ---------------------------------------------------------------- K13 und T13
async function resetSubH({ units, paidUnits }) {
await clearEvents('c1')
mails.sent.length = 0
aw.seed('subscriptions', { ...hostingDoc, storageUnits: units, storagePaidUnits: paidUnits, storagePaidPeriodEnd: hostingDoc.currentPeriodEnd })
st.subscriptions.set('sub_h', stripeSubscription({ id: 'sub_h', customerId: 'c1', productKey: 'hosting', storageUnits: units }))
}
async function k13({ units, paidUnits, ziel, termsVersion }) {
await resetSubH({ units, paidUnits })
await changeStorage('c1', ziel, { ...consent, termsVersion })
return { kunde: mails.sent.find((m) => m.subject === 'Ihr E-Mail-Zusatzspeicher – Bestätigung'), team: mails.sent.find((m) => m.subject.startsWith('[Speicher]')) }
}
test('K13: Fassung 6 ohne PDF behauptet keinen Anhang, T13 sagt es dem Team (auch ohne Aufgabe); mit PDF und alte Fassung wie bisher', async () => {
const ohne = await k13({ units: 0, paidUnits: 0, ziel: 2, termsVersion: NEU })
assert.equal(ohne.kunde.attachments.length, 0)
assert.doesNotMatch(ohne.kunde.text, /Anhang|beigefügt/i)
assert.match(ohne.kunde.text, /senden wir Ihnen gesondert zu/)
assert.match(ohne.team.text, new RegExp(HINWEIS))
// Plus nur bis zur schon bezahlten Menge: keine Aufgabe, aber die Unterlagen müssen nachgesendet werden
const bezahlt = await k13({ units: 1, paidUnits: 3, ziel: 3, termsVersion: NEU })
assert.equal(bezahlt.kunde.attachments.length, 0)
assert.ok(bezahlt.team, 'T13 geht auch ohne Aufgabe raus, wenn der Anhang fehlt')
assert.match(bezahlt.team.text, new RegExp(HINWEIS))
for (const [name, fassung, neu] of [['Fassung 6 mit PDF', MIT_PDF, MIT_PDF], ['Fassung 5', config.abo.termsVersion, NEU]]) {
const r = await mitNeuFassung(neu, () => k13({ units: 0, paidUnits: 0, ziel: 2, termsVersion: fassung }))
assert.equal(r.kunde.attachments.length, 1, name)
assert.match(r.kunde.text, /beigefügten Vertragsunterlagen/, name)
assert.doesNotMatch(r.team.text, new RegExp(HINWEIS), name)
}
// Verringerung: nie ein Anhang, nie ein Hinweis
const minus = await k13({ units: 3, paidUnits: 3, ziel: 1, termsVersion: NEU })
assert.equal(minus.kunde.attachments.length, 0)
assert.doesNotMatch(minus.team.text, new RegExp(HINWEIS))
})
// Karte mit Bestätigung bei der Bank: K12 geht beim Klick, T12 "wirksam" erst mit pending_update_applied; auch dieses T12 sagt, dass die PDFs fehlen
test('T12 beim späteren Wirksamwerden (Karte, pending_update_applied) trägt anhang_fehlt, solange die PDFs der Fassung 6 fehlen', async () => {
await resetHosting()
const nowU = nowUnix()
st.nextSubscriptionUpdate = {
pending_update: { expires_at: nowU + 23 * 3600, billing_cycle_anchor: nowU, subscription_items: [], trial_end: null, trial_from_plan: null },
latest_invoice: switchInvoice({ status: 'open', hosted_invoice_url: 'https://pay.example/karte' }),
}
await switchToWartung('c1', { ...consent, termsVersion: NEU }, nowU)
assert.ok(mails.sent.find((m) => m.subject === 'Ihr Wechsel ins WEBklar Wartungs-Abo – Bestätigung'), 'K12 ging beim Klick raus')
assert.equal(mails.sent.filter((m) => m.subject.startsWith('[Abo] Wechsel ins Wartungs-Abo')).length, 0, 'T12 wartet auf die Zahlung')
st.applyPendingUpdate('sub_h')
const res = await postWebhook({ id: 'evt_pua', object: 'event', type: 'customer.subscription.pending_update_applied', created: nowU, data: { object: { id: 'sub_h', object: 'subscription' } } })
assert.equal(res.status, 200, JSON.stringify(res.body))
const t12 = mails.sent.filter((m) => m.subject.startsWith('[Abo] Wechsel ins Wartungs-Abo'))
assert.equal(t12.length, 1)
assert.match(t12[0].text, new RegExp(HINWEIS))
})