Files
Webklar-Kundenbereich/test/shopConnect.test.js
T

243 lines
12 KiB
JavaScript

import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import Stripe from 'stripe'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
// Connect-Dienst des Webshops (Task D1) gegen den Stripe-Nachbau: gesendete Parameter, Kopfzeile Stripe-Account,
// Statusabbildung, Webhook-Signatur und Livemode-Regel.
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: st.url })
const { config } = await import('../server/config.js')
const connect = await import('../server/services/shop/connect.js')
const signer = new Stripe('sk_test_fake')
const SECRET = 'whsec_connect_test'
const KEY = config.stripe.secretKey
const customer = { $id: 'cust1', email: 'inhaber@beispiel.de' }
const project = { $id: 'proj1', projectName: 'Bäckerei Muster', liveDomain: 'https://www.baeckerei-muster.de/shop?x=1' }
after(() => {
config.stripe.secretKey = KEY
config.shop.connectWebhookSecret = ''
return st.close()
})
beforeEach(() => {
st.calls.length = 0
config.stripe.secretKey = KEY
config.shop.connectWebhookSecret = SECRET
})
const signiere = (event, secret = SECRET) => {
const payload = JSON.stringify(event)
return { payload, signatur: signer.webhooks.generateTestHeaderString({ payload, secret }) }
}
const rejects = (fn, status) => assert.rejects(Promise.resolve().then(fn), (err) => err.status === status)
test('kontoAnlegen sendet controller, Capabilities, Profil, Metadaten und Idempotenz-Schluessel', async () => {
const id = await connect.kontoAnlegen({ customer, project })
assert.match(id, /^acct_/)
const [call] = st.calls
assert.equal(call.method, 'POST')
assert.equal(call.path, '/v1/accounts')
assert.equal(call.account, undefined)
assert.equal(call.idempotencyKey, 'shop-konto-proj1')
assert.deepEqual(call.body, {
country: 'DE',
email: 'inhaber@beispiel.de',
'business_profile[name]': 'Bäckerei Muster',
'business_profile[url]': 'https://www.baeckerei-muster.de',
'controller[fees][payer]': 'account',
'controller[losses][payments]': 'stripe',
'controller[stripe_dashboard][type]': 'full',
'controller[requirement_collection]': 'stripe',
'capabilities[card_payments][requested]': 'true',
'capabilities[transfers][requested]': 'true',
'metadata[webklar]': 'shop',
'metadata[customerId]': 'cust1',
'metadata[projectId]': 'proj1',
})
const konto = st.accounts.get(id)
assert.equal(konto.controller.stripe_dashboard.type, 'full')
assert.equal(konto.charges_enabled, false)
})
test('kontoAnlegen: gleicher Projekt-Schluessel liefert dasselbe Konto, ohne Live-Domain keine business_profile.url', async () => {
const a = await connect.kontoAnlegen({ customer, project })
const b = await connect.kontoAnlegen({ customer, project })
assert.equal(a, b)
st.calls.length = 0
await connect.kontoAnlegen({ customer, project: { $id: 'proj2', projectName: 'Ohne Domain' } })
assert.equal(st.calls[0].body['business_profile[url]'], undefined)
assert.equal(st.calls[0].body['business_profile[name]'], 'Ohne Domain')
st.calls.length = 0
await connect.kontoAnlegen({ customer, project: { $id: 'proj3', projectName: 'Kaputt', liveDomain: 'nicht gültig' } })
assert.equal(st.calls[0].body['business_profile[url]'], undefined)
})
test('onboardingLink sendet Konto, Typ, Rueck-URLs und eventually_due', async () => {
const id = await connect.kontoAnlegen({ customer, project })
st.calls.length = 0
const url = await connect.onboardingLink(id, { returnUrl: 'https://p.test/zurueck', refreshUrl: 'https://p.test/neu' })
assert.match(url, /^https:\/\/connect\.stripe\.test\/setup\//)
assert.deepEqual(st.calls[0].body, {
account: id,
type: 'account_onboarding',
refresh_url: 'https://p.test/neu',
return_url: 'https://p.test/zurueck',
'collection_options[fields]': 'eventually_due',
})
})
test('kontoStatus bildet unfertige und fertige Konten ab', async () => {
const id = await connect.kontoAnlegen({ customer, project })
const neu = await connect.kontoStatus(id)
assert.deepEqual({ ...neu, geprueftAt: undefined }, {
charges_enabled: false,
payouts_enabled: false,
details_submitted: false,
currently_due: ['external_account'],
past_due: [],
disabled_reason: null,
geprueftAt: undefined,
})
assert.ok(!Number.isNaN(Date.parse(neu.geprueftAt)))
st.setAccount(id, { charges_enabled: true, payouts_enabled: true, details_submitted: true, requirements: { currently_due: [], past_due: [] } })
const fertig = await connect.kontoStatus(id)
assert.equal(fertig.charges_enabled, true)
assert.equal(fertig.payouts_enabled, true)
assert.equal(fertig.details_submitted, true)
assert.deepEqual(fertig.currently_due, [])
st.setAccount(id, { charges_enabled: false, requirements: { past_due: ['individual.id_number'], disabled_reason: 'requirements.past_due' } })
const gesperrt = await connect.kontoStatus(id)
assert.deepEqual(gesperrt.past_due, ['individual.id_number'])
assert.equal(gesperrt.disabled_reason, 'requirements.past_due')
})
test('statusAusKonto ist rein und robust gegen fehlende Felder', () => {
const now = new Date('2026-10-05T10:00:00Z')
assert.deepEqual(connect.statusAusKonto({}, now), {
charges_enabled: false, payouts_enabled: false, details_submitted: false,
currently_due: [], past_due: [], disabled_reason: null, geprueftAt: '2026-10-05T10:00:00.000Z',
})
const eingabe = { charges_enabled: true, requirements: { currently_due: ['a'] } }
const aus = connect.statusAusKonto(eingabe, now)
aus.currently_due.push('b')
assert.deepEqual(eingabe.requirements.currently_due, ['a'])
})
test('Checkout, Abruf, Ablauf: Stripe-Account-Kopfzeile, Idempotenz, Ablage je Konto', async () => {
const id = await connect.kontoAnlegen({ customer, project })
const other = 'acct_andere1'
st.calls.length = 0
const params = { mode: 'payment', success_url: 'https://x.test/ok', cancel_url: 'https://x.test/nein', metadata: { wkShop: '1', orderId: 'o1' }, expires_at: 1900000000 }
const sess = await connect.checkoutAnlegen(id, params, { idempotencyKey: 'shop-checkout-o1' })
assert.match(sess.id, /^cs_/)
assert.match(sess.url, /^https:\/\/checkout\.stripe\.test\//)
assert.equal(st.calls[0].account, id)
assert.equal(st.calls[0].idempotencyKey, 'shop-checkout-o1')
assert.equal(st.calls[0].body['metadata[wkShop]'], '1')
const again = await connect.checkoutAnlegen(id, params, { idempotencyKey: 'shop-checkout-o1' })
assert.equal(again.id, sess.id)
assert.equal(st.accountSessions(id).size, 1)
assert.equal(st.checkoutSessions.size, 0, 'Plattform-Ablage bleibt unberuehrt')
const geholt = await connect.sessionHolen(id, sess.id, { expand: ['payment_intent'] })
assert.equal(geholt.status, 'open')
const get = st.calls.at(-1)
assert.equal(get.account, id)
assert.equal(get.query['expand[0]'], 'payment_intent')
await assert.rejects(connect.sessionHolen(other, sess.id), (err) => err.statusCode === 404)
const expired = await connect.sessionAblaufen(id, sess.id)
assert.equal(expired.status, 'expired')
assert.equal(st.calls.at(-1).account, id)
assert.equal(st.calls.at(-1).path, `/v1/checkout/sessions/${sess.id}/expire`)
await assert.rejects(connect.sessionAblaufen(id, sess.id), (err) => err.statusCode === 400)
})
test('erstatten: Teil- und Restbetrag, Konto-Kopfzeile, Idempotenz, Obergrenze', async () => {
const id = await connect.kontoAnlegen({ customer, project })
st.setPaymentIntent(id, 'pi_abc', { amount: 2500 })
st.calls.length = 0
const teil = await connect.erstatten(id, { paymentIntent: 'pi_abc', betragCent: 1000, grund: 'Artikel beschädigt', idempotencyKey: 'shop-erstattung-o1-1' })
assert.equal(teil.amount, 1000)
assert.deepEqual(st.calls[0].body, {
payment_intent: 'pi_abc',
amount: '1000',
reason: 'requested_by_customer',
'metadata[webklar]': 'shop',
'metadata[grund]': 'Artikel beschädigt',
})
assert.equal(st.calls[0].account, id)
assert.equal(st.calls[0].idempotencyKey, 'shop-erstattung-o1-1')
const wieder = await connect.erstatten(id, { paymentIntent: 'pi_abc', betragCent: 1000, idempotencyKey: 'shop-erstattung-o1-1' })
assert.equal(wieder.id, teil.id)
assert.equal(st.refunds(id).size, 1)
const rest = await connect.erstatten(id, { paymentIntent: 'pi_abc' })
assert.equal(rest.amount, 1500, 'ohne Betrag der Rest')
await assert.rejects(connect.erstatten(id, { paymentIntent: 'pi_abc', betragCent: 1 }), (err) => err.statusCode === 400)
await rejects(() => connect.erstatten(id, { paymentIntent: 'pi_abc', betragCent: 10.5 }), 400)
await rejects(() => connect.erstatten(id, { paymentIntent: 'pi_abc', betragCent: 0 }), 400)
await rejects(() => connect.erstatten(id, { paymentIntent: '../x', betragCent: 100 }), 400)
})
test('Konto-ID wird geprueft, bevor etwas gesendet wird', async () => {
st.calls.length = 0
for (const bad of ['', 'acct_', 'acct_a/b', 'cus_123', 'acct_ab cd', null, undefined, 'acct_x?y=1']) {
await rejects(() => connect.kontoStatus(bad), 400)
await rejects(() => connect.onboardingLink(bad, { returnUrl: 'https://a.test', refreshUrl: 'https://a.test' }), 400)
await rejects(() => connect.checkoutAnlegen(bad, {}), 400)
await rejects(() => connect.sessionHolen(bad, 'cs_test_1'), 400)
await rejects(() => connect.sessionAblaufen(bad, 'cs_test_1'), 400)
await rejects(() => connect.erstatten(bad, { paymentIntent: 'pi_1' }), 400)
}
await rejects(() => connect.sessionHolen('acct_ok1', '../x'), 400)
assert.equal(st.calls.length, 0)
})
test('Stripe-Fehler (Konto unbekannt) kommen als Fehler an', async () => {
await assert.rejects(connect.kontoStatus('acct_gibtesnicht'), (err) => err.statusCode === 404)
})
test('connectEreignis: gueltige Signatur liefert das Ereignis', () => {
const event = { id: 'evt_1', object: 'event', type: 'account.updated', account: 'acct_x1', livemode: false, data: { object: { id: 'acct_x1' } } }
const { payload, signatur } = signiere(event)
const aus = connect.connectEreignis(Buffer.from(payload), signatur)
assert.equal(aus.id, 'evt_1')
assert.equal(aus.account, 'acct_x1')
assert.equal(connect.connectEreignis(payload, signatur).id, 'evt_1', 'auch als Text')
})
test('connectEreignis: ungueltige oder fehlende Signatur ist 400', () => {
const event = { id: 'evt_1', object: 'event', type: 'account.updated', livemode: false, data: { object: {} } }
const { payload } = signiere(event)
assert.throws(() => connect.connectEreignis(payload, signiere(event, 'whsec_falsch').signatur), (err) => err.status === 400)
assert.throws(() => connect.connectEreignis(payload, undefined), (err) => err.status === 400)
assert.throws(() => connect.connectEreignis(payload, 'quatsch'), (err) => err.status === 400)
const { signatur } = signiere(event)
assert.throws(() => connect.connectEreignis(payload.replace('evt_1', 'evt_2'), signatur), (err) => err.status === 400)
})
test('connectEreignis: ohne Secret 503', () => {
config.shop.connectWebhookSecret = ''
const event = { id: 'evt_1', object: 'event', type: 'x', livemode: false, data: { object: {} } }
const { payload, signatur } = signiere(event)
assert.throws(() => connect.connectEreignis(payload, signatur), (err) => err.status === 503)
})
test('connectEreignis: mit Live-Schluessel zaehlen nur Live-Ereignisse', () => {
config.stripe.secretKey = 'sk_live_fake'
const test = { id: 'evt_t', object: 'event', type: 'account.updated', livemode: false, data: { object: {} } }
const live = { id: 'evt_l', object: 'event', type: 'account.updated', livemode: true, data: { object: {} } }
const a = signiere(test)
assert.equal(connect.connectEreignis(a.payload, a.signatur), null)
const b = signiere(live)
assert.equal(connect.connectEreignis(b.payload, b.signatur).id, 'evt_l')
// Signatur wird trotzdem zuerst geprueft
assert.throws(() => connect.connectEreignis(a.payload, b.signatur), (err) => err.status === 400)
// Testschluessel: beide Arten gehen durch
config.stripe.secretKey = KEY
assert.equal(connect.connectEreignis(a.payload, a.signatur).id, 'evt_t')
})