Abo-API: Nachweis-ID nach Appwrite-Regeln, Webhook bei dauerhaften Fehlern, Tests

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Fable 5.1 committed 2026-09-28 10:19:05 +00:00
1 parent 7df0b72019
commit 887e1e54a0
10 files changed
+409 -107

No files matched your search

+32 -1
View File
@@ -3,11 +3,26 @@ 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").
* Appwrite ("Invalid document structure: Unknown attribute"). Dokument-IDs prüft er wie Appwrite
* (höchstens 36 Zeichen aus a-z, A-Z, 0-9, Punkt, Bindestrich, Unterstrich, kein Sonderzeichen am
* Anfang). failNext({ collection?, method?, status, message? }) lässt die nächste passende Anfrage
* mit diesem Status scheitern (Fehlerpfade wie "Appwrite nicht erreichbar" oder "Collection fehlt").
*/
export async function startAppwriteMock({ schema = {} } = {}) {
const collections = new Map()
let sequence = 0
let pendingFailure = null
// Wie Appwrite (Validator UID): 'unique()' ist erlaubt, sonst [A-Za-z0-9][A-Za-z0-9._-]{0,35}
const validDocumentId = (id) => id === 'unique()' || (typeof id === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/.test(id))
const invalidDocumentId = [
400,
{
message: 'Invalid `documentId` param: UID must contain at most 36 chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char',
code: 400,
type: 'general_argument_invalid',
},
]
const docs = (collection) => {
if (!collections.has(collection)) collections.set(collection, new Map())
@@ -83,9 +98,21 @@ export async function startAppwriteMock({ schema = {} } = {}) {
const store = docs(collection)
const notFound = [404, { message: 'Document with the requested ID could not be found.', code: 404, type: 'document_not_found' }]
// Eingeplanter Fehler (failNext): genau eine passende Anfrage scheitert
if (
pendingFailure &&
(!pendingFailure.collection || pendingFailure.collection === collection) &&
(!pendingFailure.method || pendingFailure.method === req.method)
) {
const { status, message } = pendingFailure
pendingFailure = null
return [status, { message: message || `Nachbau: eingeplanter Fehler ${status}`, code: status, type: 'general_server_error' }]
}
if (!id && req.method === 'GET') return list(collection, url.searchParams.getAll('queries[]'))
if (!id && req.method === 'POST') {
const { documentId, data = {} } = await readBody(req)
if (!validDocumentId(documentId)) return invalidDocumentId
if (store.has(documentId)) {
return [409, { message: 'Document with the requested ID already exists.', code: 409, type: 'document_already_exists' }]
}
@@ -128,6 +155,10 @@ export async function startAppwriteMock({ schema = {} } = {}) {
url: `http://127.0.0.1:${server.address().port}/v1`,
docs: (collection) => new Map([...docs(collection)].map(([id, doc]) => [id, publicDoc(doc)])),
seed,
/** Naechste passende Dokument-Anfrage scheitert mit status (einmalig). */
failNext: ({ collection = null, method = null, status = 500, message = '' }) => {
pendingFailure = { collection, method, status, message }
},
close: () => new Promise((resolve) => server.close(resolve)),
}
}
+41 -20
View File
@@ -1,11 +1,12 @@
// testApp.js zuerst: es prueft beim Laden, dass useTestEnv() gelaufen ist, bevor config.js die Umgebung liest
// testApp.js zuerst: es prueft beim Laden, dass useTestEnv() vor config.js gelaufen ist
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,
* Feste Testdaten für die Routen-Tests (Aufgaben 7–13): Kunden c1–c4, 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).
* (Neuabschluss-Tests), c4 ist ein Testkonto (ABO_TEST_EMAILS=test@example.invalid im Test setzen).
* Erst nach useTestEnv() dynamisch importieren (siehe testApp.js).
*/
const DAY_MS = 86400000
@@ -15,18 +16,20 @@ 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 },
c4: { $id: 'c4', appwriteUserId: 'u4', email: 'test@example.invalid', name: 'Testkonto', status: 'active', portalAccessEnabled: true },
}
/** Abo-Dokument mit id als Dokument-ID und Stripe-Abo-ID (in Produktion immer gleich). */
export const subscriptionDoc = (id, overrides = {}) => ({ ...overrides, $id: id, stripeSubscriptionId: id })
// 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',
export const hostingDoc = subscriptionDoc('sub_h', {
customerId: 'c1',
email: 'k@example.invalid',
stripeCustomerId: 'cus_c1',
stripeSubscriptionId: 'sub_h',
status: 'active',
productKey: 'hosting',
planKey: config.products.hosting.lookupKey,
@@ -49,27 +52,23 @@ export const hostingDoc = {
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 = {
/** Wartungs-Abo sub_w von c1 (im Test meist per subscriptionDoc('sub_h', { ...wartungDoc }) eingespielt). */
export const wartungDoc = subscriptionDoc('sub_w', {
...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 = {
export const hosting2Doc = subscriptionDoc('sub_h2', {
...hostingDoc,
$id: 'sub_h2',
customerId: 'c2',
email: 'k2@example.invalid',
stripeCustomerId: 'cus_c2',
stripeSubscriptionId: 'sub_h2',
}
})
const consentAt = new Date().toISOString()
@@ -90,7 +89,21 @@ export const customerCookie = (customerId = 'c1') =>
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. */
/** Admin im Modus "Als Kunde ansehen" (portalAdmin.js /view-as): Bestellen und Aendern sind gesperrt. */
export const viewAsCookie = (customerId = 'c1') =>
sessionCookie({
role: 'admin',
viewAs: true,
isAdmin: true,
appwriteUserId: 'u_admin',
adminName: 'Kenso',
adminEmail: 'kenso@webklar.com',
customerId,
name: customers[customerId]?.name || '',
email: customers[customerId]?.email || '',
})
/** Kunden c1–c4 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)
@@ -101,10 +114,18 @@ 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, ...).
* dazu ein Zusatzspeicher-Posten, wenn storageUnits > 0. Die Standard-ID ist 28 Zeichen lang wie
* bei Stripe (sub_ + 24); fuer den Spiegel von sub_h explizit id: 'sub_h' uebergeben. 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 } = {}) {
export function stripeSubscription({
id = 'sub_1SD6vPRxAbCdEfGhIjKlMnOp',
customerId = 'c1',
productKey = 'hosting',
storageUnits = 0,
metadata = {},
...rest
} = {}) {
const product = config.products[productKey]
const items = [
{
+18 -15
View File
@@ -1,44 +1,47 @@
import http from 'node:http'
import express from 'express'
import cookieParser from 'cookie-parser'
import signature from 'cookie-signature'
import Stripe from 'stripe'
import { config } from '../../server/config.js'
import { sessionMiddleware } from '../../server/middleware/session.js'
/**
* 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:
* Express-App für Routen-Tests: wie server/index.js mit sessionMiddleware() (cookieParser mit
* 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.
* dynamisch importieren. config.js liest die Umgebung beim ersten Import; zeigt sie nicht auf die
* Nachbauten, weigert sich dieses Modul beim Laden. Die Anfrage-Helfer sprechen die zuletzt
* gestartete App an.
*/
function assertTestEnv() {
for (const name of ['APPWRITE_ENDPOINT', 'STRIPE_API_BASE']) {
for (const [name, value] of [
['APPWRITE_ENDPOINT', config.appwrite.endpoint],
['STRIPE_API_BASE', config.stripe.apiBase],
]) {
let host = null
try {
host = new URL(process.env[name] || '').hostname
host = new URL(value || '').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`)
throw new Error(`testApp: ${name} zeigt nicht auf 127.0.0.1 – useTestEnv() muss vor dem ersten Import von config.js laufen`)
}
}
}
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)}`
const value = `s:${signature.sign(JSON.stringify(session), config.sessionSecret)}`
return `${config.cookieName}=${encodeURIComponent(value)}`
}
let baseUrl = null
@@ -47,7 +50,7 @@ let baseUrl = null
export async function startTestApp(routers = {}) {
const app = express()
app.set('trust proxy', 1)
app.use(cookieParser(sessionSecret()))
app.use(sessionMiddleware())
app.use(
express.json({
limit: '2mb',
@@ -110,7 +113,7 @@ export const post = (path, body, opts = {}) => request('POST', path, { ...opts,
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' } = {}) {
export function postWebhook(event, { path = '/api/abo/webhook', secret = config.stripe.webhookSecret || 'whsec_test' } = {}) {
const payload = JSON.stringify(event)
const stripeSignature = webhookSigner.webhooks.generateTestHeaderString({ payload, secret })
return request('POST', path, { body: payload, headers: { 'stripe-signature': stripeSignature } })