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

+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 () => {