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)) })