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 { useTestEnv } from './support/testEnv.js' import { schemaKeys } from './support/schema.js' import { mailSink } from './support/mailSink.js' // Wechsel Hosting -> Wartung in Fassung 6: Kundenart vom Server, Betrieb ohne Sofortbeginn-Zeitstempel, Nachweis vor dem Wechsel 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, post } = await import('./support/testApp.js') const { hostingDoc, consent, customerCookie, customers, 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 { listContractEvents } = await import('../server/services/contractEvents.js') const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const { WECHSEL_GESPERRT, FEHLER_NACHWEIS, FEHLER_UNVOLLSTAENDIG } = await import('../server/services/bestelltexte.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 nowUnix = () => Math.floor(Date.now() / 1000) const unix = (iso) => Math.floor(new Date(iso).getTime() / 1000) const CARD = { id: 'pm_card_visa', object: 'payment_method', type: 'card' } const setzeKundenart = (id, wert) => aw.seed('customers', { ...customers[id], ...(wert === undefined ? {} : { kundenart: wert }) }) async function clearEvents(customerId) { for (const e of await listContractEvents(customerId)) await deleteDocument(config.collections.contractEvents, e.$id) } /** Hosting-Abo sub_h von c1 in Spiegel und Stripe-Nachbau auf den Ausgangsstand setzen, Ereignisse und Mail-Senke leeren. */ 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 } const line = (priceId, amount, { proration = false, subscription = 'sub_h', periodStart = null } = {}) => ({ amount, quantity: 1, period: { start: periodStart ?? unix(hostingDoc.startedAt), end: unix(hostingDoc.currentPeriodEnd) }, pricing: { type: 'price_details', price_details: { price: priceId } }, parent: { type: 'subscription_item_details', subscription_item_details: { proration, subscription } }, }) const previewInvoice = (...lines) => ({ object: 'invoice', lines: { object: 'list', data: lines, has_more: false }, amount_due: lines.reduce((sum, l) => sum + l.amount, 0) }) 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 }) const NEU = () => config.abo.termsVersionNeu const wb = (extra = {}) => ({ acceptTerms: true, termsVersion: NEU(), prorationDate: nowUnix(), amountDueCents: 4100, ...extra }) const zeilen = () => [...aw.docs('featureConsents').values()].filter((d) => d.featureKey === 'wechsel') const bereit = () => { st.invoicePreview = previewInvoice(line('price_hosting', -900, { proration: true, periodStart: nowUnix() }), line('price_wartung', 5000)) st.nextSubscriptionUpdate = { latest_invoice: switchInvoice() } } async function wechsel(b, cookie = customerCookie()) { await resetHosting() const vor = st.calls.length bereit() const res = await post('/api/abo/switch', b, { cookie }) return { res, call: st.calls.slice(vor).find((c) => c.path === '/v1/subscriptions/sub_h' && c.method === 'POST') || null } } test('Betrieb: Wechsel ohne Häkchen, ohne Zeitstempel, Grund im Ereignis, Nachweis ohne Beginn-Häkchen', async () => { setzeKundenart('c1', 'unternehmen') const n0 = zeilen().length const { res, call } = await wechsel(wb()) assert.equal(res.status, 200, JSON.stringify(res.body)) assert.deepEqual([call.body['metadata[termsVersion]'], call.body['metadata[earlyStartConsentAt]']], [NEU(), '']) const [ev] = await listContractEvents('c1') assert.deepEqual([ev.termsVersion, ev.earlyStartConsentAt, ev.details.kundenart], [NEU(), null, 'unternehmen']) const [z] = zeilen().slice(n0) assert.deepEqual([z.kundenart, z.projectId, JSON.parse(z.fassungen).bezug], ['unternehmen', '', 'hosting>wartung']) assert.deepEqual(JSON.parse(z.texte).map((t) => t.id), ['bestellung', 'linkzeile', 'knopf', 'kundenart']) assert.match(JSON.parse(z.texte)[0].text, /^Wechsel ins Wartungs-Abo, Beginn sofort\. Heute fällig: 41,00 € /) }) test('Privat und leer: ohne Häkchen 400 mit Sperrtext und ohne Schreiben, mit Häkchen Zeitstempel und Häkchen im Nachweis', async () => { for (const art of ['privat', undefined]) { setzeKundenart('c1', art) const n0 = zeilen().length for (const earlyStart of [undefined, false, 'ja', 1]) { const { res, call } = await wechsel(wb(earlyStart === undefined ? {} : { earlyStart })) assert.deepEqual([res.status, res.body.error, call], [400, WECHSEL_GESPERRT, null]) } assert.equal(zeilen().length, n0) const ok = await wechsel(wb({ earlyStart: true })) assert.equal(ok.res.status, 200, JSON.stringify(ok.res.body)) assert.match(ok.call.body['metadata[earlyStartConsentAt]'], /^\d{4}-\d\d-\d\dT/) const [ev] = await listContractEvents('c1') assert.equal(ev.earlyStartConsentAt, ev.consentAt) assert.ok(!('kundenart' in ev.details)) assert.deepEqual(JSON.parse(zeilen().at(-1).texte).map((t) => t.id), ['bestellung', 'linkzeile', 'beginn', 'knopf', 'kundenart']) } }) test('Kundenart kommt nie vom Client (Review Focus 1)', async () => { setzeKundenart('c1', 'unternehmen') const b = await wechsel(wb({ kundenart: 'privat', earlyStart: true })) assert.equal(b.call.body['metadata[earlyStartConsentAt]'], '') assert.equal(zeilen().at(-1).kundenart, 'unternehmen') setzeKundenart('c1', 'privat') assert.equal((await wechsel(wb({ kundenart: 'unternehmen' }))).res.status, 400) }) test('Die gemerkte Funktion aus P6 läuft im Fassung-6-Wechsel weiter', async () => { setzeKundenart('c1', 'unternehmen') aw.seed('websiteProjects', { $id: 'wf_p1', customerId: 'c1', projectName: 'Bäckerei' }) const { res } = await wechsel(wb({ funktion: { key: 'termine', projectId: 'wf_p1' } })) assert.equal(res.status, 200, JSON.stringify(res.body)) assert.deepEqual((await listContractEvents('c1'))[0].details.funktion, { key: 'termine', projectId: 'wf_p1' }) }) test('Unvollständige Bestellung in Fassung 6: 400 ohne Schreiben', async () => { setzeKundenart('c1', 'unternehmen') const n0 = zeilen().length for (const schlecht of [{ acceptTerms: false }, { acceptTerms: undefined }, { prorationDate: 'x' }, { amountDueCents: '4100' }, { prorationDate: undefined }]) { const { res, call } = await wechsel(wb(schlecht)) assert.deepEqual([res.status, res.body.error, call], [400, FEHLER_UNVOLLSTAENDIG, null], JSON.stringify(schlecht)) } assert.equal(zeilen().length, n0) }) test('Fassungsweiche und Nachweisfehler', async () => { setzeKundenart('c1', 'unternehmen') const n0 = zeilen().length const fremd = await wechsel(wb(), customerCookie('c2')) assert.equal(fremd.res.status, 409) assert.equal(fremd.call, null) const alt = await wechsel({ acceptTerms: true, termsVersion: config.abo.termsVersion, prorationDate: nowUnix(), amountDueCents: 4100 }) assert.equal(alt.res.status, 400) // alter Weg verlangt earlyStart: true, auch von einem Betrieb assert.equal(zeilen().length, n0) await resetHosting() bereit() aw.failNext({ collection: config.collections.featureConsents, method: 'POST' }) const vor = st.calls.length const f = await post('/api/abo/switch', wb(), { cookie: customerCookie() }) assert.deepEqual([f.status, f.body.error], [500, FEHLER_NACHWEIS]) assert.equal(st.calls.slice(vor).some((c) => c.path === '/v1/subscriptions/sub_h' && c.method === 'POST'), false) }) test('Fassung 5 aus dem Pilot: wie früher mit Zeitstempel, ohne Nachweis, ohne Grund', async () => { setzeKundenart('c1', 'unternehmen') const n0 = zeilen().length const { res, call } = await wechsel({ acceptTerms: true, earlyStart: true, termsVersion: config.abo.termsVersion, prorationDate: nowUnix(), amountDueCents: 4100 }) assert.equal(res.status, 200, JSON.stringify(res.body)) assert.match(call.body['metadata[earlyStartConsentAt]'], /^\d{4}-\d\d-\d\dT/) const [ev] = await listContractEvents('c1') assert.deepEqual([ev.termsVersion, 'kundenart' in ev.details], [config.abo.termsVersion, false]) assert.equal(zeilen().length, n0) }) test('switchToWartung: ohne Zeitstempel nur mit kundenart unternehmen', async () => { await resetHosting() const { earlyStartConsentAt: _x, ...ohne } = consent for (const c of [ohne, { ...ohne, kundenart: 'privat' }, { ...ohne, kundenart: 'firma' }]) await assert.rejects(switchToWartung('c1', c, nowUnix()), (e) => e.status === 400) bereit() assert.ok((await switchToWartung('c1', { ...ohne, kundenart: 'unternehmen' }, nowUnix(), { expectedAmountCents: 4100 })).event) })