Abo-API: Bestellung je Produkt, Katalog, Speicherstand, Wechsel-Möglichkeit
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
05363d06ad
commit
c4607e9c43
6 files changed
+784
-40
No files matched your search
@@ -12,6 +12,8 @@ export async function startFakeStripe() {
|
||||
const products = new Map()
|
||||
const coupons = new Map()
|
||||
const billingPortalConfigurations = new Map()
|
||||
const customers = new Map()
|
||||
const checkoutSessions = new Map()
|
||||
const calls = []
|
||||
let seq = 0
|
||||
const nextId = (prefix) => `${prefix}_test_${(seq += 1)}`
|
||||
@@ -115,6 +117,44 @@ export async function startFakeStripe() {
|
||||
coupons.set(id, obj)
|
||||
return [200, obj]
|
||||
}
|
||||
// Checkout (Neuabschluss): Kunde suchen/anlegen, Checkout-Sitzung anlegen (F10)
|
||||
if (method === 'GET' && path === '/v1/customers/search') {
|
||||
// Suchsyntax wie in getOrCreateStripeCustomer: metadata['webklarCustomerId']:'c1'
|
||||
const m2 = (query.get('query') || '').match(/metadata\['(\w+)'\]:'([^']*)'/)
|
||||
const found = [...customers.values()].filter((c) => !m2 || c.metadata?.[m2[1]] === m2[2])
|
||||
return [200, { object: 'search_result', data: found, has_more: false, url: '/v1/customers/search' }]
|
||||
}
|
||||
if (method === 'POST' && path === '/v1/customers') {
|
||||
const data = unflatten(body)
|
||||
const obj = {
|
||||
id: nextId('cus'),
|
||||
object: 'customer',
|
||||
email: data.email ?? null,
|
||||
name: data.name ?? null,
|
||||
address: data.address ?? null,
|
||||
invoice_settings: data.invoice_settings || {},
|
||||
metadata: data.metadata || {},
|
||||
}
|
||||
customers.set(obj.id, obj)
|
||||
return [200, obj]
|
||||
}
|
||||
if (method === 'POST' && path === '/v1/checkout/sessions') {
|
||||
const data = unflatten(body)
|
||||
const id = nextId('cs')
|
||||
const obj = {
|
||||
id,
|
||||
object: 'checkout.session',
|
||||
url: `https://checkout.stripe.test/c/pay/${id}`,
|
||||
mode: data.mode || 'payment',
|
||||
customer: data.customer ?? null,
|
||||
client_reference_id: data.client_reference_id ?? null,
|
||||
subscription: null,
|
||||
metadata: data.metadata || {},
|
||||
subscription_data: data.subscription_data || {},
|
||||
}
|
||||
checkoutSessions.set(id, obj)
|
||||
return [200, obj]
|
||||
}
|
||||
if (method === 'GET' && path === '/v1/billing_portal/configurations') {
|
||||
const active = query.get('active')
|
||||
const found = [...billingPortalConfigurations.values()].filter(
|
||||
@@ -172,6 +212,8 @@ export async function startFakeStripe() {
|
||||
products,
|
||||
coupons,
|
||||
billingPortalConfigurations,
|
||||
customers,
|
||||
checkoutSessions,
|
||||
close: () => new Promise((resolve) => server.close(resolve)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// testApp.js zuerst: es prueft beim Laden, dass useTestEnv() gelaufen ist, bevor config.js die Umgebung liest
|
||||
import { sessionCookie } from './testApp.js'
|
||||
import { config } from '../../server/config.js'
|
||||
|
||||
/**
|
||||
* Feste Testdaten für die Routen-Tests (Aufgaben 7–13): Kunden c1–c3, Abo-Dokumente, Zustimmung,
|
||||
* Session-Cookies. seedFixtures(aw) legt Kunden und Abos in den Appwrite-Nachbau; c3 hat kein Abo
|
||||
* (Neuabschluss-Tests). Erst nach useTestEnv() dynamisch importieren (siehe testApp.js).
|
||||
*/
|
||||
|
||||
const DAY_MS = 86400000
|
||||
const daysFromNow = (days) => new Date(Date.now() + days * DAY_MS).toISOString()
|
||||
|
||||
export const customers = {
|
||||
c1: { $id: 'c1', appwriteUserId: 'u1', email: 'k@example.invalid', name: 'Kunde Eins', status: 'active', portalAccessEnabled: true },
|
||||
c2: { $id: 'c2', appwriteUserId: 'u2', email: 'k2@example.invalid', name: 'Kunde Zwei', status: 'active', portalAccessEnabled: true },
|
||||
c3: { $id: 'c3', appwriteUserId: 'u3', email: 'k3@example.invalid', name: 'Kunde Drei', status: 'active', portalAccessEnabled: true },
|
||||
}
|
||||
|
||||
// Abo seit 40 Tagen (Widerrufsfrist des Abschlusses vorbei), naechste Abbuchung in 20 Tagen
|
||||
const startedAt = daysFromNow(-40)
|
||||
|
||||
/** Hosting-Abo sub_h von c1: aktiv, Fassung 4, ohne Zusatzspeicher. */
|
||||
export const hostingDoc = {
|
||||
$id: 'sub_h',
|
||||
customerId: 'c1',
|
||||
email: 'k@example.invalid',
|
||||
stripeCustomerId: 'cus_c1',
|
||||
stripeSubscriptionId: 'sub_h',
|
||||
status: 'active',
|
||||
productKey: 'hosting',
|
||||
planKey: config.products.hosting.lookupKey,
|
||||
priceAmount: config.products.hosting.amountCents,
|
||||
startedAt,
|
||||
termStartAt: startedAt,
|
||||
currentPeriodEnd: daysFromNow(20),
|
||||
trialEnd: null,
|
||||
cancelAtPeriodEnd: false,
|
||||
cancelAt: null,
|
||||
canceledAt: null,
|
||||
endedAt: null,
|
||||
termsVersion: config.abo.termsVersion,
|
||||
termsAcceptedAt: startedAt,
|
||||
earlyStartConsentAt: startedAt,
|
||||
startChoice: 'sofort',
|
||||
consentIp: '127.0.0.1',
|
||||
consentUserAgent: 'test',
|
||||
consentTextVersion: config.abo.termsVersion,
|
||||
storageUnits: 0,
|
||||
storagePaidUnits: 0,
|
||||
storagePaidPeriodEnd: null,
|
||||
}
|
||||
|
||||
/** Wartungs-Abo sub_w von c1 (im Test meist als sub_h eingespielt, damit c1 nur ein Abo hat). */
|
||||
export const wartungDoc = {
|
||||
...hostingDoc,
|
||||
$id: 'sub_w',
|
||||
stripeSubscriptionId: 'sub_w',
|
||||
productKey: 'wartung',
|
||||
planKey: config.products.wartung.lookupKey,
|
||||
priceAmount: config.products.wartung.amountCents,
|
||||
}
|
||||
|
||||
/** Zweites Hosting-Abo sub_h2 von c2. */
|
||||
export const hosting2Doc = {
|
||||
...hostingDoc,
|
||||
$id: 'sub_h2',
|
||||
customerId: 'c2',
|
||||
email: 'k2@example.invalid',
|
||||
stripeCustomerId: 'cus_c2',
|
||||
stripeSubscriptionId: 'sub_h2',
|
||||
}
|
||||
|
||||
const consentAt = new Date().toISOString()
|
||||
|
||||
/** Zustimmung im Portal (Bestellung, Wechsel, Speicher-Plus); earlyStartConsentAt fuer den Wechsel (F16). */
|
||||
export const consent = {
|
||||
termsVersion: config.abo.termsVersion,
|
||||
consentAt,
|
||||
consentIp: '127.0.0.1',
|
||||
consentUserAgent: 'test',
|
||||
earlyStartConsentAt: consentAt,
|
||||
}
|
||||
|
||||
/** Kunden-Session; Standard c1. */
|
||||
export const customerCookie = (customerId = 'c1') =>
|
||||
sessionCookie({ customerId, appwriteUserId: customers[customerId]?.appwriteUserId || `u_${customerId}` })
|
||||
|
||||
/** Admin-Session (Ticketsystem-Mitarbeiter im Portal), F11. */
|
||||
export const adminCookie = () =>
|
||||
sessionCookie({ role: 'admin', appwriteUserId: 'u_admin', name: 'Kenso', email: 'kenso@webklar.com' })
|
||||
|
||||
/** Kunden c1–c3 und die Abos sub_h (c1) und sub_h2 (c2) in den Appwrite-Nachbau legen. */
|
||||
export function seedFixtures(aw) {
|
||||
for (const customer of Object.values(customers)) aw.seed('customers', customer)
|
||||
aw.seed('subscriptions', hostingDoc)
|
||||
aw.seed('subscriptions', hosting2Doc)
|
||||
}
|
||||
|
||||
const unix = (iso) => Math.floor(new Date(iso).getTime() / 1000)
|
||||
|
||||
/**
|
||||
* Stripe-Abo in der Form der SDK-API-Version für den Stripe-Nachbau: Abo-Posten je Produkt,
|
||||
* dazu ein Zusatzspeicher-Posten, wenn storageUnits > 0. metadata ergaenzt/ueberschreibt die
|
||||
* Zustimmungsdaten, rest alles Weitere (status, cancel_at, ...).
|
||||
*/
|
||||
export function stripeSubscription({ id = 'sub_h', customerId = 'c1', productKey = 'hosting', storageUnits = 0, metadata = {}, ...rest } = {}) {
|
||||
const product = config.products[productKey]
|
||||
const items = [
|
||||
{
|
||||
id: `si_${id}_abo`,
|
||||
object: 'subscription_item',
|
||||
quantity: 1,
|
||||
price: { id: `price_${productKey}`, object: 'price', lookup_key: product.lookupKey, unit_amount: product.amountCents },
|
||||
},
|
||||
]
|
||||
if (storageUnits > 0) {
|
||||
items.push({
|
||||
id: `si_${id}_speicher`,
|
||||
object: 'subscription_item',
|
||||
quantity: storageUnits,
|
||||
price: { id: 'price_mailStorage', object: 'price', lookup_key: config.mailStorage.lookupKey, unit_amount: config.mailStorage.amountCents },
|
||||
})
|
||||
}
|
||||
return {
|
||||
id,
|
||||
object: 'subscription',
|
||||
status: 'active',
|
||||
customer: `cus_${customerId}`,
|
||||
start_date: unix(hostingDoc.startedAt),
|
||||
current_period_end: unix(hostingDoc.currentPeriodEnd),
|
||||
trial_end: null,
|
||||
cancel_at_period_end: false,
|
||||
cancel_at: null,
|
||||
canceled_at: null,
|
||||
ended_at: null,
|
||||
metadata: {
|
||||
webklarCustomerId: customerId,
|
||||
email: customers[customerId]?.email || '',
|
||||
productKey,
|
||||
termsVersion: config.abo.termsVersion,
|
||||
consentTextVersion: config.abo.termsVersion,
|
||||
termsAcceptedAt: consentAt,
|
||||
startChoice: 'sofort',
|
||||
earlyStartConsentAt: consentAt,
|
||||
consentIp: '127.0.0.1',
|
||||
consentUserAgent: 'test',
|
||||
...metadata,
|
||||
},
|
||||
items: { object: 'list', data: items },
|
||||
...rest,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import http from 'node:http'
|
||||
import express from 'express'
|
||||
import cookieParser from 'cookie-parser'
|
||||
import signature from 'cookie-signature'
|
||||
import Stripe from 'stripe'
|
||||
|
||||
/**
|
||||
* Express-App für Routen-Tests: wie server/index.js mit cookieParser(SESSION_SECRET) und
|
||||
* express.json samt rawBody (Stripe-Webhook), aber nur mit den Routern, die der Test übergibt:
|
||||
* const app = await startTestApp({ '/api/abo': aboRoutes, '/api/portal-admin': portalAdminRoutes })
|
||||
*
|
||||
* Reihenfolge im Test: Nachbauten starten, useTestEnv(), dann dieses Modul und die Server-Module
|
||||
* dynamisch importieren (assertTestEnv() weigert sich sonst, weil config.js die Umgebung beim
|
||||
* ersten Import liest). Die Anfrage-Helfer sprechen die zuletzt gestartete App an.
|
||||
*/
|
||||
|
||||
function assertTestEnv() {
|
||||
for (const name of ['APPWRITE_ENDPOINT', 'STRIPE_API_BASE']) {
|
||||
let host = null
|
||||
try {
|
||||
host = new URL(process.env[name] || '').hostname
|
||||
} catch {
|
||||
host = null
|
||||
}
|
||||
if (host !== '127.0.0.1') {
|
||||
throw new Error(`testApp: ${name} zeigt nicht auf 127.0.0.1 – useTestEnv() vor dem Import aufrufen`)
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTestEnv()
|
||||
|
||||
const cookieName = () => process.env.SESSION_COOKIE_NAME || 'webklar_portal_session'
|
||||
const sessionSecret = () => process.env.SESSION_SECRET || ''
|
||||
|
||||
/**
|
||||
* Signierter Session-Cookie wie setPortalSession (cookie-parser: Praefix "s:" + cookie-signature).
|
||||
* Kunde: { customerId, appwriteUserId }. Admin: { role: 'admin', appwriteUserId, name, email }.
|
||||
*/
|
||||
export function sessionCookie(session) {
|
||||
const value = `s:${signature.sign(JSON.stringify(session), sessionSecret())}`
|
||||
return `${cookieName()}=${encodeURIComponent(value)}`
|
||||
}
|
||||
|
||||
let baseUrl = null
|
||||
|
||||
/** App mit den Routern (Pfad -> Router) auf 127.0.0.1 starten; liefert { url, close }. */
|
||||
export async function startTestApp(routers = {}) {
|
||||
const app = express()
|
||||
app.set('trust proxy', 1)
|
||||
app.use(cookieParser(sessionSecret()))
|
||||
app.use(
|
||||
express.json({
|
||||
limit: '2mb',
|
||||
verify: (req, _res, buf) => {
|
||||
req.rawBody = buf
|
||||
},
|
||||
})
|
||||
)
|
||||
for (const [mountPath, router] of Object.entries(routers)) app.use(mountPath, router)
|
||||
app.use((err, _req, res, _next) => {
|
||||
if (!res.headersSent) res.status(err.status || 500).json({ error: err.message || 'Interner Serverfehler' })
|
||||
})
|
||||
const server = http.createServer(app)
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
baseUrl = `http://127.0.0.1:${server.address().port}`
|
||||
return { url: baseUrl, close: () => new Promise((resolve) => server.close(resolve)) }
|
||||
}
|
||||
|
||||
async function request(method, path, { body, cookie, headers = {} } = {}) {
|
||||
if (!baseUrl) throw new Error('testApp: startTestApp() zuerst aufrufen')
|
||||
const res = await fetch(baseUrl + path, {
|
||||
method,
|
||||
headers: {
|
||||
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
||||
...(cookie ? { cookie } : {}),
|
||||
...headers,
|
||||
},
|
||||
body: body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body),
|
||||
redirect: 'manual',
|
||||
})
|
||||
const text = await res.text()
|
||||
let parsed = null
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null
|
||||
} catch {
|
||||
parsed = text
|
||||
}
|
||||
return { status: res.status, body: parsed, headers: res.headers }
|
||||
}
|
||||
|
||||
/** GET -> { status, body } */
|
||||
export const rawGet = (path, opts = {}) => request('GET', path, opts)
|
||||
|
||||
/** GET -> JSON-Body; ab Status 400 fliegt ein Fehler mit err.status und err.body. */
|
||||
export async function get(path, opts = {}) {
|
||||
const { status, body } = await rawGet(path, opts)
|
||||
if (status >= 400) {
|
||||
const err = new Error(`GET ${path} → ${status}: ${body?.error || JSON.stringify(body)}`)
|
||||
err.status = status
|
||||
err.body = body
|
||||
throw err
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
/** POST mit JSON-Body -> { status, body } */
|
||||
export const post = (path, body, opts = {}) => request('POST', path, { ...opts, body })
|
||||
|
||||
// Signiert wie Stripe (whsec_test aus useTestEnv); der Schluessel wird nie fuer Netzaufrufe benutzt
|
||||
const webhookSigner = new Stripe('sk_test_fake')
|
||||
|
||||
/** Stripe-Ereignis signiert an den Webhook schicken (F25) -> { status, body }. */
|
||||
export function postWebhook(event, { path = '/api/abo/webhook', secret = process.env.STRIPE_WEBHOOK_SECRET || 'whsec_test' } = {}) {
|
||||
const payload = JSON.stringify(event)
|
||||
const stripeSignature = webhookSigner.webhooks.generateTestHeaderString({ payload, secret })
|
||||
return request('POST', path, { body: payload, headers: { 'stripe-signature': stripeSignature } })
|
||||
}
|
||||
Reference in new issue
Block a user