Zusatzspeicher: Plus sofort anteilig, Minus zum Monatsende, Aufgaben für konsoleH
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
887e1e54a0
commit
379cc26dcd
11 files changed
+1531
-27
No files matched your search
@@ -5,8 +5,9 @@ import http from 'node:http'
|
||||
* Speicher. Mit schema = { collection: ['attr', ...] } lehnt er unbekannte Attribute ab wie
|
||||
* 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").
|
||||
* Anfang). failNext({ collection?, method?, status, message?, after? }) lässt die nächste passende Anfrage
|
||||
* mit diesem Status scheitern (Fehlerpfade wie "Appwrite nicht erreichbar" oder "Collection fehlt");
|
||||
* after = Zahl passender Anfragen, die vorher noch durchgehen (z. B. erst die zweite PATCH scheitert).
|
||||
*/
|
||||
export async function startAppwriteMock({ schema = {} } = {}) {
|
||||
const collections = new Map()
|
||||
@@ -104,9 +105,13 @@ export async function startAppwriteMock({ schema = {} } = {}) {
|
||||
(!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 (pendingFailure.after > 0) {
|
||||
pendingFailure.after -= 1
|
||||
} else {
|
||||
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[]'))
|
||||
@@ -155,9 +160,9 @@ 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 }
|
||||
/** Naechste passende Dokument-Anfrage scheitert mit status (einmalig); after passende Anfragen gehen vorher durch. */
|
||||
failNext: ({ collection = null, method = null, status = 500, message = '', after = 0 }) => {
|
||||
pendingFailure = { collection, method, status, message, after }
|
||||
},
|
||||
close: () => new Promise((resolve) => server.close(resolve)),
|
||||
}
|
||||
|
||||
+103
-1
@@ -4,6 +4,12 @@ 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]').
|
||||
*
|
||||
* Abo-Änderungen (F17): POST /v1/subscriptions/:id mischt items[i][id|price|quantity|deleted] und
|
||||
* metadata (leerer Wert löscht) in das gespeicherte Abo; st.nextSubscriptionUpdate wird einmalig nur
|
||||
* in die nächste Antwort gemischt (z. B. pending_update, latest_invoice). Rechnungen: GET /v1/invoices/:id
|
||||
* bettet wie Stripe höchstens 10 Zeilen ein, GET /v1/invoices/:id/lines blättert (limit, starting_after);
|
||||
* POST /v1/invoices/create_preview liefert st.invoicePreview (Objekt, oder Funktion des Aufruf-Bodys).
|
||||
*/
|
||||
export async function startFakeStripe() {
|
||||
const subscriptions = new Map()
|
||||
@@ -16,11 +22,18 @@ export async function startFakeStripe() {
|
||||
const checkoutSessions = new Map()
|
||||
const calls = []
|
||||
let seq = 0
|
||||
let nextSubscriptionUpdate = null
|
||||
let invoicePreview = null
|
||||
const nextId = (prefix) => `${prefix}_test_${(seq += 1)}`
|
||||
|
||||
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 invalid = (param, message) => [400, { error: { type: 'invalid_request_error', param, message } }]
|
||||
const listOf = (data) => ({ object: 'list', data, has_more: false, url: '/v1/list' })
|
||||
// Rechnungszeilen mit IDs (fuer starting_after), wie Stripe sie liefert; die Quelle behaelt alle Zeilen
|
||||
const invoiceLinesOf = (invoiceId) =>
|
||||
(invoices.get(invoiceId)?.lines?.data || []).map((line, i) => ({ id: `li_${invoiceId}_${i + 1}`, object: 'line_item', ...line }))
|
||||
const EMBEDDED_LINES = 10
|
||||
|
||||
const readBody = (req) =>
|
||||
new Promise((resolve) => {
|
||||
@@ -53,6 +66,56 @@ export async function startFakeStripe() {
|
||||
if (method === 'GET' && (m = path.match(/^\/v1\/subscriptions\/([^/]+)$/))) {
|
||||
return subscriptions.has(m[1]) ? [200, subscriptions.get(m[1])] : noSuch('subscription', m[1])
|
||||
}
|
||||
if (method === 'POST' && (m = path.match(/^\/v1\/subscriptions\/([^/]+)$/))) {
|
||||
const existing = subscriptions.get(m[1])
|
||||
if (!existing) return noSuch('subscription', m[1])
|
||||
const data = unflatten(body)
|
||||
const items = (existing.items?.data || []).map((item) => ({ ...item }))
|
||||
for (const [i, entry] of (data.items || []).entries()) {
|
||||
if (entry.id) {
|
||||
// Vorhandene Position: Menge/Preis aendern oder entfernen
|
||||
const index = items.findIndex((item) => item.id === entry.id)
|
||||
if (index < 0) return invalid(`items[${i}][id]`, `No such subscription_item: '${entry.id}'`)
|
||||
if (entry.deleted === 'true') {
|
||||
items.splice(index, 1)
|
||||
continue
|
||||
}
|
||||
if (entry.price) {
|
||||
if (!prices.has(entry.price)) return invalid(`items[${i}][price]`, `No such price: '${entry.price}'`)
|
||||
items[index].price = prices.get(entry.price)
|
||||
}
|
||||
if (entry.quantity !== undefined) items[index].quantity = Number(entry.quantity)
|
||||
} else if (entry.price) {
|
||||
// Neue Position per Preis; wie Stripe: derselbe Preis hoechstens einmal je Abo
|
||||
if (!prices.has(entry.price)) return invalid(`items[${i}][price]`, `No such price: '${entry.price}'`)
|
||||
if (items.some((item) => item.price?.id === entry.price)) {
|
||||
return invalid(`items[${i}][price]`, 'You cannot combine multiple subscription items with the same price.')
|
||||
}
|
||||
items.push({
|
||||
id: nextId('si'),
|
||||
object: 'subscription_item',
|
||||
quantity: entry.quantity !== undefined ? Number(entry.quantity) : 1,
|
||||
price: prices.get(entry.price),
|
||||
})
|
||||
} else {
|
||||
return invalid(`items[${i}]`, 'Each subscription item must have an id or a price.')
|
||||
}
|
||||
}
|
||||
// Metadaten wie bei Stripe zusammenfuehren: leerer Wert loescht den Schluessel
|
||||
const metadata = { ...(existing.metadata || {}) }
|
||||
for (const [key, value] of Object.entries(data.metadata || {})) {
|
||||
if (value === '') delete metadata[key]
|
||||
else metadata[key] = value
|
||||
}
|
||||
const updated = { ...existing, items: { object: 'list', ...(existing.items || {}), data: items }, metadata }
|
||||
if (data.cancel_at_period_end !== undefined) updated.cancel_at_period_end = data.cancel_at_period_end === 'true'
|
||||
if (data.cancel_at !== undefined) updated.cancel_at = data.cancel_at === '' ? null : Number(data.cancel_at)
|
||||
subscriptions.set(m[1], updated)
|
||||
// Einmaliger Zusatz nur fuer diese Antwort (pending_update, latest_invoice, ...), nicht gespeichert
|
||||
const response = nextSubscriptionUpdate ? { ...updated, ...nextSubscriptionUpdate } : updated
|
||||
nextSubscriptionUpdate = null
|
||||
return [200, response]
|
||||
}
|
||||
if (method === 'GET' && (m = path.match(/^\/v1\/prices\/([^/]+)$/))) {
|
||||
return prices.has(m[1]) ? [200, prices.get(m[1])] : noSuch('price', m[1])
|
||||
}
|
||||
@@ -90,7 +153,32 @@ export async function startFakeStripe() {
|
||||
return [200, obj]
|
||||
}
|
||||
if (method === 'GET' && (m = path.match(/^\/v1\/invoices\/([^/]+)$/))) {
|
||||
return invoices.has(m[1]) ? [200, invoices.get(m[1])] : noSuch('invoice', m[1])
|
||||
if (!invoices.has(m[1])) return noSuch('invoice', m[1])
|
||||
// Wie Stripe: hoechstens 10 Zeilen eingebettet, weitere ueber GET /v1/invoices/:id/lines (has_more)
|
||||
const lines = invoiceLinesOf(m[1])
|
||||
return [
|
||||
200,
|
||||
{
|
||||
...invoices.get(m[1]),
|
||||
lines: { object: 'list', data: lines.slice(0, EMBEDDED_LINES), has_more: lines.length > EMBEDDED_LINES, url: `/v1/invoices/${m[1]}/lines` },
|
||||
},
|
||||
]
|
||||
}
|
||||
if (method === 'GET' && (m = path.match(/^\/v1\/invoices\/([^/]+)\/lines$/))) {
|
||||
if (!invoices.has(m[1])) return noSuch('invoice', m[1])
|
||||
const lines = invoiceLinesOf(m[1])
|
||||
const limit = Math.min(Number(query.get('limit')) || 10, 100)
|
||||
const after = query.get('starting_after')
|
||||
const afterIndex = after ? lines.findIndex((line) => line.id === after) : -1
|
||||
if (after && afterIndex < 0) return invalid('starting_after', `No such line item: '${after}'`)
|
||||
const start = afterIndex + 1
|
||||
return [200, { object: 'list', data: lines.slice(start, start + limit), has_more: start + limit < lines.length, url: `/v1/invoices/${m[1]}/lines` }]
|
||||
}
|
||||
if (method === 'POST' && path === '/v1/invoices/create_preview') {
|
||||
const data = unflatten(body)
|
||||
if (data.subscription && !subscriptions.has(data.subscription)) return noSuch('subscription', data.subscription)
|
||||
const preview = typeof invoicePreview === 'function' ? invoicePreview(data) : invoicePreview
|
||||
return [200, preview || { object: 'invoice', lines: { object: 'list', data: [], has_more: false }, amount_due: 0, subtotal: 0, total: 0 }]
|
||||
}
|
||||
// scripts/stripe-products.mjs --pruefen liest das Produkt zurueck (Beschreibung, Bild)
|
||||
if (method === 'GET' && (m = path.match(/^\/v1\/products\/([^/]+)$/))) {
|
||||
@@ -234,6 +322,20 @@ export async function startFakeStripe() {
|
||||
billingPortalConfigurations,
|
||||
customers,
|
||||
checkoutSessions,
|
||||
/** Einmalig in die naechste Antwort von POST /v1/subscriptions/:id gemischt (z. B. pending_update). */
|
||||
get nextSubscriptionUpdate() {
|
||||
return nextSubscriptionUpdate
|
||||
},
|
||||
set nextSubscriptionUpdate(value) {
|
||||
nextSubscriptionUpdate = value
|
||||
},
|
||||
/** Antwort von POST /v1/invoices/create_preview: Objekt oder Funktion (body) => Objekt; null = leere Vorschau. */
|
||||
get invoicePreview() {
|
||||
return invoicePreview
|
||||
},
|
||||
set invoicePreview(value) {
|
||||
invoicePreview = value
|
||||
},
|
||||
close: () => new Promise((resolve) => server.close(resolve)),
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,9 @@ import { config } from '../../server/config.js'
|
||||
*/
|
||||
|
||||
const DAY_MS = 86400000
|
||||
const daysFromNow = (days) => new Date(Date.now() + days * DAY_MS).toISOString()
|
||||
// Sekundengenau wie Stripe (unix-Zeitstempel): der Spiegel schreibt toIso(unix) und trifft so die
|
||||
// Fixture-Werte wortgleich (z. B. storagePaidPeriodEnd = currentPeriodEnd nach einem Speicher-Plus)
|
||||
const daysFromNow = (days) => new Date(Math.floor((Date.now() + days * DAY_MS) / 1000) * 1000).toISOString()
|
||||
|
||||
export const customers = {
|
||||
c1: { $id: 'c1', appwriteUserId: 'u1', email: 'k@example.invalid', name: 'Kunde Eins', status: 'active', portalAccessEnabled: true },
|
||||
|
||||
Reference in new issue
Block a user