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) })