From 54defb75c46809b529e5952f305c0c7874403d95 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 15:30:56 +0000 Subject: [PATCH] Tests: Stripe- und Appwrite-Nachbau im Testprozess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getStripe() nutzt in Tests über STRIPE_API_BASE den Nachbau auf 127.0.0.1; in Produktion ist der Wert leer und nichts ändert sich. Co-Authored-By: Claude Opus 5.5 --- server/config.js | 2 + server/services/stripeBilling.js | 8 +- test/support.test.js | 45 +++++++++++ test/support/appwriteMock.js | 133 +++++++++++++++++++++++++++++++ test/support/fakeStripe.js | 65 +++++++++++++++ test/support/testEnv.js | 19 +++++ 6 files changed, 271 insertions(+), 1 deletion(-) create mode 100644 test/support.test.js create mode 100644 test/support/appwriteMock.js create mode 100644 test/support/fakeStripe.js create mode 100644 test/support/testEnv.js diff --git a/server/config.js b/server/config.js index 953b7f5..dcd2524 100644 --- a/server/config.js +++ b/server/config.js @@ -106,6 +106,8 @@ export const config = { stripe: { secretKey: process.env.STRIPE_SECRET_KEY || '', webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '', + // Nur für Tests: Stripe-Nachbau statt api.stripe.com (z. B. http://127.0.0.1:12111) + apiBase: process.env.STRIPE_API_BASE || '', priceLookupKey: products.wartung.lookupKey, priceAmountCents: products.wartung.amountCents, productName: products.wartung.name, diff --git a/server/services/stripeBilling.js b/server/services/stripeBilling.js index f62fc0c..f2cd682 100644 --- a/server/services/stripeBilling.js +++ b/server/services/stripeBilling.js @@ -30,7 +30,13 @@ export function getStripe() { throw err } if (!stripeClient) { - stripeClient = new Stripe(config.stripe.secretKey, { maxNetworkRetries: 2 }) + const options = { maxNetworkRetries: 2 } + // Tests: Stripe-Nachbau auf 127.0.0.1 (config.stripe.apiBase ist in Produktion leer) + if (config.stripe.apiBase) { + const base = new URL(config.stripe.apiBase) + Object.assign(options, { host: base.hostname, port: Number(base.port), protocol: base.protocol.replace(':', '') }) + } + stripeClient = new Stripe(config.stripe.secretKey, options) } return stripeClient } diff --git a/test/support.test.js b/test/support.test.js new file mode 100644 index 0000000..9ba00af --- /dev/null +++ b/test/support.test.js @@ -0,0 +1,45 @@ +import { test, after } 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' + +// Nachbauten starten und die Umgebung setzen, BEVOR config.js geladen wird +const aw = await startAppwriteMock({ schema: { subscriptions: ['customerId', 'status'] } }) +const st = await startFakeStripe() +after(() => Promise.all([aw.close(), st.close()])) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { createDocument, getDocument, listDocuments, Query } = await import('../server/services/appwriteAdmin.js') +const { getStripe } = await import('../server/services/stripeBilling.js') + +test('Appwrite-Nachbau: anlegen, lesen, filtern', async () => { + await createDocument('subscriptions', { customerId: 'c1', status: 'active' }, 'sub_a') + await createDocument('subscriptions', { customerId: 'c2', status: 'active' }, 'sub_c') + assert.equal((await getDocument('subscriptions', 'sub_a')).status, 'active') + const list = await listDocuments('subscriptions', [Query.equal('customerId', 'c1'), Query.limit(5)]) + assert.deepEqual(list.map((d) => d.$id), ['sub_a']) +}) + +test('Appwrite-Nachbau: doppelte ID 409, fehlendes Dokument 404', async () => { + await assert.rejects(createDocument('subscriptions', { customerId: 'c1' }, 'sub_a'), (e) => e.status === 409) + await assert.rejects(getDocument('subscriptions', 'gibt_es_nicht'), (e) => e.status === 404) +}) + +test('Appwrite-Nachbau: Schema wie Appwrite', async () => { + await assert.rejects(createDocument('subscriptions', { unbekannt: 1 }, 'sub_b'), (e) => e.status === 400) +}) + +test('Stripe-Nachbau: getStripe spricht mit 127.0.0.1', async () => { + st.subscriptions.set('sub_1', { id: 'sub_1', object: 'subscription', items: { object: 'list', data: [] } }) + assert.equal((await getStripe().subscriptions.retrieve('sub_1')).id, 'sub_1') + assert.equal(st.calls.at(-1).path, '/v1/subscriptions/sub_1') +}) + +test('Stripe-Nachbau: unbekannte ID 404', async () => { + await assert.rejects(getStripe().prices.retrieve('price_weg'), (e) => e.statusCode === 404) +}) + +test('useTestEnv lehnt fremde Hosts ab', () => { + assert.throws(() => useTestEnv({ appwriteUrl: 'https://ticket.webklar.com/v1', stripeUrl: st.url })) + assert.equal(new URL(process.env.APPWRITE_ENDPOINT).hostname, '127.0.0.1') +}) diff --git a/test/support/appwriteMock.js b/test/support/appwriteMock.js new file mode 100644 index 0000000..723b523 --- /dev/null +++ b/test/support/appwriteMock.js @@ -0,0 +1,133 @@ +import http from 'node:http' + +/** + * Appwrite-Nachbau für Tests: die REST-Pfade, die server/services/appwriteAdmin.js benutzt, im + * Speicher. Mit schema = { collection: ['attr', ...] } lehnt er unbekannte Attribute ab wie + * Appwrite ("Invalid document structure: Unknown attribute"). + */ +export async function startAppwriteMock({ schema = {} } = {}) { + const collections = new Map() + let sequence = 0 + + const docs = (collection) => { + if (!collections.has(collection)) collections.set(collection, new Map()) + return collections.get(collection) + } + + const stamp = (doc, collection) => { + const now = new Date().toISOString() + return { $createdAt: now, $updatedAt: now, $collectionId: collection, ...doc, $seq: (sequence += 1) } + } + + const seed = (collection, doc) => { + if (!doc?.$id) throw new Error('seed: $id fehlt') + docs(collection).set(doc.$id, stamp(doc, collection)) + } + + const unknownAttribute = (collection, data) => { + const allowed = schema[collection] + if (!allowed) return null + return Object.keys(data).find((key) => !key.startsWith('$') && !allowed.includes(key)) || null + } + + const matches = (doc, query) => { + const value = doc[query.attribute] + switch (query.method) { + case 'equal': + return query.values.includes(value) + case 'greaterThan': + return value > query.values[0] + case 'lessThan': + return value < query.values[0] + default: + return true + } + } + + const list = (collection, rawQueries) => { + const queries = rawQueries.map((q) => JSON.parse(q)) + const unsupported = queries.find((q) => !['equal', 'greaterThan', 'lessThan', 'orderAsc', 'orderDesc', 'limit'].includes(q.method)) + if (unsupported) return [400, { message: `Query ${unsupported.method} wird nicht nachgebaut`, code: 400 }] + let result = [...docs(collection).values()].filter((doc) => queries.every((q) => matches(doc, q))) + for (const q of queries.filter((q) => q.method === 'orderAsc' || q.method === 'orderDesc')) { + const dir = q.method === 'orderAsc' ? 1 : -1 + result.sort((a, b) => { + const x = a[q.attribute] + const y = b[q.attribute] + if (x === y) return (a.$seq - b.$seq) * dir + return (x > y ? 1 : -1) * dir + }) + } + const limit = queries.find((q) => q.method === 'limit') + if (limit) result = result.slice(0, limit.values[0]) + return [200, { total: result.length, documents: result.map(publicDoc) }] + } + + const publicDoc = ({ $seq, ...doc }) => doc + + const readBody = (req) => + new Promise((resolve) => { + let raw = '' + req.on('data', (chunk) => (raw += chunk)) + req.on('end', () => resolve(raw ? JSON.parse(raw) : {})) + }) + + const handle = async (req) => { + const url = new URL(req.url, 'http://127.0.0.1') + const docPath = url.pathname.match(/^\/v1\/databases\/[^/]+\/collections\/([^/]+)\/documents(?:\/([^/]+))?$/) + const userPath = url.pathname.match(/^\/v1\/users\/([^/]+)$/) + if (userPath && req.method === 'GET') return [200, { $id: userPath[1], passwordUpdate: '' }] + if (!docPath) return [404, { message: 'Route not found', code: 404 }] + + const [, collection, id] = docPath + const store = docs(collection) + const notFound = [404, { message: 'Document with the requested ID could not be found.', code: 404, type: 'document_not_found' }] + + if (!id && req.method === 'GET') return list(collection, url.searchParams.getAll('queries[]')) + if (!id && req.method === 'POST') { + const { documentId, data = {} } = await readBody(req) + if (store.has(documentId)) { + return [409, { message: 'Document with the requested ID already exists.', code: 409, type: 'document_already_exists' }] + } + const bad = unknownAttribute(collection, data) + if (bad) return [400, { message: `Invalid document structure: Unknown attribute: "${bad}"`, code: 400 }] + const doc = stamp({ ...data, $id: documentId }, collection) + store.set(documentId, doc) + return [201, publicDoc(doc)] + } + if (id && req.method === 'GET') return store.has(id) ? [200, publicDoc(store.get(id))] : notFound + if (id && req.method === 'PATCH') { + if (!store.has(id)) return notFound + const { data = {} } = await readBody(req) + const bad = unknownAttribute(collection, data) + if (bad) return [400, { message: `Invalid document structure: Unknown attribute: "${bad}"`, code: 400 }] + const doc = { ...store.get(id), ...data, $updatedAt: new Date().toISOString() } + store.set(id, doc) + return [200, publicDoc(doc)] + } + if (id && req.method === 'DELETE') { + if (!store.delete(id)) return notFound + return [204, null] + } + return [405, { message: 'Method not allowed', code: 405 }] + } + + const server = http.createServer(async (req, res) => { + try { + const [status, body] = await handle(req) + res.writeHead(status, { 'Content-Type': 'application/json' }) + res.end(body === null ? '' : JSON.stringify(body)) + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ message: err.message, code: 500 })) + } + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + + return { + url: `http://127.0.0.1:${server.address().port}/v1`, + docs: (collection) => new Map([...docs(collection)].map(([id, doc]) => [id, publicDoc(doc)])), + seed, + close: () => new Promise((resolve) => server.close(resolve)), + } +} diff --git a/test/support/fakeStripe.js b/test/support/fakeStripe.js new file mode 100644 index 0000000..26f6a9a --- /dev/null +++ b/test/support/fakeStripe.js @@ -0,0 +1,65 @@ +import http from 'node:http' + +/** + * Stripe-Nachbau für Tests. stripe-node spricht ihn über config.stripe.apiBase (STRIPE_API_BASE) + * an. Objekte werden vorab in die Maps gelegt; jeder Aufruf landet in calls als + * { method, path, query, body } (Formular-Schlüssel flach, z. B. 'items[0][price]'). + */ +export async function startFakeStripe() { + const subscriptions = new Map() + const prices = new Map() + const invoices = new Map() + const calls = [] + + const flat = (params) => Object.fromEntries(params.entries()) + const noSuch = (what, id) => [404, { error: { type: 'invalid_request_error', code: 'resource_missing', message: `No such ${what}: '${id}'` } }] + const listOf = (data) => ({ object: 'list', data, has_more: false, url: '/v1/list' }) + + const readBody = (req) => + new Promise((resolve) => { + let raw = '' + req.on('data', (chunk) => (raw += chunk)) + req.on('end', () => resolve(new URLSearchParams(raw))) + }) + + const route = (method, path, query) => { + let m + if (method === 'GET' && (m = path.match(/^\/v1\/subscriptions\/([^/]+)$/))) { + return subscriptions.has(m[1]) ? [200, subscriptions.get(m[1])] : noSuch('subscription', m[1]) + } + if (method === 'GET' && (m = path.match(/^\/v1\/prices\/([^/]+)$/))) { + return prices.has(m[1]) ? [200, prices.get(m[1])] : noSuch('price', m[1]) + } + if (method === 'GET' && path === '/v1/prices') { + const keys = [...query.entries()].filter(([k]) => /^lookup_keys\[\d*\]$/.test(k)).map(([, v]) => v) + const active = query.get('active') + const found = [...prices.values()].filter( + (p) => (!keys.length || keys.includes(p.lookup_key)) && (active === null || String(p.active !== false) === active) + ) + return [200, listOf(found)] + } + if (method === 'GET' && (m = path.match(/^\/v1\/invoices\/([^/]+)$/))) { + return invoices.has(m[1]) ? [200, invoices.get(m[1])] : noSuch('invoice', m[1]) + } + return [404, { error: { type: 'invalid_request_error', message: `No such route: ${method} ${path}` } }] + } + + const server = http.createServer(async (req, res) => { + const url = new URL(req.url, 'http://127.0.0.1') + const body = await readBody(req) + calls.push({ method: req.method, path: url.pathname, query: flat(url.searchParams), body: flat(body) }) + const [status, payload] = route(req.method, url.pathname, url.searchParams) + res.writeHead(status, { 'Content-Type': 'application/json', 'Request-Id': `req_test_${calls.length}` }) + res.end(JSON.stringify(payload)) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + + return { + url: `http://127.0.0.1:${server.address().port}`, + calls, + subscriptions, + prices, + invoices, + close: () => new Promise((resolve) => server.close(resolve)), + } +} diff --git a/test/support/testEnv.js b/test/support/testEnv.js new file mode 100644 index 0000000..f6159b8 --- /dev/null +++ b/test/support/testEnv.js @@ -0,0 +1,19 @@ +/** + * Umgebung für Tests mit den Nachbauten. Muss vor dem ersten Import von server/config.js laufen. + * Verweigert jede Adresse außerhalb von 127.0.0.1, damit kein Test Produktion erreicht. + */ +export function useTestEnv({ appwriteUrl, stripeUrl }) { + for (const url of [appwriteUrl, stripeUrl]) { + if (new URL(url).hostname !== '127.0.0.1') throw new Error(`Testumgebung: nur 127.0.0.1 erlaubt, nicht ${url}`) + } + Object.assign(process.env, { + APPWRITE_ENDPOINT: appwriteUrl, + APPWRITE_API_KEY: 'test-key', + STRIPE_SECRET_KEY: 'sk_test_fake', + STRIPE_API_BASE: stripeUrl, + STRIPE_WEBHOOK_SECRET: 'whsec_test', + SMTP_HOST: '', + SESSION_SECRET: 'test-session-secret-mit-mindestens-32-zeichen', + ABO_PILOT_EMAILS: '*', + }) +}