import { test, after, beforeEach, afterEach, mock } 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' import { SHOP_DATETIMES } from '../server/services/shop/schema.js' import { TERMINE_DATETIMES } from '../server/services/termine/schema.js' // Webshop D3: Connect-Webhook (signierte Ereignisse an POST /api/shop/stripe-webhook). Mails landen in der Senke. const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures'), shopConfig: schemaKeys('shopConfig'), shopProducts: schemaKeys('shopProducts'), shopOrders: schemaKeys('shopOrders'), termineOutbox: schemaKeys('termineOutbox'), }, datetimes: { ...SHOP_DATETIMES, ...TERMINE_DATETIMES }, defaultLimit: 25, }) const st = await startFakeStripe() useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) const { config } = await import('../server/config.js') const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js') const K = await import('../server/services/shop/kontext.js') const B = await import('../server/services/shop/bestellungen.js') const { startTestApp, postWebhook, send } = await import('./support/testApp.js') const { default: webhookRoutes } = await import('../server/routes/shopWebhook.js') const app = await startTestApp({ '/api/shop/stripe-webhook': webhookRoutes }) const { normalisiereProdukt } = await import('../server/services/shop/produkte.js') const { STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js') const { rechtstexteHash } = await import('../server/services/shop/rechtstexte.js') after(async () => { await app.close() setMailTransportForTests(null) await aw.close() await st.close() }) const KUNDE = 'c_kunde01' const PROJEKT = 'p_projekt01' const FEATURE = 'f_p_projekt01_shop' const SITE_KEY = 'abcdefghijklmnop' const ACCOUNT = 'acct_haendler1' const T0 = new Date('2026-10-05T12:00:00.000Z') const nach = (s) => new Date(T0.getTime() + s * 1000) const PORTAL = 'https://project.webklar.com' const EINSTELLUNGEN = { ...JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN)), kleinunternehmer: true, lieferzeit: '2–4 Werktage', shopname: 'Brot & Mehr', anbieter: { name: 'Brot & Mehr GmbH', strasse: 'Hauptstraße 5', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'shop@brot.example', telefon: '', ustId: '' }, abholung: { aktiv: true, adresse: 'Hauptstraße 5, 67655 Kaiserslautern', hinweis: 'Mo–Fr 8–12 Uhr' }, versand: { aktiv: true, laender: ['DE', 'AT'], kostenCent: { DE: 490, AT: 790 }, freiAbCent: null }, zahlungsarten: ['card'], } const TEXTE = { agb: 'AGB', widerrufsbelehrung: 'Widerruf', widerrufsformular: 'Formular', impressumUrl: 'https://brot.example/impressum', datenschutzUrl: 'https://brot.example/datenschutz' } const HASH = rechtstexteHash(TEXTE, EINSTELLUNGEN) const featureDoc = { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'shop', siteKey: SITE_KEY, state: 'active', enabled: true } const shopDoc = (over = {}) => ({ $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, siteKey: SITE_KEY, anfrageStatus: 'angenommen', stripeAccountId: ACCOUNT, stripeStatus: JSON.stringify({ charges_enabled: true, payouts_enabled: true }), einstellungen: JSON.stringify(EINSTELLUNGEN), rechtstexte: JSON.stringify({ entwurf: TEXTE, bestaetigt: { texte: TEXTE, hash: HASH, at: '2026-10-05T10:00:00.000Z', von: 'k@example.invalid' } }), geoeffnet: true, version: 1, ...over, }) const artikel = (id, eingabe = {}) => { const r = normalisiereProdukt({ name: `Artikel ${id}`, preisCent: 1250, ...eingabe }, { einstellungen: { kleinunternehmer: true } }) assert.equal(r.fehler, undefined, JSON.stringify(r.fehler)) return { $id: id, shopId: FEATURE, customerId: KUNDE, aktiv: r.produkt.aktiv, sort: 0, name: r.produkt.name, produkt: JSON.stringify(r.produkt) } } let sink let log let ctx async function aufbau({ produkte = [artikel('a1', { bestand: 5 })], shop = {} } = {}) { for (const c of ['portalFeatures', 'shopConfig', 'shopProducts', 'shopOrders', 'websiteProjects', 'termineOutbox', 'customers']) { for (const id of [...aw.docs(c).keys()]) await deleteDocument(config.collections[c], id) } aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Brotladen', liveDomain: 'https://www.brot.example' }) aw.seed('portalFeatures', featureDoc) aw.seed('shopConfig', shopDoc(shop)) aw.seed('customers', { $id: KUNDE, name: 'Erika Muster', email: 'erika@brot.example' }) for (const p of produkte) aw.seed('shopProducts', p) st.setAccount(ACCOUNT, { charges_enabled: true, details_submitted: true, requirements: { currently_due: [] } }) K.leereCache() ctx = await K.shopPerSiteKey(SITE_KEY, { now: T0 }) return ctx } beforeEach(() => { st.calls.length = 0 config.publicBaseUrl = PORTAL config.smtp.from = 'noreply@webklar.com' config.shop.mode = 'on' resetOutboxForTests() sink = mailSink() setMailTransportForTests(sink) log = { warn: mock.method(console, 'warn', () => {}), error: mock.method(console, 'error', () => {}) } K.leereCache() }) afterEach(async () => { await warteAufVersand() mock.restoreAll() }) const orders = () => [...aw.docs('shopOrders').values()] const order = (id) => aw.docs('shopOrders').get(id) const daten = (id) => JSON.parse(order(id).bestellung) const produkt = (id) => JSON.parse(aw.docs('shopProducts').get(id).produkt) const eintraege = () => [...aw.docs('termineOutbox').values()] const shopStatus = () => JSON.parse(aw.docs('shopConfig').get(FEATURE).stripeStatus) const SECRET = 'whsec_connect_test' const PFAD = '/api/shop/stripe-webhook' let nr = 0 const ereignis = (type, objekt, over = {}) => ({ id: `evt_${(nr += 1)}`, object: 'event', type, livemode: false, account: ACCOUNT, data: { object: objekt }, ...over }) const sende = (event, opt = {}) => postWebhook(event, { path: PFAD, secret: SECRET, ...opt }) const kunde = { name: 'Max Mustermann', email: 'max@example.com', phone: '+49 170 1234567', address: { line1: 'Rechnungsweg 1', line2: null, postal_code: '12345', city: 'Musterstadt', country: 'DE' } } const versandAn = { name: 'Max Mustermann', address: { line1: 'Lieferweg 7', line2: null, postal_code: '67655', city: 'Kaiserslautern', country: 'DE', state: null } } async function bestelle(positionen = [{ produktId: 'a1', menge: 2 }]) { const r = await B.legeBestellungAn(ctx, { positionen, lieferart: 'versand', land: 'DE' }, { now: new Date() }) const doc = order(r.orderId) return { ...r, doc, sessionId: doc.stripeSessionId } } const zahle = (b, felder = {}) => st.completeSession(ACCOUNT, b.sessionId, { payment_status: 'paid', amount_total: b.doc.summeCent, customer_details: kunde, shipping: versandAn, payment_intent: 'pi_test1', payment_method_types: ['card'], ...felder, }) const secretVorher = config.shop.connectWebhookSecret const keyVorher = config.stripe.secretKey beforeEach(() => { config.shop.connectWebhookSecret = SECRET config.stripe.secretKey = keyVorher }) after(() => { config.shop.connectWebhookSecret = secretVorher config.stripe.secretKey = keyVorher }) test('Signatur: ungültig ⇒ 400, ohne Secret ⇒ 503, nichts geändert', async () => { await aufbau() const b = await bestelle() const e = ereignis('checkout.session.completed', zahle(b)) assert.equal((await sende(e, { secret: 'whsec_falsch' })).status, 400) assert.equal((await ohneSignatur(e)).status, 400) assert.equal(order(b.orderId).status, 'offen') config.shop.connectWebhookSecret = '' assert.equal((await sende(e)).status, 503) assert.equal(order(b.orderId).status, 'offen') }) const ohneSignatur = (event) => send('POST', PFAD, { body: JSON.stringify(event) }) test('livemode: mit Live-Schlüssel werden Testereignisse ignoriert (200), Live-Ereignisse gelten', async () => { await aufbau() const b = await bestelle() config.stripe.secretKey = 'sk_live_fake' const session = zahle(b) const test = await sende(ereignis('checkout.session.completed', session, { livemode: false })) assert.equal(test.status, 200) assert.deepEqual(test.body, { ignoriert: true }) assert.equal(order(b.orderId).status, 'offen') const live = await sende(ereignis('checkout.session.completed', session, { livemode: true })) assert.equal(live.status, 200) assert.equal(order(b.orderId).status, 'bezahlt') }) test('account.updated: speichert den Status des Shops mit diesem Konto, leert den Cache, behält den Hinweis nur wo sinnvoll', async () => { await aufbau({ shop: { stripeStatus: JSON.stringify({ charges_enabled: false, hinweis: 'Klarna fehlt' }), einstellungen: JSON.stringify({ ...EINSTELLUNGEN, zahlungsarten: ['card', 'klarna'] }) } }) assert.equal(ctx.shop.stripeStatus.charges_enabled, false) const konto = { id: ACCOUNT, charges_enabled: true, payouts_enabled: true, details_submitted: true, requirements: { currently_due: [], past_due: [], disabled_reason: null } } const r = await sende(ereignis('account.updated', konto)) assert.equal(r.status, 200) const s = shopStatus() assert.equal(s.charges_enabled, true) assert.equal(s.payouts_enabled, true) assert.equal(s.hinweis, 'Klarna fehlt', 'Hinweis bleibt: Klarna angeboten und Konto aktiv') assert.ok(s.geprueftAt) const frisch = await K.shopPerSiteKey(SITE_KEY, { now: T0 }) assert.equal(frisch.shop.stripeStatus.charges_enabled, true, 'Cache wurde geleert') // nur Karte ⇒ Hinweis entfällt await updateDocument(config.collections.shopConfig, FEATURE, { einstellungen: JSON.stringify(EINSTELLUNGEN) }) await sende(ereignis('account.updated', { ...konto, charges_enabled: false })) assert.equal(shopStatus().hinweis, undefined) assert.equal(shopStatus().charges_enabled, false) // Konto ohne Ereignis-Feld account: Objekt-ID zählt; unbekanntes Konto ⇒ 200 ohne Wirkung const vorher = JSON.stringify(shopStatus()) assert.equal((await sende(ereignis('account.updated', { id: 'acct_unbekannt', charges_enabled: true }, { account: 'acct_unbekannt' }))).status, 200) assert.equal((await sende(ereignis('account.updated', { id: ACCOUNT, charges_enabled: true, payouts_enabled: false }, { account: undefined }))).status, 200) assert.equal(shopStatus().charges_enabled, true) assert.notEqual(JSON.stringify(shopStatus()), vorher) }) test('checkout.session.completed (bezahlt) ⇒ bezahlt, Bestand, Mails; Wiederholung ändert nichts', async () => { await aufbau() const b = await bestelle() const e = ereignis('checkout.session.completed', zahle(b)) const r = await sende(e) assert.equal(r.status, 200) assert.equal(order(b.orderId).status, 'bezahlt') assert.equal(order(b.orderId).paymentIntentId, 'pi_test1') assert.equal(produkt('a1').bestand, 3) await warteAufVersand() assert.equal(sink.sent.length, 2) assert.equal((await sende(e)).status, 200) assert.equal((await Promise.all([sende(e), sende(e)])).every((x) => x.status === 200), true) await warteAufVersand() assert.equal(produkt('a1').bestand, 3) assert.equal(sink.sent.length, 2) assert.equal(daten(b.orderId).verlauf.filter((v) => v.was === 'bezahlt').length, 1) }) test('SEPA: completed unbezahlt lässt die Bestellung offen und schickt einmal die Eingangsmail G15, async_payment_succeeded bucht', async () => { await aufbau() const b = await bestelle() const unbezahlt = zahle(b, { payment_status: 'unpaid', payment_method_types: ['sepa_debit'] }) assert.equal((await sende(ereignis('checkout.session.completed', unbezahlt))).status, 200) assert.equal(order(b.orderId).status, 'offen') assert.equal(produkt('a1').bestand, 5) await warteAufVersand() assert.deepEqual(sink.sent.map((m) => [m.to[0].address, m.subject.startsWith('Wir haben Ihre Bestellung')]), [['max@example.com', true]], 'nur die Eingangsmail an den Käufer') assert.ok(daten(b.orderId).mailsEingangAt, 'Marker gesetzt') assert.equal((await sende(ereignis('checkout.session.completed', unbezahlt))).status, 200) await warteAufVersand() assert.equal(sink.sent.length, 1, 'Wiederholung des Ereignisses: keine zweite Eingangsmail') const bezahlt = { ...unbezahlt, payment_status: 'paid' } assert.equal((await sende(ereignis('checkout.session.async_payment_succeeded', bezahlt))).status, 200) assert.equal(order(b.orderId).status, 'bezahlt') assert.equal(produkt('a1').bestand, 3) }) test('async_payment_failed ⇒ storniert, Bestand frei, G14 eingereiht; Wiederholung nur einmal', async () => { await aufbau() const b = await bestelle() const session = zahle(b, { payment_status: 'unpaid', payment_method_types: ['sepa_debit'] }) const e = ereignis('checkout.session.async_payment_failed', session) assert.equal((await sende(e)).status, 200) assert.equal(order(b.orderId).status, 'storniert') assert.deepEqual(daten(b.orderId).verlauf.map((v) => v.was), ['angelegt', 'zahlung_fehlgeschlagen']) assert.equal((await B.reserviert(FEATURE, new Date())).get('a1'), undefined) await warteAufVersand() assert.deepEqual(eintraege().map((x) => x.art), ['G14']) assert.equal(eintraege()[0].bookingId, b.orderId) assert.equal((await sende(e)).status, 200) await warteAufVersand() assert.equal(eintraege().length, 1) }) test('checkout.session.expired ⇒ storniert (abgelaufen), keine Mail; bezahlte Bestellung bleibt', async () => { await aufbau() const a = await bestelle() const e = ereignis('checkout.session.expired', { ...st.accountSessions(ACCOUNT).get(a.sessionId), status: 'expired' }) assert.equal((await sende(e)).status, 200) assert.equal(order(a.orderId).status, 'storniert') assert.deepEqual(daten(a.orderId).verlauf.map((v) => v.was), ['angelegt', 'abgelaufen']) await warteAufVersand() assert.equal(eintraege().length, 0) const c = await bestelle([{ produktId: 'a1', menge: 1 }]) await sende(ereignis('checkout.session.completed', zahle(c))) assert.equal((await sende(ereignis('checkout.session.expired', st.accountSessions(ACCOUNT).get(c.sessionId)))).status, 200) assert.equal(order(c.orderId).status, 'bezahlt') }) test('Ereignis eines fremden Kontos kann keine Bestellung eines anderen Shops berühren', async () => { await aufbau() const b = await bestelle() const session = zahle(b) for (const type of ['checkout.session.completed', 'checkout.session.async_payment_succeeded']) { assert.equal((await sende(ereignis(type, session, { account: 'acct_fremd' }))).status, 200) } assert.equal((await sende(ereignis('checkout.session.async_payment_failed', session, { account: 'acct_fremd' }))).status, 200) assert.equal((await sende(ereignis('checkout.session.expired', session, { account: 'acct_fremd' }))).status, 200) assert.equal((await sende(ereignis('checkout.session.completed', session, { account: undefined }))).status, 200) assert.equal(order(b.orderId).status, 'offen') assert.equal(produkt('a1').bestand, 5) await warteAufVersand() assert.equal(sink.sent.length, 0) assert.equal(eintraege().length, 0) }) test('Sitzungen ohne wkShop und unbekannte Ereignisse ⇒ 200 ohne Wirkung', async () => { await aufbau() const b = await bestelle() const session = zahle(b) assert.equal((await sende(ereignis('checkout.session.completed', { ...session, metadata: { orderId: b.orderId } }))).status, 200) assert.equal((await sende(ereignis('checkout.session.completed', { ...session, metadata: {} }))).status, 200) assert.equal((await sende(ereignis('checkout.session.completed', { id: 'cs_x', payment_status: 'paid' }))).status, 200) assert.equal((await sende(ereignis('payout.paid', { id: 'po_1' }))).status, 200) assert.equal((await sende(ereignis('charge.refunded', { id: 'ch_1', payment_intent: null }))).status, 200) assert.equal(order(b.orderId).status, 'offen') }) test('Fehler bei der Verarbeitung ⇒ 500 (Stripe wiederholt), Log ohne Kundendaten', async () => { await aufbau() const b = await bestelle() const e = ereignis('checkout.session.completed', zahle(b)) aw.failNext({ collection: 'shopOrders', method: 'GET', status: 500 }) const nach500 = await sende(e) assert.equal(nach500.status, 500) const logs = log.error.mock.calls.map((a) => a.arguments.map(String).join(' ')).join('\n') assert.match(logs, /checkout\.session\.completed/) assert.doesNotMatch(logs, /max@example|Mustermann/) assert.equal((await sende(e)).status, 200) assert.equal(order(b.orderId).status, 'bezahlt') }) async function bezahlteBestellung(positionen) { const b = await bestelle(positionen) await sende(ereignis('checkout.session.completed', zahle(b))) assert.equal(order(b.orderId).status, 'bezahlt') return b } const refund = (id, amount, extra = {}) => ({ id, object: 'refund', amount, currency: 'eur', status: 'succeeded', payment_intent: 'pi_test1', ...extra }) const charge = (b, refunds, extra = {}) => ({ id: 'ch_1', object: 'charge', payment_intent: 'pi_test1', amount: b.doc.summeCent, metadata: { wkShop: '1', orderId: b.orderId }, ...(refunds ? { refunds: { object: 'list', data: refunds } } : {}), ...extra }) test('charge.refunded: Teilerstattung wird nachgetragen, Wiederholung und Portal-Eintrag zählen nicht doppelt, volle ⇒ erstattet', async () => { await aufbau() const b = await bezahlteBestellung() const summe = b.doc.summeCent await sende(ereignis('charge.refunded', charge(b, [refund('re_1', 500)]))) assert.equal(order(b.orderId).status, 'bezahlt') let e = daten(b.orderId).erstattungen assert.equal(e.length, 1) assert.deepEqual({ rid: e[0].rid, betragCent: e[0].betragCent, grund: e[0].grund, quelle: e[0].quelle }, { rid: 're_1', betragCent: 500, grund: 'Stripe-Dashboard', quelle: 'stripe' }) assert.ok(e[0].at) // Wiederholung + bereits über das Portal gebuchte Erstattung (anderes Feld, gleiche rid) await sende(ereignis('charge.refunded', charge(b, [refund('re_1', 500)]))) assert.equal(daten(b.orderId).erstattungen.length, 1) const d = daten(b.orderId) await updateDocument(config.collections.shopOrders, b.orderId, { bestellung: JSON.stringify({ ...d, erstattungen: [...d.erstattungen, { rid: 're_portal', betragCent: 100, grund: 'Kaputt', at: 'x' }] }) }) await sende(ereignis('charge.refunded', charge(b, [refund('re_1', 500), refund('re_portal', 100)]))) assert.equal(daten(b.orderId).erstattungen.length, 2) // Rest ⇒ voll erstattet await sende(ereignis('charge.refunded', charge(b, [refund('re_1', 500), refund('re_portal', 100), refund('re_2', summe - 600)]))) assert.equal(order(b.orderId).status, 'erstattet') assert.equal(daten(b.orderId).erstattungen.length, 3) assert.deepEqual(daten(b.orderId).verlauf.filter((v) => v.was === 'erstattet_stripe').map((v) => v.betragCent), [500, summe - 600]) await warteAufVersand() assert.equal(eintraege().filter((x) => x.art === 'G13').length, 0, 'keine Käufermail bei Dashboard-Erstattung') // spätere Wiederholung ändert den Endzustand nicht await sende(ereignis('charge.refunded', charge(b, [refund('re_2', summe - 600)]))) assert.equal(order(b.orderId).status, 'erstattet') assert.equal(daten(b.orderId).erstattungen.length, 3) }) test('charge.refunded: ohne refunds im Ereignis werden sie bei Stripe gelesen; Bestellung ohne Metadaten wird über die Zahlung gefunden', async () => { await aufbau() const b = await bezahlteBestellung() st.setPaymentIntent(ACCOUNT, 'pi_test1', { amount: b.doc.summeCent }) const r = await st.refunds(ACCOUNT) r.set('re_s1', refund('re_s1', b.doc.summeCent)) await sende(ereignis('charge.refunded', charge(b, null, { metadata: {} }))) assert.equal(order(b.orderId).status, 'erstattet') assert.equal(daten(b.orderId).erstattungen[0].rid, 're_s1') assert.ok(st.calls.some((c) => c.path === '/v1/refunds' && c.account === ACCOUNT)) }) test('charge.refunded: fremdes Konto, offene Bestellung, fehlgeschlagene Refunds ändern nichts', async () => { await aufbau() const b = await bezahlteBestellung() await sende(ereignis('charge.refunded', charge(b, [refund('re_1', 500)]), { account: 'acct_fremd' })) await sende(ereignis('charge.refunded', charge(b, [refund('re_f', 500, { status: 'failed' }), refund('re_n', 0)]))) assert.equal(daten(b.orderId).erstattungen, undefined) const offen = await bestelle([{ produktId: 'a1', menge: 1 }]) await sende(ereignis('charge.refunded', charge(offen, [refund('re_o', 100)], { payment_intent: 'pi_offen' }))) assert.equal(order(offen.orderId).status, 'offen') assert.equal(daten(offen.orderId).erstattungen, undefined) })