import { test, after } from 'node:test' import assert from 'node:assert/strict' import { startAppwriteMock } from './support/appwriteMock.js' import { startFakeStripe } from './support/fakeStripe.js' import { mailSink } from './support/mailSink.js' import { useTestEnv } from './support/testEnv.js' import { schemaKeys } from './support/schema.js' // Bestellung (POST /api/abo/checkout) in Fassung 6: Kundenart und Beginn kommen vom Server, der Wortlaut wird vor der Stripe-Sitzung gespeichert const aw = await startAppwriteMock({ schema: { subscriptions: schemaKeys('subscriptions'), contractEvents: schemaKeys('contractEvents'), aboOffers: schemaKeys('aboOffers'), 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, get, post, postWebhook } = await import('./support/testApp.js') const { customerCookie, customers, kundenansichtCookie, seedFixtures, stripeSubscription } = await import('./support/fixtures.js') const { default: aboRoutes } = await import('../server/routes/abo.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 { offerText } = await import('../server/services/offers.js') const app = await startTestApp({ '/api/abo': aboRoutes }) setMailTransportForTests(mailSink()) after(() => { setMailTransportForTests(null) return Promise.all([app.close(), aw.close(), st.close()]) }) seedFixtures(aw) const unix = (iso) => Math.floor(new Date(iso).getTime() / 1000) const setzeKundenart = (id, wert) => aw.seed('customers', { ...customers[id], ...(wert === undefined ? {} : { kundenart: wert }) }) /** Bestellung abschicken; dazu der Checkout-Aufruf beim Stripe-Nachbau (nur Aufrufe ab jetzt, sonst null). */ async function checkout(body, { cookie = customerCookie('c3') } = {}) { const before = st.calls.length const res = await post('/api/abo/checkout', body, { cookie }) const call = st.calls.slice(before).find((c) => c.method === 'POST' && c.path === '/v1/checkout/sessions') || null return { res, call } } const NEU = () => config.abo.termsVersionNeu const body = (productKey, extra = {}) => ({ productKey, acceptTerms: true, termsVersion: NEU(), offerId: '', ...extra }) const meta = (call, key) => call.body[`subscription_data[metadata][${key}]`] const zeilen = () => [...aw.docs('featureConsents').values()].filter((d) => d.featureKey === 'abo') const ids = (z) => JSON.parse(z.texte).map((t) => t.id) test('Betrieb: sofort ohne Häkchen, Fassung 6 in den Metadaten, Nachweis ohne Beginn-Häkchen', async () => { setzeKundenart('c3', 'unternehmen') const n0 = zeilen().length const { res, call } = await checkout(body('wartung')) assert.equal(res.status, 200, JSON.stringify(res.body)) assert.equal(meta(call, 'startChoice'), 'sofort') assert.ok(!meta(call, 'earlyStartConsentAt')) assert.deepEqual([meta(call, 'termsVersion'), meta(call, 'consentTextVersion')], [NEU(), NEU()]) assert.equal(call.body['subscription_data[trial_end]'], undefined) const [z] = zeilen().slice(n0) assert.deepEqual([z.customerId, z.projectId, z.kundenart, z.stripeSessionId], ['c3', '', 'unternehmen', '']) assert.deepEqual(ids(z), ['bestellung', 'linkzeile', 'knopf', 'kundenart']) assert.deepEqual(JSON.parse(z.fassungen), { zustimmung: NEU(), bezug: 'wartung', wortlaut: 'entwurf' }) assert.match(JSON.parse(z.texte)[0].text, /^Wartungs-Abo: 50,00 € pro Monat .*Beginn sofort/) }) test('Privat und leer: ohne earlyStart nach der Frist, mit earlyStart sofort mit Zeitstempel und Häkchen im Nachweis', async () => { for (const art of ['privat', undefined]) { setzeKundenart('c3', art) const n0 = zeilen().length const { lateStartAt } = await get('/api/abo', { cookie: customerCookie('c3') }) const frist = await checkout(body('hosting', { lateStartAt })) assert.equal(frist.res.status, 200, JSON.stringify(frist.res.body)) assert.equal(meta(frist.call, 'startChoice'), 'nach_frist') assert.equal(frist.call.body['subscription_data[trial_end]'], String(unix(lateStartAt))) const sofort = await checkout(body('hosting', { earlyStart: true })) assert.equal(meta(sofort.call, 'startChoice'), 'sofort') assert.match(meta(sofort.call, 'earlyStartConsentAt'), /^\d{4}-\d\d-\d\dT/) const neu = zeilen().slice(n0) assert.deepEqual(neu.map(ids), [['bestellung', 'linkzeile', 'knopf', 'kundenart'], ['bestellung', 'linkzeile', 'beginn', 'knopf', 'kundenart']]) assert.deepEqual(neu.map((z) => z.kundenart), ['privat', 'privat']) assert.match(JSON.parse(neu[0].texte)[0].text, /Beginn nach Ablauf der Widerrufsfrist am \d\d\.\d\d\.\d{4}/) } }) test('earlyStart zählt nur als Boolean true; veralteter Leistungsbeginn ist 409 ohne Nachweis und Stripe-Aufruf', async () => { setzeKundenart('c3', 'privat') const n0 = zeilen().length const { lateStartAt } = await get('/api/abo', { cookie: customerCookie('c3') }) for (const wert of ['ja', 1, 'true', null]) assert.equal(meta((await checkout(body('hosting', { earlyStart: wert, lateStartAt }))).call, 'startChoice'), 'nach_frist', String(wert)) const alt = await checkout(body('hosting', { lateStartAt: '2026-01-02T05:00:00.000Z' })) assert.equal(alt.res.status, 409) assert.equal(alt.call, null) assert.equal(zeilen().length, n0 + 4) const ohne = await checkout(body('hosting')) assert.equal(ohne.res.status, 409, 'ohne gezeigten Leistungsbeginn') assert.equal(zeilen().length, n0 + 4) }) test('Kundenart und Beginn kommen nie vom Client (Review Focus 1)', async () => { const { lateStartAt } = await get('/api/abo', { cookie: customerCookie('c3') }) setzeKundenart('c3', 'unternehmen') const b = await checkout(body('wartung', { kundenart: 'privat', unternehmer: false, startChoice: 'nach_frist', earlyStart: true, lateStartAt })) assert.equal(meta(b.call, 'startChoice'), 'sofort') assert.ok(!meta(b.call, 'earlyStartConsentAt')) assert.equal(zeilen().at(-1).kundenart, 'unternehmen') assert.ok(!ids(zeilen().at(-1)).includes('beginn')) for (const wert of ['privat', '', 'firma', 'Unternehmen']) { setzeKundenart('c3', wert === '' ? undefined : wert) const p = await checkout(body('wartung', { kundenart: 'unternehmen', startChoice: 'sofort', lateStartAt })) assert.equal(meta(p.call, 'startChoice'), 'nach_frist', wert) assert.equal(zeilen().at(-1).kundenart, 'privat', wert) } }) test('Fassungsweiche: Fassung 6 ohne Pilot 409, Fassung 5 aus dem Pilot wie früher, fehlende Version nie neu', async () => { setzeKundenart('c3', 'unternehmen') const n0 = zeilen().length const fremd = await checkout(body('wartung'), { cookie: customerCookie('c2') }) assert.equal(fremd.res.status, 409) assert.match(fremd.res.body.error, /Bedingungen/) assert.equal(fremd.call, null) const v3 = await checkout(body('wartung', { termsVersion: '2026-09-25-v3' })) assert.equal(v3.res.status, 409) assert.equal(v3.res.body.error, 'Die Bedingungen wurden aktualisiert. Bitte lade die Seite neu.') const alt = await checkout({ productKey: 'wartung', acceptTerms: true, startChoice: 'sofort', termsVersion: config.abo.termsVersion, offerId: '' }) assert.equal(alt.res.status, 200) assert.equal(meta(alt.call, 'termsVersion'), config.abo.termsVersion) assert.equal((await checkout({ productKey: 'wartung', acceptTerms: true, termsVersion: config.abo.termsVersion, offerId: '' })).res.status, 400) // alter Weg verlangt startChoice const { termsVersion: _t, ...ohne } = body('wartung') const gemerkt = config.abo.termsVersionNeu for (const neuWert of [gemerkt, undefined]) { config.abo.termsVersionNeu = neuWert assert.equal((await checkout(ohne)).res.status, 409) } config.abo.termsVersionNeu = gemerkt assert.equal(zeilen().length, n0) // weder Fassung 5 noch Fehlerfälle schreiben einen Nachweis }) test('Fehlende Zustimmung in Fassung 6 ist 400 ohne Nachweis und ohne Stripe-Aufruf', async () => { setzeKundenart('c3', 'unternehmen') const n0 = zeilen().length for (const acceptTerms of [false, undefined, 'true', 1]) { const r = await checkout(body('wartung', { acceptTerms })) assert.deepEqual([r.res.status, r.res.body.error, r.call], [400, 'Die Bestellung ist unvollständig. Bitte lade die Seite neu.', null], String(acceptTerms)) } assert.equal((await checkout(body('gibtEsNicht'))).res.status, 400) assert.equal(zeilen().length, n0) }) test('Nachweis nicht speicherbar: 500, keine Stripe-Sitzung; Ansichtsmodus 403 ohne Schreiben', async () => { setzeKundenart('c3', 'unternehmen') const n0 = zeilen().length aw.failNext({ collection: config.collections.featureConsents, method: 'POST' }) const f = await checkout(body('wartung')) assert.deepEqual([f.res.status, f.res.body.error, f.call], [500, 'Das hat gerade nicht geklappt. Bitte versuche es gleich noch einmal.', null]) const v = await checkout(body('wartung'), { cookie: kundenansichtCookie({ customerId: 'c3' }) }) assert.deepEqual([v.res.status, v.call, zeilen().length], [403, null, n0]) }) test('Kunde nicht lesbar: 500 statt Rückfall auf privat oder Betrieb', async () => { setzeKundenart('c3', 'unternehmen') const n0 = zeilen().length aw.failNext({ collection: config.collections.customers, method: 'GET', status: 500 }) const r = await checkout(body('wartung')) assert.equal(r.res.status, 500) assert.equal(r.call, null) assert.equal(zeilen().length, n0) }) test('Angebot: Die Zeile im Nachweis nennt das Angebot, das der Server kennt', async () => { setzeKundenart('c3', 'unternehmen') const offer = { $id: 'o1', customerId: 'c3', status: 'offen', scope: 'neu', months: 2, monthlyPriceCents: 2500, regularPriceCents: 5000, validUntil: '2099-01-01T00:00:00.000Z', stripeCouponId: 'WEBKLAR-ANGEBOT-O1', note: '' } aw.seed('aboOffers', offer) try { const { res, call } = await checkout(body('wartung', { offerId: 'o1' })) assert.equal(res.status, 200, JSON.stringify(res.body)) assert.equal(meta(call, 'offerId'), 'o1') assert.ok(JSON.parse(zeilen().at(-1).texte)[0].text.includes(`individuelles Angebot: ${offerText(offer)}, Beginn sofort`)) } finally { await deleteDocument(config.collections.aboOffers, 'o1').catch(() => {}) } }) // Webhook: Abo-IDs sind 28 Zeichen wie bei Stripe (sub_ + 24), 'abschluss-' + ID wären sonst länger als 36 const SUB_NEW = 'sub_1SD6vPRxAbCdEfGhIjKlMnOp' const checkoutCompleted = (subscriptionId, metadata = {}) => ({ id: `evt_${subscriptionId}`, object: 'event', type: 'checkout.session.completed', created: Math.floor(Date.now() / 1000), 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: 'hosting', ...metadata }, }, }, }) async function cleanupWebhook(subscriptionId) { for (const e of await listContractEvents('c3')) await deleteDocument(config.collections.contractEvents, e.$id) await deleteDocument(config.collections.subscriptions, subscriptionId).catch(() => {}) } test('Webhook: Ereignis abschluss nennt den Grund kundenart nur bei Fassung 6 ohne Zeitstempel', async () => { const lauf = async (metadata) => { st.subscriptions.set(SUB_NEW, stripeSubscription({ id: SUB_NEW, customerId: 'c3', productKey: 'wartung', metadata })) try { assert.equal((await postWebhook(checkoutCompleted(SUB_NEW, { productKey: 'wartung' }))).status, 200) return (await listContractEvents('c3'))[0].details } finally { await cleanupWebhook(SUB_NEW) } } assert.equal((await lauf({ termsVersion: NEU(), consentTextVersion: NEU(), startChoice: 'sofort', earlyStartConsentAt: '' })).kundenart, 'unternehmen') assert.equal('kundenart' in (await lauf({ termsVersion: NEU(), startChoice: 'sofort', earlyStartConsentAt: new Date().toISOString() })), false) assert.equal('kundenart' in (await lauf({ termsVersion: NEU(), startChoice: 'nach_frist', earlyStartConsentAt: '' })), false) assert.equal('kundenart' in (await lauf({ termsVersion: config.abo.termsVersion, startChoice: 'sofort', earlyStartConsentAt: '' })), false) })