Files
Webklar-Kundenbereich/test/shopWebhook.test.js
T
ClaudeandClaude Opus 5.5 77fb8c8f44 Shop Review B I-1: Eingangsmail G15 bei SEPA, G09 nimmt die Bestellung ausdruecklich an
G15 "Wir haben Ihre Bestellung erhalten" geht einmal raus, sobald Stripe die Sitzung
abgeschlossen, die Zahlung aber noch nicht gemeldet hat (meldeEingang, Marker mailsEingangAt,
aus dem Webhook und aus pruefeOffene). G09 sagt ausdruecklich, dass der Kaufvertrag mit der
Mail geschlossen ist, trennt Bestell- und Zahlungsdatum und nennt die Telefonnummer des
Anbieters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:44:48 +00:00

395 lines
20 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, 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)),
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)
})