Finanzen: Zeitgrenzen fuer Stripe-Lesen, Schutz vor doppelt angelegten Rechnungen

I2: Leseanfragen an Stripe mit timeout 15 s und einer Wiederholung (auch Folgeseiten), die Uebersicht
wartet hoechstens 20 s auf Stripe und rechnet sonst ohne Stripe weiter.
I3: idempotencyKey vom Aufrufer an stripe.invoices.create, Schutz im Prozess gegen gleichzeitiges und
30 s spaeteres Doppelanlegen (409), 502 mit invoiceId/Nummer wenn die fertige Rechnung nicht lesbar ist.
M9: Warnung, wenn die Rechnungsliste bei 1000 abgeschnitten wird.
M11: Ursache eines 502 an einer bestehenden Rechnung (Art, Status, Code) im Protokoll.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-01 15:42:06 +00:00
1 parent ef2ce49669
commit daa451094a
8 files changed
+476 -48

No files matched your search

+88 -1
View File
@@ -23,7 +23,7 @@ const { config } = await import('../server/config.js')
const { seedFixtures, customers } = await import('./support/fixtures.js')
const { startTestApp, get, post, rawGet } = await import('./support/testApp.js')
const { default: financeRoutes } = await import('../server/routes/admin/finanzen.js')
const { invalidateFinanceCache } = await import('../server/services/finance/stripeFinance.js')
const { invalidateFinanceCache, resetInvoiceCreateGuardForTests } = await import('../server/services/finance/stripeFinance.js')
const { getStripe } = await import('../server/services/stripeBilling.js')
const { addDays, berlinDate } = await import('../server/services/finance/dates.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
@@ -58,6 +58,7 @@ const unix = (ymd) => Math.floor(new Date(`${ymd}T12:00:00+02:00`).getTime() / 1
async function reset() {
mock.restoreAll()
invalidateFinanceCache()
resetInvoiceCreateGuardForTests()
st.calls.length = 0
st.invoices.clear()
st.customers.clear()
@@ -459,6 +460,33 @@ test('GET /uebersicht: Stripe 500 -> 200, Bankteil, Kopf und Aufgaben aus Bank u
assert.equal(body.abos.monatlichCents, 7000)
})
test('buildOverview: antwortet Stripe nicht binnen der Wartezeit, rechnet die Uebersicht ohne Stripe weiter (I2)', async () => {
seedAll()
const error = mock.method(console, 'error', () => {})
st.delayNext({ method: 'GET', path: '/v1/invoices', ms: 800 })
const started = Date.now()
const body = await buildOverview({ stripeWaitMs: 100 })
assert.ok(Date.now() - started < 600, `Uebersicht brauchte ${Date.now() - started} ms`)
assert.deepEqual(body.stand.stripe, { ok: false, fehler: 'Stripe ist gerade nicht erreichbar.', abgerufenUm: null })
assert.equal(body.stand.bank.balanceBookedCents, 10893)
assert.equal(body.kopf.kontostandCents, 10893)
assert.deepEqual(body.rechnungen.altersgruppen.map((g) => g.anzahl), [0, 0, 0, 0])
assert.ok(ART(body).includes('abo-zahlung'))
assert.equal(error.mock.callCount(), 1)
assert.match(error.mock.calls[0].arguments[0], /Stripe laden fehlgeschlagen: keine Antwort nach 100 ms/)
// spaeter antwortet Stripe wieder: die naechste Uebersicht ist vollstaendig
await new Promise((resolve) => setTimeout(resolve, 800))
invalidateFinanceCache()
const later = await buildOverview({ stripeWaitMs: 5000 })
assert.equal(later.stand.stripe.ok, true)
})
test('buildOverview: Stripe antwortet rechtzeitig -> ok, die Wartezeit haelt den Prozess nicht auf (I2)', async () => {
seedAll()
const body = await buildOverview({ stripeWaitMs: 5000 })
assert.equal(body.stand.stripe.ok, true)
})
test('GET /uebersicht: nur die Auszahlungen scheitern -> Rechnungen bleiben, Stripe-Block meldet den Fehler', async () => {
seedAll()
st.failNext({ method: 'GET', path: '/v1/payouts', status: 400, message: 'Geheimer Stripe-Text' })
@@ -706,6 +734,65 @@ test('POST /rechnungen: Senden scheitert nach dem Festschreiben -> 502 mit invoi
assert.equal(again.body.rechnung.status, 'offen')
})
test('POST /rechnungen: idempotencyKey geht als Idempotency-Key an Stripe, ein ungueltiger -> 400 (I3)', async () => {
const res = await postInvoice({ customerId: 'c1', items: ITEMS, idempotencyKey: 'woms-formular-4711' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(callsTo('POST', '/v1/invoices')[0].idempotencyKey, 'woms-formular-4711')
for (const idempotencyKey of ['x'.repeat(101), 'mit Leerzeichen', 7]) {
const bad = await postInvoice({ customerId: 'c1', items: ITEMS, memo: 'anders', idempotencyKey })
assert.equal(bad.status, 400, String(idempotencyKey))
assert.equal(bad.body.error, 'Der Wiederholungsschlüssel ist ungültig (1 bis 100 Zeichen ohne Leerzeichen).')
}
assert.equal(callsTo('POST', '/v1/invoices').length, 1)
})
test('POST /rechnungen: derselbe Inhalt gleich noch einmal -> 409 "wird gerade schon erstellt", keine zweite Rechnung (I3)', async () => {
const body = { customerId: 'c1', items: ITEMS, senden: true }
const results = await Promise.all([postInvoice(body), postInvoice(body)])
assert.deepEqual(results.map((r) => r.status).sort(), [200, 409])
const conflict = results.find((r) => r.status === 409)
assert.deepEqual(conflict.body, { error: 'Diese Rechnung wird gerade schon erstellt.' })
assert.equal(st.invoices.size, 1)
assert.equal(callsTo('POST', /\/send$/).length, 1)
// auch einen Moment spaeter (Nutzer klickt noch einmal), aber nicht bei anderem Inhalt
assert.equal((await postInvoice(body)).status, 409)
assert.equal((await postInvoice({ ...body, memo: 'anderer Vermerk' })).status, 200)
assert.equal(st.invoices.size, 2)
})
test('POST /rechnungen: Rechnung nach dem Anlegen nicht lesbar -> 502 mit invoiceId und Nummer, Ursache im Protokoll (I3, M11)', async () => {
const error = mock.method(console, 'error', () => {})
st.failNext({ method: 'GET', path: /^\/v1\/invoices\/in_/, status: 400, message: 'Geheimer Lesetext sk_live_abc' })
const res = await postInvoice({ customerId: 'c1', items: ITEMS, senden: true })
assert.equal(res.status, 502)
assert.match(res.body.invoiceId, /^in_/)
assert.match(res.body.invoiceNumber, /^WK-\d{4}$/)
assert.equal(res.body.error, `Rechnung ${res.body.invoiceNumber} wurde gesendet, konnte aber nicht gelesen werden. Bitte die Rechnungsliste prüfen.`)
assert.deepEqual(Object.keys(res.body).sort(), ['error', 'invoiceId', 'invoiceNumber'])
assert.ok(!JSON.stringify(res.body).includes('Geheimer'))
assert.equal(st.invoices.get(res.body.invoiceId).status, 'open')
assert.equal(error.mock.callCount(), 1)
assert.deepEqual(error.mock.calls[0].arguments, ['[finanzen] Rechnung anlegen fehlgeschlagen:', 'StripeInvalidRequestError', 400, ''])
})
test('POST /rechnungen: Senden scheitert (502) -> Art, Status und Code der Stripe-Ursache im Protokoll, nie deren Text (M11)', async () => {
const error = mock.method(console, 'error', () => {})
st.failNext({ method: 'POST', path: /\/send$/, status: 400, message: 'Geheimer Sendetext sk_live_abc' })
const res = await postInvoice({ customerId: 'c1', items: ITEMS, senden: true })
assert.equal(res.status, 502)
assert.equal(error.mock.callCount(), 1)
assert.deepEqual(error.mock.calls[0].arguments, ['[finanzen] Rechnung anlegen fehlgeschlagen:', 'StripeInvalidRequestError', 400, ''])
assert.ok(!JSON.stringify(error.mock.calls).includes('Geheimer'))
})
test('POST /rechnungen: eine Anlage ohne Rechnung bei Stripe (400) schreibt keine zweite Ursache ins Protokoll (M11)', async () => {
const error = mock.method(console, 'error', () => {})
assert.equal((await postInvoice({ customerId: 'gibtsnicht', items: ITEMS })).status, 400)
assert.equal(error.mock.callCount(), 0)
})
test('POST /rechnungen: Stripe lehnt ab (4xx) -> 409, ueberspringt Stripe-Text; Stripe nicht erreichbar -> 502', async () => {
st.failNext({ method: 'POST', path: '/v1/invoices', status: 400, message: 'Geheimer Stripe-Text' })
const rejected = await postInvoice({ customerId: 'c1', items: ITEMS })
+4 -2
View File
@@ -21,7 +21,7 @@ const { seedFixtures } = await import('./support/fixtures.js')
const { startTestApp, get, post, rawGet } = await import('./support/testApp.js')
const { default: financeRoutes } = await import('../server/routes/admin/finanzen.js')
const settings = await import('../server/services/finance/settings.js')
const { createProjectInvoice, invalidateFinanceCache } = await import('../server/services/finance/stripeFinance.js')
const { createProjectInvoice, invalidateFinanceCache, resetInvoiceCreateGuardForTests } = await import('../server/services/finance/stripeFinance.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const app = await startTestApp({ '/api/admin/finanzen': financeRoutes })
@@ -49,6 +49,7 @@ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
async function reset() {
mock.restoreAll()
invalidateFinanceCache()
resetInvoiceCreateGuardForTests()
st.calls.length = 0
st.invoices.clear()
st.customers.clear()
@@ -568,8 +569,9 @@ test('Rechnung: nach PATCH { bankDetailsText } steht die IBAN-Zeile im Rechnungs
`${config.stripe.invoiceFooter}\nIBAN DE00 0000 0000 0000 0000 00\nBIC TESTDEFF\n${PAYMENT_NOTE}`
)
// wieder leer: der Fuss hat keine Bankzeile mehr
// wieder leer: der Fuss hat keine Bankzeile mehr (derselbe Inhalt wie eben: den Schutz vor doppeltem Anlegen zuruecksetzen)
assert.equal((await patchSettings({ bankDetailsText: '' })).status, 200)
resetInvoiceCreateGuardForTests()
await createProjectInvoice({ customerId: 'c1', items: ITEMS, createdBy: 'kenso@example.invalid' })
assert.equal(footerOfLastDraft(), `${config.stripe.invoiceFooter}\n${PAYMENT_NOTE}`)
})
+63 -2
View File
@@ -10,13 +10,18 @@ const aw = await startAppwriteMock({ schema: {} })
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { euroDe } = await import('../server/services/finance/format.js')
const { mapInvoice, listInvoices, listPayouts, invalidateFinanceCache } = await import('../server/services/finance/stripeFinance.js')
const { mapInvoice, listInvoices, listPayouts, invalidateFinanceCache, setStripeReadOptionsForTests } = await import(
'../server/services/finance/stripeFinance.js'
)
const { getStripe } = await import('../server/services/stripeBilling.js')
after(() => {
mock.timers.reset()
return Promise.all([aw.close(), st.close()])
})
beforeEach(() => {
mock.restoreAll()
setStripeReadOptionsForTests(null)
mock.timers.reset()
invalidateFinanceCache()
st.calls.length = 0
@@ -212,10 +217,66 @@ test('listInvoices: blaettert ueber alle Seiten', async () => {
assert.equal(st.calls.filter((c) => c.path === '/v1/invoices').length, 3)
})
test('listInvoices: hoechstens 1000 Rechnungen', async () => {
test('listInvoices: hoechstens 1000 Rechnungen, bei der Obergrenze genau eine Warnung je Laden (M9)', async () => {
for (let i = 1; i <= 1100; i += 1) st.invoices.set(`in_${i}`, rawInvoice({ id: `in_${i}` }))
const warn = mock.method(console, 'warn', () => {})
const list = await listInvoices()
assert.equal(list.length, 1000)
assert.deepEqual(warn.mock.calls.map((c) => c.arguments), [['[finanzen] Rechnungsliste bei 1000 abgeschnitten']])
// aus dem Zwischenspeicher: kein neues Laden, keine zweite Warnung; ein neues Laden warnt wieder
await listInvoices()
assert.equal(warn.mock.callCount(), 1)
await listInvoices({ force: true })
assert.equal(warn.mock.callCount(), 2)
})
test('listInvoices: unter der Obergrenze keine Warnung (M9)', async () => {
for (let i = 1; i <= 999; i += 1) st.invoices.set(`in_${i}`, rawInvoice({ id: `in_${i}` }))
const warn = mock.method(console, 'warn', () => {})
assert.equal((await listInvoices()).length, 999)
assert.equal(warn.mock.callCount(), 0)
})
test('Lesen: Voreinstellung 15 s Zeitgrenze und eine Wiederholung je Anfrage an Stripe (I2)', async () => {
const stripe = getStripe()
const invoicesList = mock.method(stripe.invoices, 'list', () => ({ autoPagingToArray: async () => [] }))
const payoutsList = mock.method(stripe.payouts, 'list', async () => ({ data: [] }))
await listInvoices()
await listPayouts()
assert.deepEqual(invoicesList.mock.calls[0].arguments[1], { timeout: 15_000, maxNetworkRetries: 1 })
assert.deepEqual(payoutsList.mock.calls[0].arguments[1], { timeout: 15_000, maxNetworkRetries: 1 })
})
test('Lesen: antwortet Stripe nicht, endet die Anfrage an der Zeitgrenze statt nach 80 Sekunden (I2)', async () => {
setStripeReadOptionsForTests({ timeout: 80, maxNetworkRetries: 0 })
st.invoices.set('in_1', rawInvoice())
st.payouts.set('po_1', { id: 'po_1', object: 'payout', amount: 100, arrival_date: noonBerlin('2026-09-12'), status: 'paid' })
const isTimeout = (err) => err.type === 'StripeConnectionError' && /timeout/i.test(err.message)
st.delayNext({ method: 'GET', path: '/v1/invoices', ms: 700 })
let started = Date.now()
await assert.rejects(listInvoices(), isTimeout)
assert.ok(Date.now() - started < 500, `Rechnungen: ${Date.now() - started} ms`)
st.delayNext({ method: 'GET', path: '/v1/payouts', ms: 700 })
started = Date.now()
await assert.rejects(listPayouts(), isTimeout)
assert.ok(Date.now() - started < 500, `Auszahlungen: ${Date.now() - started} ms`)
// der Fehler wird nicht zwischengespeichert: der naechste Aufruf klappt
assert.equal((await listInvoices()).length, 1)
assert.equal((await listPayouts()).length, 1)
})
test('Lesen: die Zeitgrenze gilt auch fuer die Folgeseiten der Rechnungsliste (I2)', async () => {
for (let i = 1; i <= 150; i += 1) st.invoices.set(`in_${i}`, rawInvoice({ id: `in_${i}` }))
setStripeReadOptionsForTests({ timeout: 80, maxNetworkRetries: 0 })
// die erste Seite kommt sofort, die zweite haengt
st.delayNext({ method: 'GET', path: '/v1/invoices', ms: 700, after: 1 })
const started = Date.now()
await assert.rejects(listInvoices(), (err) => err.type === 'StripeConnectionError')
assert.ok(Date.now() - started < 500)
assert.equal(st.calls.filter((c) => c.path === '/v1/invoices').length, 2)
})
test('listInvoices: zweimal innerhalb von 60 s -> ein Stripe-Aufruf, danach invalidateFinanceCache und force', async () => {
+128 -2
View File
@@ -1,4 +1,4 @@
import { test, after, beforeEach } from 'node:test'
import { test, after, beforeEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
@@ -14,7 +14,7 @@ const { seedFixtures } = await import('./support/fixtures.js')
const { berlinDate } = await import('../server/services/finance/dates.js')
const { getStripe } = await import('../server/services/stripeBilling.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { invoiceFooter, createProjectInvoice, sendInvoiceNow, voidInvoice, deleteDraft, listInvoices, invalidateFinanceCache } = await import(
const { invoiceFooter, createProjectInvoice, sendInvoiceNow, voidInvoice, deleteDraft, listInvoices, invalidateFinanceCache, resetInvoiceCreateGuardForTests } = await import(
'../server/services/finance/stripeFinance.js'
)
@@ -37,7 +37,9 @@ const BASE_ITEMS = [
]
beforeEach(() => {
mock.restoreAll()
invalidateFinanceCache()
resetInvoiceCreateGuardForTests()
st.calls.length = 0
st.invoices.clear()
st.customers.clear()
@@ -418,6 +420,8 @@ test('Schreibzugriffe leeren den Zwischenspeicher: die Liste zeigt neue, gesende
await voidInvoice(draft.id)
assert.equal((await listInvoices())[0].status, 'storniert')
// gleicher Inhalt wie der erste Entwurf: der Schutz vor doppeltem Anlegen waere noch aktiv
resetInvoiceCreateGuardForTests()
const second = await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, createdBy: 'k' })
assert.equal((await listInvoices()).length, 2)
await deleteDraft(second.id)
@@ -480,3 +484,125 @@ test('Stripe-Nachbau: Statusuebergaenge wie Stripe (finalize nur Entwurf, send/v
assert.equal(removed.deleted, true)
assert.equal(st.invoices.has(third.id), false)
})
// ---------- Doppeltes Anlegen (I3) ----------
const createdCalls = () => callsTo('POST', '/v1/invoices')
test('createProjectInvoice: idempotencyKey geht als Idempotency-Key nur an die Anlage der Rechnung (I3)', async () => {
await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, idempotencyKey: 'woms-formular-4711', createdBy: 'k@example.invalid' })
const [create] = createdCalls()
assert.equal(create.idempotencyKey, 'woms-formular-4711')
// die Posten tragen nicht den Schluessel des Aufrufers (stripe-node vergibt sonst eigene, zufaellige)
assert.ok(callsTo('POST', '/v1/invoiceitems').every((call) => call.idempotencyKey !== 'woms-formular-4711'))
// ohne Schluessel (oder leer / null) nimmt stripe-node einen eigenen, zufaelligen: nie den des Aufrufers
st.calls.length = 0
for (const idempotencyKey of [undefined, null, '']) {
resetInvoiceCreateGuardForTests()
await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, idempotencyKey })
}
assert.equal(createdCalls().length, 3)
assert.equal(new Set(createdCalls().map((call) => call.idempotencyKey)).size, 3, 'jedes Mal ein anderer')
})
test('createProjectInvoice: ungueltiger Wiederholungsschluessel -> 400, nichts bei Stripe (I3)', async () => {
for (const idempotencyKey of ['x'.repeat(101), 'mit Leerzeichen', 'Zeilen\numbruch', 42, {}]) {
await rejectsWith(
createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, idempotencyKey }),
400,
'Der Wiederholungsschlüssel ist ungültig (1 bis 100 Zeichen ohne Leerzeichen).'
)
}
assert.equal(st.calls.length, 0)
// genau 100 Zeichen sind erlaubt
await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, idempotencyKey: 'k'.repeat(100) })
assert.equal(createdCalls()[0].idempotencyKey, 'k'.repeat(100))
})
test('createProjectInvoice: zwei gleichzeitige gleiche Aufrufe legen nur eine Rechnung an, der zweite bekommt 409 (I3)', async () => {
const args = { customerId: 'c1', items: BASE_ITEMS, memo: 'Doppelklick', senden: true }
const results = await Promise.allSettled([createProjectInvoice(args), createProjectInvoice(args)])
assert.deepEqual(results.map((r) => r.status), ['fulfilled', 'rejected'])
await rejectsWith(Promise.reject(results[1].reason), 409, 'Diese Rechnung wird gerade schon erstellt.')
assert.equal(st.invoices.size, 1)
assert.equal(createdCalls().length, 1)
assert.equal(callsTo('POST', /\/send$/).length, 1, 'nur eine Mail')
})
test('createProjectInvoice: 30 Sekunden nach einem Erfolg 409, danach wieder erlaubt; anderer Inhalt ist nie betroffen (I3)', async () => {
const args = { customerId: 'c1', items: BASE_ITEMS }
await createProjectInvoice(args)
await rejectsWith(createProjectInvoice(args), 409, 'Diese Rechnung wird gerade schon erstellt.')
assert.equal(st.invoices.size, 1)
// anderer Inhalt: anderer Vermerk, anderes Zahlungsziel, senden, anderer Kunde, andere Positionen
await createProjectInvoice({ ...args, memo: 'anders' })
await createProjectInvoice({ ...args, daysUntilDue: 30 })
await createProjectInvoice({ ...args, senden: true })
await createProjectInvoice({ customerId: 'act1', items: BASE_ITEMS })
await createProjectInvoice({ customerId: 'c1', items: [{ ...BASE_ITEMS[0], quantity: 3 }] })
assert.equal(st.invoices.size, 6)
// nach 30 Sekunden gilt dieselbe Rechnung wieder als neu
const real = Date.now
mock.method(Date, 'now', () => real() + 29_000)
await rejectsWith(createProjectInvoice(args), 409)
mock.method(Date, 'now', () => real() + 31_000)
await createProjectInvoice(args)
assert.equal(st.invoices.size, 7)
})
test('createProjectInvoice: scheitert die Anlage und bleibt keine Rechnung, ist der Schutz frei (I3)', async () => {
const args = { customerId: 'c1', items: BASE_ITEMS }
st.failNext({ method: 'POST', path: '/v1/invoices', status: 400, message: 'abgelehnt' })
await assert.rejects(createProjectInvoice(args))
// Ungueltiger Kunde zaehlt ebenso als gescheitert
await rejectsWith(createProjectInvoice({ customerId: 'lead1', items: BASE_ITEMS }), 400)
await rejectsWith(createProjectInvoice({ customerId: 'lead1', items: BASE_ITEMS }), 400)
// Posten scheitert: Entwurf wird geloescht, der Schutz freigegeben
st.failNext({ method: 'POST', path: '/v1/invoiceitems', status: 400, message: 'abgelehnt' })
await assert.rejects(createProjectInvoice(args))
assert.equal(st.invoices.size, 0)
await createProjectInvoice(args)
assert.equal(st.invoices.size, 1)
})
test('createProjectInvoice: Senden scheitert nach dem Festschreiben -> Rechnung besteht, der Schutz bleibt (I3)', async () => {
const args = { customerId: 'c1', items: BASE_ITEMS, senden: true }
st.failNext({ method: 'POST', path: /\/send$/, status: 400, message: 'Mailversand abgelehnt' })
await assert.rejects(createProjectInvoice(args), (err) => err.status === 502 && /^in_/.test(err.invoiceId))
await rejectsWith(createProjectInvoice(args), 409, 'Diese Rechnung wird gerade schon erstellt.')
assert.equal(st.invoices.size, 1)
})
test('createProjectInvoice: Rechnung nach dem Senden nicht lesbar -> 502 mit invoiceId und Nummer statt blankem Fehler (I3)', async () => {
const args = { customerId: 'c1', items: BASE_ITEMS, senden: true }
st.failNext({ method: 'GET', path: /^\/v1\/invoices\/in_/, status: 400, message: 'Lesefehler 10.0.0.7' })
let caught
await assert.rejects(createProjectInvoice(args), (err) => {
caught = err
return true
})
assert.equal(caught.status, 502)
assert.equal(caught.name, 'InvoiceError')
assert.match(caught.invoiceId, /^in_/)
assert.match(caught.invoiceNumber, /^WK-\d{4}$/)
assert.equal(caught.message, `Rechnung ${caught.invoiceNumber} wurde gesendet, konnte aber nicht gelesen werden. Bitte die Rechnungsliste prüfen.`)
assert.ok(!caught.message.includes('10.0.0.7'))
assert.ok(caught.cause, 'die Ursache bleibt fuers Protokoll')
assert.equal(st.invoices.get(caught.invoiceId).status, 'open')
assert.equal(callsTo('POST', /\/send$/).length, 1)
// die Rechnung besteht: ein zweiter Versuch mit gleichem Inhalt wird abgewiesen
await rejectsWith(createProjectInvoice(args), 409)
// Entwurf: ohne Nummer, andere Meldung
resetInvoiceCreateGuardForTests()
st.failNext({ method: 'GET', path: /^\/v1\/invoices\/in_/, status: 400 })
await assert.rejects(createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS }), (err) => {
assert.equal(err.status, 502)
assert.equal(err.invoiceNumber, '')
assert.equal(err.message, 'Der Entwurf wurde angelegt, konnte aber nicht gelesen werden. Bitte die Rechnungsliste prüfen.')
return true
})
})
+29 -2
View File
@@ -25,7 +25,8 @@ import http from 'node:http'
* .../send (nur open), .../void (nur open -> void), .../pay (nur open -> paid, auch paid_out_of_band; wie die echte API
* liefert die Rechnung danach kein Feld paid_out_of_band, nur der Aufruf in calls zeigt es) und
* DELETE /v1/invoices/:id (nur draft); jeder andere Zustand ist wie bei Stripe ein 400. Der Kunde einer neuen
* Rechnung muss nicht in st.customers stehen (dann bleiben Name und E-Mail leer).
* Rechnung muss nicht in st.customers stehen (dann bleiben Name und E-Mail leer). Traegt ein Aufruf die Kopfzeile
* Idempotency-Key, steht sie als idempotencyKey in calls; delayNext verzoegert eine Antwort (Zeitgrenzen testen).
*/
// Parameter, die Stripe bei ausstehenden Aktualisierungen annimmt (docs.stripe.com/billing/subscriptions/
@@ -58,6 +59,8 @@ export async function startFakeStripe() {
let invoicePreview = null
// Eingeplanter Fehler (failNext, F41d): der naechste passende Aufruf scheitert einmalig
let pendingFailure = null
// Eingeplante Verzoegerung (delayNext): die Antwort des naechsten passenden Aufrufs kommt erst nach ms
let pendingDelay = null
const nextId = (prefix) => `${prefix}_test_${(seq += 1)}`
const matchesPath = (path, pathname) => (path instanceof RegExp ? path.test(pathname) : path === pathname)
@@ -74,6 +77,19 @@ export async function startFakeStripe() {
return [status, { error: { type: status >= 500 ? 'api_error' : 'invalid_request_error', message: message || `Nachbau: eingeplanter Fehler ${status}` } }]
}
const plannedDelay = (method, pathname) => {
if (!pendingDelay) return 0
if (pendingDelay.method && pendingDelay.method !== method) return 0
if (pendingDelay.path && !matchesPath(pendingDelay.path, pathname)) return 0
if (pendingDelay.after > 0) {
pendingDelay.after -= 1
return 0
}
const { ms } = pendingDelay
pendingDelay = null
return ms
}
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 } }]
@@ -572,7 +588,11 @@ export async function startFakeStripe() {
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) })
// idempotencyKey (Kopfzeile Idempotency-Key) steht nur an Aufrufen, die einen mitschicken
const idempotencyKey = req.headers['idempotency-key']
calls.push({ method: req.method, path: url.pathname, query: flat(url.searchParams), body: flat(body), ...(idempotencyKey ? { idempotencyKey } : {}) })
const delay = plannedDelay(req.method, url.pathname)
if (delay) await new Promise((resolve) => setTimeout(resolve, delay))
const [status, payload] = plannedFailure(req.method, url.pathname) || route(req.method, url.pathname, url.searchParams, body)
res.writeHead(status, { 'Content-Type': 'application/json', 'Request-Id': `req_test_${calls.length}` })
res.end(JSON.stringify(payload))
@@ -633,6 +653,13 @@ export async function startFakeStripe() {
failNext: ({ method = null, path = null, status = 500, message = '', after = 0 }) => {
pendingFailure = { method, path, status, message, after }
},
/**
* Die Antwort auf den naechsten passenden Aufruf (method, path als String oder RegExp) kommt erst nach ms
* Millisekunden; after passende Aufrufe gehen vorher ohne Verzoegerung durch. Der Aufruf steht sofort in calls.
*/
delayNext: ({ method = null, path = null, ms, after = 0 }) => {
pendingDelay = { method, path, ms, after }
},
close: () => new Promise((resolve) => server.close(resolve)),
}
}