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('statusAusKonto M6: lange Listen werden gekappt (30 Eintraege, 100 Zeichen), das Ergebnis passt als JSON in 4000 Zeichen', () => { const lang = Array.from({ length: 80 }, (_, i) => `individual.verification.document.${'x'.repeat(200)}${i}`) const aus = connect.statusAusKonto({ requirements: { currently_due: lang, past_due: lang, disabled_reason: 'r'.repeat(500) } }) assert.ok(aus.currently_due.length <= 30 && aus.past_due.length <= 30) assert.ok([...aus.currently_due, ...aus.past_due].every((e) => e.length <= 100)) assert.ok(aus.disabled_reason.length <= 100) assert.ok(JSON.stringify(aus).length <= 3500, `JSON ${JSON.stringify(aus).length} Zeichen`) assert.ok(aus.currently_due.length > 0 && aus.past_due.length > 0, 'beide Listen behalten Eintraege') // kurze Listen bleiben unveraendert, Nicht-Texte werden uebersprungen assert.deepEqual(connect.statusAusKonto({ requirements: { currently_due: ['a', 5, null, 'b'] } }).currently_due, ['a', 'b']) }) 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') })