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:
1 parent
ef2ce49669
commit
daa451094a
8 files changed
+476
-48
No files matched your search
@@ -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 })
|
||||
|
||||
@@ -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}`)
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user