Shop Review A I1/I2: Statuswechsel und Bestandsabzug unter einer Sperre, fehlende Schritte (Bestand, G09, K25) per Marker nachholen

Der Bestandsabzug laeuft jetzt im selben Block unter shop-bestand und shop-produkte wie der Statuswechsel auf bezahlt, zieheBestandAb haelt keine eigene Sperre mehr. Im Bestell-JSON stehen bestandAbgezogenAt, mailsKaeuferAt und mailsHaendlerAt als Marker; schliesseAb (Webhook-Wiederholung, Statusabruf, Minutenjob) holt fehlende Schritte genau einmal nach, Mails hoechstens fuenfmal versucht. Bezahlte Bestellungen ohne Bestandsabzug zaehlen weiter als reserviert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 09:30:36 +00:00
1 parent 42a6bb5a4a
commit 9c2897a0b0
4 files changed
+305 -83

No files matched your search

+25
View File
@@ -450,6 +450,31 @@ test('Status: offen ⇒ bezahlt, sobald Stripe die Sitzung als bezahlt meldet; E
assert.equal(sink.sent.length, mailsVorher)
})
test('Status I2: bezahlte Bestellung mit fehlendem Bestandsabzug wird beim nächsten Abruf nachgeholt', async () => {
await aufbau({ produkte: [artikel('a1', { bestand: 10 })] })
const { orderId, t, sessionId } = await offeneBestellung()
st.completeSession(ACCOUNT, sessionId, {
payment_status: 'paid',
amount_total: 2990,
customer_details: { name: 'Max Mustermann', email: 'max@example.com' },
shipping: { name: 'Max Mustermann', address: { line1: 'Musterweg 1', postal_code: '67655', city: 'Kaiserslautern', country: 'DE' } },
payment_intent: 'pi_test1',
})
aw.failNext({ collection: config.collections.shopProducts, method: 'PATCH', status: 500 })
const erster = await status(orderId, t)
assert.equal(erster.status, 200)
assert.equal(orders()[0].status, 'bezahlt')
assert.equal(JSON.parse(orders()[0].bestellung).bestandAbgezogenAt, null)
const zweiter = await status(orderId, t)
assert.equal(zweiter.body.status, 'bezahlt')
assert.ok(JSON.parse(orders()[0].bestellung).bestandAbgezogenAt)
await warteAufVersand()
const mails = sink.sent.length
await status(orderId, t)
await warteAufVersand()
assert.equal(sink.sent.length, mails, 'Mails nur einmal')
})
test('Status: nicht bezahlte Sitzung (z. B. SEPA unterwegs) bleibt offen; Stripe-Fehler beim Abgleich ⇒ trotzdem Antwort', async () => {
const { orderId, t, sessionId } = await offeneBestellung()
st.completeSession(ACCOUNT, sessionId, { payment_status: 'unpaid', amount_total: 2990 })
+122
View File
@@ -429,3 +429,125 @@ test('minutenJob: bei SHOP_ENABLED=off aus, sonst Abgleich; läuft nicht doppelt
assert.equal(order(b.orderId).status, 'bezahlt')
assert.deepEqual(await J.minutenJob(nach(7300)), { geprueft: 0, bezahlt: 0, verworfen: 0, wartend: 0, fehler: 0 })
})
// ---------------------------------------------------------------------------------------------------------------------
// Review A: I1 (Statuswechsel und Bestandsabzug unter einer Sperre) und I2 (fehlende Schritte nachholen)
test('schliesseAb I1: Statuswechsel und Bestandsabzug laufen unter der Bestandssperre - eine parallele Bestellung kann den letzten Artikel nicht noch einmal bekommen', async () => {
await aufbau({ produkte: [artikel('a1', { bestand: 2 }), artikel('a2', {})] })
const b = await bestelle([{ produktId: 'a1', menge: 2 }])
const session = zahle(b)
const echtesFetch = globalThis.fetch
let konkurrenz = null
globalThis.fetch = async (url, opt = {}) => {
const antwort = await echtesFetch(url, opt)
const pfad = String(url)
if (!konkurrenz && opt.method === 'PATCH' && pfad.includes(`/${config.collections.shopOrders}/documents/${b.orderId}`) && String(opt.body).includes('"bezahlt"')) {
// Die Bestellung ist jetzt bezahlt, der Bestand noch nicht abgezogen: genau jetzt kommt ein zweiter Kaeufer
konkurrenz = B.legeBestellungAn(ctx, { positionen: [{ produktId: 'a1', menge: 2 }], lieferart: 'versand', land: 'DE' }, { now: nach(61) }).then(
(r) => ({ ok: true, r }),
(e) => ({ ok: false, e })
)
await new Promise((r) => setTimeout(r, 80))
}
return antwort
}
try {
await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(60) })
const zweiter = await konkurrenz
assert.equal(zweiter.ok, false, 'der letzte Artikel darf nicht noch einmal reserviert werden')
assert.equal(zweiter.e.code, 'ausverkauft')
} finally {
globalThis.fetch = echtesFetch
}
assert.equal(produkt('a1').bestand, 0)
assert.equal(orders().filter((o) => o.status === 'offen').length, 0)
})
test('schliesseAb I2: scheitert der Bestandsabzug, bleibt der Artikel reserviert; der zweite Lauf holt den Abzug einmal nach und verschickt die Mails einmal', async () => {
await aufbau({ produkte: [artikel('a1', { bestand: 5 })] })
const b = await bestelle([{ produktId: 'a1', menge: 2 }])
const session = zahle(b)
aw.failNext({ collection: config.collections.shopProducts, method: 'PATCH', status: 500 })
await assert.rejects(B.schliesseAb({ accountId: ACCOUNT, session, now: nach(60) }))
assert.equal(order(b.orderId).status, 'bezahlt')
const daten = JSON.parse(order(b.orderId).bestellung)
assert.equal(daten.bestandAbgezogenAt, null)
assert.equal(produkt('a1').bestand, 5)
assert.equal((await B.reserviert(FEATURE, nach(70))).get('a1'), 2, 'noch nicht abgezogen => weiter reserviert')
await warteAufVersand()
assert.equal(sink.sent.length, 0)
const wieder = await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(120) })
assert.equal(wieder.neu, false)
assert.equal(produkt('a1').bestand, 3)
const nachher = JSON.parse(order(b.orderId).bestellung)
assert.ok(nachher.bestandAbgezogenAt && nachher.mailsKaeuferAt && nachher.mailsHaendlerAt)
assert.equal((await B.reserviert(FEATURE, nach(130))).get('a1'), undefined)
await warteAufVersand()
assert.equal(sink.sent.length, 2)
await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(180) })
await warteAufVersand()
assert.equal(produkt('a1').bestand, 3, 'Bestand nur einmal')
assert.equal(sink.sent.length, 2, 'Mails nur einmal')
assert.equal(eintraege().length, 1)
})
let kaputt = false
const kaputtSchalter = () => (kaputt = false) || setMailTransportForTests({ sent: sink.sent, sendMail: (mail) => (kaputt ? Promise.reject(new Error('smtp kaputt')) : sink.sendMail(mail)) })
test('schliesseAb I2: G09 und K25 werden einzeln nachgeholt, sobald sie scheitern, und nie doppelt verschickt', async () => {
await aufbau()
kaputtSchalter()
const b = await bestelle()
const session = zahle(b)
aw.failNext({ collection: config.collections.termineOutbox, method: 'POST', status: 500 })
kaputt = true
const erst = await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(60) })
assert.equal(erst.neu, true)
await warteAufVersand()
let daten = JSON.parse(order(b.orderId).bestellung)
assert.equal(daten.mailsKaeuferAt, null, 'G09 nicht eingereiht => offen')
assert.equal(daten.mailsHaendlerAt, null, 'K25 nicht gesendet => offen')
assert.equal(sink.sent.length, 0)
kaputt = false
await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(120) })
await warteAufVersand()
daten = JSON.parse(order(b.orderId).bestellung)
assert.ok(daten.mailsKaeuferAt && daten.mailsHaendlerAt)
assert.equal(sink.sent.length, 2)
assert.equal(produkt('a1').bestand, 3, 'Bestand blieb bei einmal abgezogen')
await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(240) })
await warteAufVersand()
assert.equal(sink.sent.length, 2)
assert.equal(eintraege().length, 1)
})
test('schliesseAb I2: ein dauerhaft scheiternder Mailschritt wird nicht endlos wiederholt', async () => {
await aufbau()
kaputtSchalter()
const b = await bestelle()
const session = zahle(b)
kaputt = true
for (let i = 0; i < 8; i += 1) await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(60 + i) })
const daten = JSON.parse(order(b.orderId).bestellung)
assert.equal(daten.mailsHaendlerAt, null)
assert.ok(daten.mailsVersuche.haendler <= 5)
assert.equal(B.abschlussAusstehend(order(b.orderId)), false, 'nach dem Versuchslimit nichts mehr ausstehend')
})
test('pruefeOffene I2: bezahlte Bestellungen mit fehlenden Schritten werden vom Minutenjob nachgeholt', async () => {
await aufbau({ produkte: [artikel('a1', { bestand: 5 })] })
const b = await bestelle([{ produktId: 'a1', menge: 2 }])
const session = zahle(b)
aw.failNext({ collection: config.collections.shopProducts, method: 'PATCH', status: 500 })
await assert.rejects(B.schliesseAb({ accountId: ACCOUNT, session, now: nach(60) }))
const z = await B.pruefeOffene(nach(60 + 6 * 60))
assert.equal(z.fehler, 0)
assert.equal(produkt('a1').bestand, 3)
await warteAufVersand()
assert.equal(sink.sent.length, 2)
})