Files
Webklar-Kundenbereich/test/shopStripeRoutes.test.js
ClaudeandClaude Opus 5.5 8814c4aef1 Webshop: fehlendes Stripe-Plattformprofil klar melden statt 'Stripe nicht erreichbar'
Lehnt Stripe die Kontoanlage ab, weil das Connect-Plattformprofil von WEBklar fehlt (oder Connect nicht aktiv ist),
bekommt der Kunde 503 'plattform_nicht_bereit' mit eigenem Du-Text, und das Log nennt die Ursache (ohne Stripe-Text).
Gefunden im Pilot 2026-10-06.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:58:20 +00:00

269 lines
15 KiB
JavaScript
Raw Permalink 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 } 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 { SHOP_DATETIMES } from '../server/services/shop/schema.js'
// Webshop D2: Stripe-Konto einrichten, neuer Onboarding-Link, Status aktualisieren (Fake-Stripe)
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
shopConfig: schemaKeys('shopConfig'),
shopProducts: schemaKeys('shopProducts'),
customerViewLog: schemaKeys('customerViewLog'),
},
datetimes: SHOP_DATETIMES,
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, send } = await import('./support/testApp.js')
const { customerCookie, kundenansichtCookie, viewAsCookie, seedFixtures, customers } = await import('./support/fixtures.js')
const { default: shopRoutes, resetShopRoutesForTests } = await import('../server/routes/shop.js')
const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js')
const sf = await import('../server/services/siteFeatures.js')
const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js')
const { leereCache } = await import('../server/services/shop/kontext.js')
const app = await startTestApp({ '/': kundenansichtGuard, '/api/shop': shopRoutes })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
seedFixtures(aw)
aw.seed('websiteProjects', { $id: 'pt2', customerId: 'c1', projectName: 'Zweiter Shop' })
aw.seed('websiteProjects', { $id: 'pt3', customerId: 'c2', projectName: 'Fremdes Projekt' })
// Je Test ein frisches Projekt: Kontoanlage ist je Projekt idempotent (Idempotency-Key), alte Konten wuerden sonst wiederkehren
let P = 'pt1'
let zaehler = 0
const cookie = customerCookie('c1')
const GEDULD = 'Stripe ist gerade nicht erreichbar. Bitte versuch es gleich noch einmal.'
const post = (pfad, body = { projectId: P }, opts = { cookie }) => send('POST', `/api/shop/stripe/${pfad}`, { ...opts, body })
const status = (projectId = P) => send('GET', `/api/shop/status?projectId=${projectId}`, { cookie })
const plattform = () => st.calls.filter((c) => c.path === '/v1/accounts' || c.path === '/v1/account_links' || c.path.startsWith('/v1/accounts/'))
async function aktiviere(projectId = P, state = 'active') {
const feature = await sf.ensureFeatureDoc('c1', projectId, 'shop')
await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields(state))
return sf.getFeatureDoc('c1', projectId, 'shop')
}
const kontenFuer = (projectId) => [...st.accounts.values()].filter((a) => a.metadata?.projectId === projectId)
const shopConfigDoc = (feature) => aw.docs('shopConfig').get(feature.$id)
beforeEach(async () => {
P = `pn${(zaehler += 1)}`
aw.seed('websiteProjects', { $id: P, customerId: 'c1', projectName: 'Töpferei', liveDomain: 'toepferei.example', archived: false })
config.shop.mode = 'pilot'
config.shop.pilotEmails = [customers.c1.email]
resetShopRoutesForTests()
for (const col of ['portalFeatures', 'shopConfig', 'shopProducts', 'customerViewLog']) {
for (const id of [...aw.docs(col).keys()]) await deleteDocument(config.collections[col], id)
}
st.calls.length = 0
leereCache()
})
test('einrichten: legt ein Konto an, speichert Konto-ID und Status, liefert den Onboarding-Link mit Rücksprung-Adressen', async () => {
const feature = await aktiviere()
const res = await post('einrichten')
assert.equal(res.status, 200)
assert.match(res.body.url, /^https:\/\/connect\.stripe\.test\/setup\/acct_/)
assert.deepEqual(Object.keys(res.body), ['url'])
const doc = shopConfigDoc(feature)
assert.match(doc.stripeAccountId, /^acct_/)
assert.equal(kontenFuer(P).length, 1)
const gespeichert = JSON.parse(doc.stripeStatus)
assert.equal(gespeichert.charges_enabled, false)
assert.deepEqual(gespeichert.currently_due, ['external_account'])
// Plattform-Aufrufe ohne Stripe-Account-Kopfzeile, Konto mit Kunden-E-Mail und Projekt in den Metadaten
assert.ok(plattform().length >= 3)
for (const c of plattform()) assert.equal(c.account, undefined, `${c.method} ${c.path} darf keine Stripe-Account-Kopfzeile tragen`)
const anlage = st.calls.find((c) => c.method === 'POST' && c.path === '/v1/accounts')
assert.equal(anlage.body.email, customers.c1.email)
assert.equal(anlage.body['metadata[projectId]'], P)
const link = st.calls.find((c) => c.path === '/v1/account_links')
assert.equal(link.body.account, doc.stripeAccountId)
assert.equal(link.body.return_url, `${config.publicBaseUrl}/dashboard.html#webshop?stripe=zurueck&projekt=${P}`)
assert.equal(link.body.refresh_url, `${config.publicBaseUrl}/dashboard.html#webshop?stripe=neu&projekt=${P}`)
// GET /status zeigt das Konto als verbunden, ohne die Konto-ID
const s = (await status()).body
assert.equal(s.stripe.verbunden, true)
assert.equal(JSON.stringify(s).includes(doc.stripeAccountId), false)
})
test('einrichten: zwei parallele Aufrufe (Doppelklick) legen nur ein Konto an', async () => {
const feature = await aktiviere(P, 'setup')
const [a, b] = await Promise.all([post('einrichten'), post('einrichten')])
assert.deepEqual([a.status, b.status], [200, 200])
assert.equal(st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/accounts').length, 1)
assert.equal(kontenFuer(P).length, 1)
assert.equal(st.calls.filter((c) => c.path === '/v1/account_links').length, 2)
assert.ok(a.body.url && b.body.url)
// weiterer Aufruf: Konto bleibt dasselbe
const id = shopConfigDoc(feature).stripeAccountId
assert.equal((await post('einrichten')).status, 200)
assert.equal(shopConfigDoc(feature).stripeAccountId, id)
assert.equal(kontenFuer(P).length, 1)
})
test('link: braucht ein Konto (409), liefert danach einen neuen Link mit den Rücksprung-Adressen', async () => {
await aktiviere()
const ohne = await post('link')
assert.deepEqual([ohne.status, ohne.body.error, ohne.body.code], [409, 'Es ist noch kein Stripe-Konto angelegt.', 'kein_konto'])
assert.equal((await post('einrichten')).status, 200)
st.calls.length = 0
const res = await post('link')
assert.equal(res.status, 200)
assert.match(res.body.url, /^https:\/\/connect\.stripe\.test\/setup\//)
assert.equal(st.calls.length, 1)
assert.equal(st.calls[0].account, undefined)
assert.equal(st.calls[0].body.return_url, `${config.publicBaseUrl}/dashboard.html#webshop?stripe=zurueck&projekt=${P}`)
assert.equal(kontenFuer(P).length, 1)
})
test('aktualisieren: braucht ein Konto (409), liest den Status neu und speichert ihn', async () => {
const feature = await aktiviere()
assert.equal((await post('aktualisieren')).status, 409)
await post('einrichten')
const id = shopConfigDoc(feature).stripeAccountId
st.setAccount(id, { charges_enabled: true, payouts_enabled: true, details_submitted: true, requirements: { currently_due: [] } })
const res = await post('aktualisieren')
assert.equal(res.status, 200)
assert.equal(res.body.stripe.verbunden, true)
assert.equal(res.body.stripe.status.charges_enabled, true)
assert.deepEqual(res.body.stripe.status.currently_due, [])
assert.equal(JSON.parse(shopConfigDoc(feature).stripeStatus).charges_enabled, true)
assert.equal(JSON.stringify(res.body).includes(id), false)
// gleiche Form wie GET /status
assert.deepEqual((await status()).body.stripe, res.body.stripe)
})
test('aktualisieren: Hinweis zu Klarna/SEPA bleibt nur, solange Zahlungen möglich sind und eine der Zahlungsarten gewählt ist', async () => {
const feature = await aktiviere()
await post('einrichten')
const id = shopConfigDoc(feature).stripeAccountId
const setze = async (stripeStatus, zahlungsarten) => {
const e = { zahlungsarten }
await updateDocument(config.collections.shopConfig, feature.$id, { stripeStatus: JSON.stringify(stripeStatus), einstellungen: JSON.stringify(e) })
leereCache()
}
st.setAccount(id, { charges_enabled: true, details_submitted: true, requirements: { currently_due: [] } })
await setze({ charges_enabled: true, hinweis: 'Klarna ist noch nicht freigeschaltet.' }, ['card', 'klarna'])
await post('aktualisieren')
assert.equal(JSON.parse(shopConfigDoc(feature).stripeStatus).hinweis, 'Klarna ist noch nicht freigeschaltet.')
await setze({ charges_enabled: true, hinweis: 'Klarna ist noch nicht freigeschaltet.' }, ['card'])
await post('aktualisieren')
assert.equal(JSON.parse(shopConfigDoc(feature).stripeStatus).hinweis, undefined)
st.setAccount(id, { charges_enabled: false })
await setze({ charges_enabled: true, hinweis: 'Klarna ist noch nicht freigeschaltet.' }, ['card', 'sepa'])
await post('aktualisieren')
assert.equal(JSON.parse(shopConfigDoc(feature).stripeStatus).hinweis, undefined)
})
test('Stripe-Fehler: 502 mit festem Du-Text, nie der Stripe-Text; bei gescheiterter Kontoanlage bleibt kein Konto gespeichert', async () => {
const feature = await aktiviere()
const log = []
const orig = console.error
console.error = (...a) => log.push(a.join(' '))
try {
st.failNext({ method: 'POST', path: '/v1/accounts', status: 400, message: 'Geheimer Stripe-Text sk_live_123' })
const a = await post('einrichten')
assert.deepEqual([a.status, a.body.error], [502, GEDULD])
assert.equal(JSON.stringify(a.body).includes('Geheim'), false)
assert.equal(shopConfigDoc(feature).stripeAccountId || '', '')
// Konto da, Link scheitert: Konto bleibt gespeichert, 502
st.failNext({ method: 'POST', path: '/v1/account_links', status: 400, message: 'Geheimer Linktext' })
const b = await post('einrichten')
assert.deepEqual([b.status, b.body.error], [502, GEDULD])
assert.match(shopConfigDoc(feature).stripeAccountId, /^acct_/)
st.failNext({ method: 'POST', path: '/v1/account_links', status: 400, message: 'Geheimer Linktext' })
assert.deepEqual([(await post('link')).status], [502])
st.failNext({ method: 'GET', path: /^\/v1\/accounts\//, status: 400, message: 'Geheimer Lesetext' })
const c = await post('aktualisieren')
assert.deepEqual([c.status, c.body.error], [502, GEDULD])
} finally {
console.error = orig
}
assert.equal(log.some((l) => l.includes('Geheim') || l.includes('sk_live')), false)
})
test('Plattformprofil fehlt (Connect nicht fertig): 503 mit eigenem Du-Text und Code, fester Logeintrag ohne Stripe-Text', async () => {
const feature = await aktiviere()
const log = []
const orig = console.error
console.error = (...a) => log.push(a.join(' '))
try {
for (const message of [
'You must complete your platform profile to use Connect and create live connected accounts. Visit your dashboard at https://dashboard.stripe.com/connect/accounts/overview to answer the questionnaire.',
"You can only create new accounts if you've signed up for Connect, which you can learn how to do at https://stripe.com/docs/connect.",
]) {
st.failNext({ method: 'POST', path: '/v1/accounts', status: 400, message })
const a = await post('einrichten')
assert.deepEqual([a.status, a.body.code, a.body.error], [503, 'plattform_nicht_bereit', 'Die Stripe-Anbindung ist bei WEBklar noch nicht fertig eingerichtet. Wir kümmern uns darum – schreib uns gern im Chat.'])
assert.equal(JSON.stringify(a.body).includes('dashboard.stripe.com'), false)
}
assert.equal(shopConfigDoc(feature).stripeAccountId || '', '')
} finally {
console.error = orig
}
assert.equal(log.filter((l) => l.includes('Plattformprofil')).length, 2)
assert.equal(log.some((l) => l.includes('questionnaire') || l.includes('stripe.com')), false)
})
test('Kundenansicht: der Wächter sperrt die Routen (nicht auf der Erlaubnisliste), der Router selbst auch ohne Wächter (Kundenansicht und Ansehen als)', async () => {
const feature = await aktiviere()
for (const pfad of ['einrichten', 'link', 'aktualisieren']) {
const res = await post(pfad, { projectId: P }, { cookie: kundenansichtCookie({ customerId: 'c1' }), headers: { 'X-Kundenansicht': 'a'.repeat(32) } })
assert.deepEqual([res.status, res.body.code], [403, 'view_blocked'], pfad)
}
const express = (await import('express')).default
const { sessionMiddleware } = await import('../server/middleware/session.js')
const roh = express()
roh.use(sessionMiddleware())
roh.use(express.json())
roh.use('/api/shop', shopRoutes)
const server = await new Promise((resolve) => {
const s = roh.listen(0, '127.0.0.1', () => resolve(s))
})
try {
const url = `http://127.0.0.1:${server.address().port}`
for (const pfad of ['einrichten', 'link', 'aktualisieren']) {
for (const c of [kundenansichtCookie({ customerId: 'c1' }), viewAsCookie('c1')]) {
const res = await fetch(`${url}/api/shop/stripe/${pfad}`, { method: 'POST', headers: { 'content-type': 'application/json', cookie: c }, body: JSON.stringify({ projectId: P }) })
assert.equal(res.status, 403, pfad)
assert.equal((await res.json()).error, 'In der Kundenansicht kann das Stripe-Konto nicht eingerichtet werden.')
}
}
} finally {
await new Promise((resolve) => server.close(resolve))
}
assert.equal(st.calls.length, 0)
assert.equal(shopConfigDoc(feature)?.stripeAccountId || '', '')
})
test('Zugang und Zustand: fremdes Projekt 403, ohne Projekt 400, ohne Session 401, nicht eingerichtet/deaktiviert/Schalter aus 409', async () => {
const feature = await aktiviere()
assert.equal((await post('einrichten', { projectId: 'pt3' })).status, 403)
assert.equal((await post('einrichten', {})).status, 400)
assert.equal((await send('POST', '/api/shop/stripe/einrichten', { body: { projectId: P } })).status, 401)
assert.equal((await post('einrichten', { projectId: 'pt2' })).status, 409)
await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields('disabled'))
const aus = await post('einrichten')
assert.deepEqual([aus.status, aus.body.code], [409, 'nicht_eingerichtet'])
await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields('off'))
assert.equal((await post('aktualisieren')).status, 409)
await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields('active'))
config.shop.mode = 'off'
const schalter = await post('link')
assert.deepEqual([schalter.status, schalter.body.code], [409, 'nicht_verfuegbar'])
assert.equal(st.calls.length, 0)
})
test('Rate-Limit: die 301. Schreibanfrage in der Stunde wird abgewiesen', async () => {
await aktiviere()
let letzter
for (let i = 0; i < 301; i += 1) letzter = await post('link')
assert.equal(letzter.status, 429)
})