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:
1 parent
42a6bb5a4a
commit
9c2897a0b0
4 files changed
+305
-83
No files matched your search
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user