Files
Webklar-Kundenbereich/test/shopAbschluss.test.js
T
ClaudeandClaude Opus 5.5 43473c0879 Webshop: IP-Hash entfällt mit Zahlung oder Storno, keine Käufer-Telefonnummer mehr gespeichert
schliesseAb und verwirf entfernen ipHash aus dem Bestell-JSON, sobald die Bestellung nicht mehr offen ist
(Begrenzung offener Bestellungen je IP bleibt). customer_details.phone wird nicht mehr in kaeufer.telefon
übernommen; Kundenansicht und Bestelldetail geben auch in alten Datensätzen keine Telefonnummer aus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:26:56 +00:00

664 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { mailSink } from './support/mailSink.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
import { SHOP_DATETIMES } from '../server/services/shop/schema.js'
import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
// Webshop E2: Abschluss einer bezahlten Sitzung (schliesseAb), Verwerfen, Abgleich offener Bestellungen (pruefeOffene), Minutenjob.
// Mails landen in der Senke (Kaeufer ueber termineOutbox, Haendler direkt), nichts verlaesst den Prozess.
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
shopConfig: schemaKeys('shopConfig'),
shopProducts: schemaKeys('shopProducts'),
shopOrders: schemaKeys('shopOrders'),
shopRechtstexteArchiv: schemaKeys('shopRechtstexteArchiv'),
termineOutbox: schemaKeys('termineOutbox'),
},
datetimes: { ...SHOP_DATETIMES, ...TERMINE_DATETIMES },
defaultLimit: 25,
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js')
const K = await import('../server/services/shop/kontext.js')
const { archiviereRechtstexte } = await import('../server/services/shop/archiv.js')
const B = await import('../server/services/shop/bestellungen.js')
const J = await import('../server/services/shop/jobs.js')
const { normalisiereProdukt } = await import('../server/services/shop/produkte.js')
const { STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js')
const { rechtstexteHash } = await import('../server/services/shop/rechtstexte.js')
after(async () => {
setMailTransportForTests(null)
await aw.close()
await st.close()
})
const KUNDE = 'c_kunde01'
const PROJEKT = 'p_projekt01'
const FEATURE = 'f_p_projekt01_shop'
const SITE_KEY = 'abcdefghijklmnop'
const ACCOUNT = 'acct_haendler1'
const T0 = new Date('2026-10-05T12:00:00.000Z')
const nach = (s) => new Date(T0.getTime() + s * 1000)
const PORTAL = 'https://project.webklar.com'
const EINSTELLUNGEN = {
...JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN)),
kleinunternehmer: true,
lieferzeit: '2–4 Werktage',
shopname: 'Brot & Mehr',
anbieter: { name: 'Brot & Mehr GmbH', strasse: 'Hauptstraße 5', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'shop@brot.example', telefon: '', ustId: '' },
abholung: { aktiv: true, adresse: 'Hauptstraße 5, 67655 Kaiserslautern', hinweis: 'Mo–Fr 8–12 Uhr' },
versand: { aktiv: true, laender: ['DE', 'AT'], kostenCent: { DE: 490, AT: 790 }, freiAbCent: null },
zahlungsarten: ['card'],
}
const TEXTE = { agb: 'AGB', widerrufsbelehrung: 'Widerruf', widerrufsformular: 'Formular', impressumUrl: 'https://brot.example/impressum', datenschutzUrl: 'https://brot.example/datenschutz' }
const HASH = rechtstexteHash(TEXTE, EINSTELLUNGEN)
const featureDoc = { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'shop', siteKey: SITE_KEY, state: 'active', enabled: true }
const shopDoc = (over = {}) => ({
$id: FEATURE,
customerId: KUNDE,
projectId: PROJEKT,
siteKey: SITE_KEY,
anfrageStatus: 'angenommen',
stripeAccountId: ACCOUNT,
stripeStatus: JSON.stringify({ charges_enabled: true, payouts_enabled: true }),
einstellungen: JSON.stringify(EINSTELLUNGEN),
rechtstexte: JSON.stringify({ entwurf: TEXTE, bestaetigt: { texte: TEXTE, hash: HASH, at: '2026-10-05T10:00:00.000Z', von: 'k@example.invalid' } }),
geoeffnet: true,
version: 1,
...over,
})
const artikel = (id, eingabe = {}) => {
const r = normalisiereProdukt({ name: `Artikel ${id}`, preisCent: 1250, ...eingabe }, { einstellungen: { kleinunternehmer: true } })
assert.equal(r.fehler, undefined, JSON.stringify(r.fehler))
return { $id: id, shopId: FEATURE, customerId: KUNDE, aktiv: r.produkt.aktiv, sort: 0, name: r.produkt.name, produkt: JSON.stringify(r.produkt) }
}
let sink
let log
let ctx
async function aufbau({ produkte = [artikel('a1', { bestand: 5 })], shop = {} } = {}) {
for (const c of ['portalFeatures', 'shopConfig', 'shopProducts', 'shopOrders', 'shopRechtstexteArchiv', 'websiteProjects', 'termineOutbox', 'customers']) {
for (const id of [...aw.docs(c).keys()]) await deleteDocument(config.collections[c], id)
}
aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Brotladen', liveDomain: 'https://www.brot.example' })
aw.seed('portalFeatures', featureDoc)
aw.seed('shopConfig', shopDoc(shop))
aw.seed('customers', { $id: KUNDE, name: 'Erika Muster', email: 'erika@brot.example' })
for (const p of produkte) aw.seed('shopProducts', p)
st.setAccount(ACCOUNT, { charges_enabled: true, details_submitted: true, requirements: { currently_due: [] } })
K.leereCache()
ctx = await K.shopPerSiteKey(SITE_KEY, { now: T0 })
return ctx
}
beforeEach(() => {
st.calls.length = 0
config.publicBaseUrl = PORTAL
config.smtp.from = 'noreply@webklar.com'
config.shop.mode = 'on'
resetOutboxForTests()
sink = mailSink()
setMailTransportForTests(sink)
log = { warn: mock.method(console, 'warn', () => {}), error: mock.method(console, 'error', () => {}) }
K.leereCache()
})
afterEach(async () => {
await warteAufVersand()
mock.restoreAll()
})
const orders = () => [...aw.docs('shopOrders').values()]
const order = (id) => aw.docs('shopOrders').get(id)
const produkt = (id) => JSON.parse(aw.docs('shopProducts').get(id).produkt)
const eintraege = () => [...aw.docs('termineOutbox').values()]
const warnLog = () => log.warn.mock.calls.map((a) => a.arguments.map(String).join(' ')).join('\n')
const kunde = { name: 'Max Mustermann', email: 'max@example.com', phone: '+49 170 1234567', address: { line1: 'Rechnungsweg 1', line2: null, postal_code: '12345', city: 'Musterstadt', country: 'DE' } }
const versandAn = { name: 'Max Mustermann', address: { line1: 'Lieferweg 7', line2: 'Hinterhaus', postal_code: '67655', city: 'Kaiserslautern', country: 'DE', state: null } }
/** Bestellung anlegen (offen) und die zugehoerige Stripe-Sitzung liefern. */
async function bestelle(positionen = [{ produktId: 'a1', menge: 2 }], over = {}) {
const r = await B.legeBestellungAn(ctx, { positionen, lieferart: 'versand', land: 'DE', ...over }, { now: T0 })
const doc = order(r.orderId)
return { ...r, doc, sessionId: doc.stripeSessionId }
}
/** Sitzung als bezahlt markieren und so zurueckgeben, wie der Webhook sie schickt. */
const zahle = (b, felder = {}) =>
st.completeSession(ACCOUNT, b.sessionId, {
payment_status: 'paid',
amount_total: b.doc.summeCent,
customer_details: kunde,
shipping: versandAn,
payment_intent: 'pi_test1',
payment_method_types: ['card'],
...felder,
})
test('schliesseAb: Normalfall - bezahlt, Käuferdaten, Bestand abgezogen, G09 an den Käufer und K25 an den Händler ohne BCC', async () => {
await aufbau()
const b = await bestelle()
const session = zahle(b)
const erg = await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(120) })
assert.equal(erg.neu, true)
assert.equal(erg.order.status, 'bezahlt')
const doc = order(b.orderId)
assert.equal(doc.status, 'bezahlt')
assert.equal(new Date(doc.bezahltAt).toISOString(), nach(120).toISOString())
assert.equal(doc.paymentIntentId, 'pi_test1')
assert.equal(doc.kaeuferEmail, 'max@example.com')
const daten = JSON.parse(doc.bestellung)
assert.deepEqual(daten.kaeufer, { name: 'Max Mustermann', email: 'max@example.com' }, 'Stripe liefert eine Telefonnummer, gespeichert wird sie nicht')
assert.ok(!doc.bestellung.includes('1234567'), 'keine Telefonnummer irgendwo im Bestell-JSON')
assert.deepEqual(daten.lieferadresse, { name: 'Max Mustermann', line1: 'Lieferweg 7', line2: 'Hinterhaus', postal_code: '67655', city: 'Kaiserslautern', country: 'DE' })
assert.equal(daten.rechnungsadresse.line1, 'Rechnungsweg 1')
assert.equal(daten.zahlungsart, 'card')
assert.deepEqual(daten.verlauf.map((v) => v.was), ['angelegt', 'bezahlt'])
assert.equal(daten.ueberverkauft, undefined)
assert.equal(produkt('a1').bestand, 3)
await warteAufVersand()
const [g09] = eintraege()
assert.equal(eintraege().length, 1)
assert.equal(g09.art, 'G09')
assert.equal(g09.bookingId, b.orderId)
assert.equal(g09.featureId, FEATURE)
const mails = sink.sent
assert.equal(mails.length, 2)
const kaeufer = mails.find((m) => m.to[0].address === 'max@example.com')
const haendler = mails.find((m) => m.to[0].address === 'erika@brot.example')
assert.ok(kaeufer && haendler)
assert.match(kaeufer.subject, new RegExp(`Ihre Bestellung ${doc.bestellnummer} bei Brot & Mehr`))
assert.ok(kaeufer.text.includes('Lieferweg 7') && kaeufer.text.includes('Hinterhaus'))
assert.match(haendler.text, /Max Mustermann/)
assert.ok(!haendler.bcc && !kaeufer.bcc, 'kein BCC')
assert.doesNotMatch(haendler.text, /Bestand reichte nicht/)
assert.doesNotMatch(haendler.text, /Lastschrift/)
assert.equal(order(b.orderId).status, 'bezahlt')
})
test('schliesseAb I-6: G09 enthält die Rechtstexte der Bestellung (Archiv zum Hash), auch wenn der Händler sie danach geändert und neu bestätigt hat', async () => {
await aufbau()
const b = await bestelle()
await archiviereRechtstexte({ shopId: FEATURE, customerId: KUNDE, hash: HASH, at: T0.toISOString(), von: 'k@example.invalid', texte: { ...TEXTE, agb: 'AGB ZUM ZEITPUNKT DER BESTELLUNG', versandUndZahlung: 'x', gewaehrleistung: 'GEWÄHRLEISTUNG DAMALS' } })
// neue Fassung bestätigt, bevor bezahlt wird (SEPA: Tage später)
const neu = { ...TEXTE, agb: 'NEUE AGB NACH DER BESTELLUNG' }
aw.seed('shopConfig', shopDoc({ rechtstexte: JSON.stringify({ entwurf: neu, bestaetigt: { texte: neu, hash: 'e'.repeat(64), at: '2026-10-06T10:00:00.000Z', von: 'k@example.invalid' } }) }))
K.leereCache()
assert.equal(JSON.parse(order(b.orderId).bestellung).rechtstexteHash, HASH)
await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b), now: nach(120) })
await warteAufVersand()
const g09 = sink.sent.find((m) => m.to[0].address === 'max@example.com')
assert.match(g09.text, /AGB ZUM ZEITPUNKT DER BESTELLUNG/)
assert.match(g09.text, /GEWÄHRLEISTUNG DAMALS/)
assert.doesNotMatch(g09.text, /NEUE AGB/)
})
test('schliesseAb: idempotent - zweiter Aufruf ändert nichts, keine zweite Mail, Bestand nur einmal', async () => {
await aufbau()
const b = await bestelle()
const session = zahle(b)
const [a, z] = await Promise.all([B.schliesseAb({ accountId: ACCOUNT, session, now: nach(60) }), B.schliesseAb({ accountId: ACCOUNT, session, now: nach(61) })])
assert.deepEqual([a.neu, z.neu].sort(), [false, true])
const dritte = await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(300) })
assert.equal(dritte.neu, false)
assert.equal(dritte.order.status, 'bezahlt')
await warteAufVersand()
assert.equal(eintraege().length, 1)
assert.equal(sink.sent.length, 2)
assert.equal(produkt('a1').bestand, 3)
assert.equal(JSON.parse(order(b.orderId).bestellung).verlauf.filter((v) => v.was === 'bezahlt').length, 1)
})
test('schliesseAb: falsches Konto, fremde oder unbekannte Sitzung ändern nichts', async () => {
await aufbau()
const b = await bestelle()
const session = zahle(b)
assert.deepEqual(await B.schliesseAb({ accountId: 'acct_fremd', session, now: nach(60) }), { order: null, neu: false })
assert.match(warnLog(), /gehört nicht zum Stripe-Konto/)
assert.deepEqual(await B.schliesseAb({ accountId: ACCOUNT, session: { ...session, metadata: { ...session.metadata, wkShop: undefined } }, now: nach(60) }), { order: null, neu: false })
assert.deepEqual(await B.schliesseAb({ accountId: ACCOUNT, session: { ...session, metadata: { wkShop: '1', orderId: 'gibt-es-nicht' } }, now: nach(60) }), { order: null, neu: false })
assert.deepEqual(await B.schliesseAb({ accountId: ACCOUNT, session: { ...session, metadata: { wkShop: '1' } }, now: nach(60) }), { order: null, neu: false })
assert.deepEqual(await B.schliesseAb({ accountId: ACCOUNT, session: null, now: nach(60) }), { order: null, neu: false })
assert.equal(order(b.orderId).status, 'offen')
assert.equal(produkt('a1').bestand, 5)
await warteAufVersand()
assert.equal(sink.sent.length, 0)
})
test('schliesseAb: SEPA noch nicht bezahlt ist wirkungslos, bis async_payment_succeeded kommt; SEPA-Hinweis in K25', async () => {
await aufbau()
const b = await bestelle()
const unbezahlt = zahle(b, { payment_status: 'unpaid', payment_method_types: ['sepa_debit'] })
const erg = await B.schliesseAb({ accountId: ACCOUNT, session: unbezahlt, now: nach(60) })
assert.equal(erg.neu, false)
assert.equal(erg.order.status, 'offen')
assert.equal(produkt('a1').bestand, 5)
await warteAufVersand()
assert.equal(sink.sent.length, 0)
const bezahlt = zahle(b, { payment_status: 'paid', payment_method_types: ['sepa_debit'] })
const spaeter = await B.schliesseAb({ accountId: ACCOUNT, session: bezahlt, now: nach(3 * 86400) })
assert.equal(spaeter.neu, true)
assert.equal(JSON.parse(order(b.orderId).bestellung).zahlungsart, 'sepa_debit')
await warteAufVersand()
assert.match(sink.sent.find((m) => m.to[0].address === 'erika@brot.example').text, /Lastschrift/)
})
test('meldeEingang I-1: nur bei unbezahlter, abgeschlossener Sitzung einer offenen Bestellung - einmal G15 an die Adresse der Sitzung, Marker, kein Vertragstext', async () => {
await aufbau()
const b = await bestelle()
const unbezahlt = zahle(b, { payment_status: 'unpaid', payment_method_types: ['sepa_debit'] })
assert.deepEqual(await B.meldeEingang({ accountId: ACCOUNT, session: { ...unbezahlt, payment_status: 'paid' }, now: nach(60) }), { gesendet: false }, 'bezahlt ist Sache von schliesseAb')
assert.deepEqual(await B.meldeEingang({ accountId: 'acct_fremd', session: unbezahlt, now: nach(60) }), { gesendet: false }, 'fremdes Konto')
assert.deepEqual(await B.meldeEingang({ accountId: ACCOUNT, session: { ...unbezahlt, customer_details: null }, now: nach(60) }), { gesendet: false }, 'ohne Adresse')
await warteAufVersand()
assert.equal(sink.sent.length, 0)
assert.deepEqual(await B.meldeEingang({ accountId: ACCOUNT, session: unbezahlt, now: nach(60) }), { gesendet: true })
assert.deepEqual(await B.meldeEingang({ accountId: ACCOUNT, session: unbezahlt, now: nach(120) }), { gesendet: false }, 'nur einmal')
await warteAufVersand()
assert.equal(sink.sent.length, 1)
assert.equal(sink.sent[0].to[0].address, 'max@example.com')
assert.match(sink.sent[0].subject, /^Wir haben Ihre Bestellung \S+ erhalten$/)
assert.doesNotMatch(sink.sent[0].text, /Kaufvertrag ist damit geschlossen/)
assert.equal(order(b.orderId).status, 'offen')
assert.equal(JSON.parse(order(b.orderId).bestellung).mailsEingangAt, nach(60).toISOString())
// Später bezahlt: G09 folgt, der Marker bleibt erhalten
await B.schliesseAb({ accountId: ACCOUNT, session: { ...unbezahlt, payment_status: 'paid' }, now: nach(3 * 86400) })
await warteAufVersand()
assert.equal(JSON.parse(order(b.orderId).bestellung).mailsEingangAt, nach(60).toISOString())
assert.deepEqual(await B.meldeEingang({ accountId: ACCOUNT, session: unbezahlt, now: nach(4 * 86400) }), { gesendet: false }, 'nicht mehr offen')
})
test('meldeEingang I-1: scheitert das Einreihen, bleibt der Marker leer und die Versuche werden gezählt (höchstens 5)', async () => {
await aufbau()
const b = await bestelle()
const unbezahlt = zahle(b, { payment_status: 'unpaid', payment_method_types: ['sepa_debit'] })
await deleteDocument(config.collections.portalFeatures, FEATURE) // Shop-Kontext fehlt: das Einreihen scheitert
K.leereCache()
for (let i = 0; i < 7; i += 1) assert.deepEqual(await B.meldeEingang({ accountId: ACCOUNT, session: unbezahlt, now: nach(60 + i) }), { gesendet: false })
const d = JSON.parse(order(b.orderId).bestellung)
assert.equal(d.mailsEingangAt, undefined)
assert.equal(d.mailsVersuche.eingang, 5)
assert.match(warnLog(), /G15 zur Bestellung/)
})
test('pruefeOffene I-1: SEPA unterwegs - der Minutenjob holt die Eingangsmail G15 nach, ein zweiter Lauf schickt keine zweite', async () => {
await aufbau()
const sepa = await bestelle([{ produktId: 'a1', menge: 1 }])
zahle(sepa, { payment_status: 'unpaid', payment_method_types: ['sepa_debit'] })
const spaet = nach(30 * 60 + 30 + 6 * 60)
const z = await B.pruefeOffene(spaet)
assert.deepEqual(z, { geprueft: 1, bezahlt: 0, verworfen: 0, wartend: 1, fehler: 0 })
await warteAufVersand()
assert.equal(sink.sent.length, 1)
assert.equal(sink.sent[0].to[0].address, 'max@example.com')
await B.pruefeOffene(nach(30 * 60 + 30 + 8 * 60))
await warteAufVersand()
assert.equal(sink.sent.length, 1)
assert.equal(order(sepa.orderId).status, 'offen')
})
test('schliesseAb: späte Zahlung nach Ablauf wird angenommen, andere Stornos nicht', async () => {
await aufbau()
const a = await bestelle()
assert.equal((await B.verwirf(a.orderId, 'abgelaufen', { now: nach(1900) })).verworfen, true)
assert.equal(order(a.orderId).status, 'storniert')
const erg = await B.schliesseAb({ accountId: ACCOUNT, session: zahle(a), now: nach(2000) })
assert.equal(erg.neu, true)
assert.equal(order(a.orderId).status, 'bezahlt')
const daten = JSON.parse(order(a.orderId).bestellung)
assert.equal(daten.spaet, true)
assert.deepEqual(daten.verlauf.map((v) => v.was), ['angelegt', 'abgelaufen', 'bezahlt'])
assert.equal(produkt('a1').bestand, 3)
const c = await bestelle([{ produktId: 'a1', menge: 1 }])
assert.equal((await B.verwirf(c.orderId, 'zahlung_fehlgeschlagen', { now: nach(10) })).verworfen, true)
const nein = await B.schliesseAb({ accountId: ACCOUNT, session: zahle(c), now: nach(20) })
assert.equal(nein.neu, false)
assert.equal(order(c.orderId).status, 'storniert')
assert.equal(produkt('a1').bestand, 3)
})
test('schliesseAb: abweichender Betrag - trotzdem bezahlt, Vermerk im Verlauf, Warnung ohne Käuferdaten', async () => {
await aufbau()
const b = await bestelle()
const erg = await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b, { amount_total: b.doc.summeCent + 100 }), now: nach(60) })
assert.equal(erg.neu, true)
const daten = JSON.parse(order(b.orderId).bestellung)
const vermerk = daten.verlauf.find((v) => v.was === 'betrag_abweichend')
assert.deepEqual({ e: vermerk.erwartetCent, b: vermerk.bezahltCent }, { e: b.doc.summeCent, b: b.doc.summeCent + 100 })
assert.match(warnLog(), /weicht vom Bestellwert ab/)
assert.doesNotMatch(warnLog(), /max@example|Mustermann|Lieferweg/)
})
test('schliesseAb: Variantenbestand wird in der Variante abgezogen, Artikel ohne Bestand bleiben unberührt', async () => {
await aufbau({
produkte: [
artikel('a1', { bestand: 10, varianten: { name: 'Größe', werte: [{ name: 'M', preisCent: 2000, bestand: 4 }, { name: 'L', preisCent: 2200, bestand: 6 }] } }),
artikel('a2'),
],
})
const werte = produkt('a1').varianten.werte
const L = werte.find((w) => w.name === 'L')
const M = werte.find((w) => w.name === 'M')
const b = await bestelle([{ produktId: 'a1', varianteId: L.id, menge: 2 }, { produktId: 'a1', varianteId: M.id, menge: 1 }, { produktId: 'a2', menge: 3 }])
await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b), now: nach(60) })
const danach = produkt('a1')
assert.equal(danach.varianten.werte.find((w) => w.name === 'L').bestand, 4)
assert.equal(danach.varianten.werte.find((w) => w.name === 'M').bestand, 3)
assert.equal(danach.bestand, null, 'bei Varianten führen die Varianten den Bestand')
assert.equal(produkt('a2').bestand, null)
})
test('schliesseAb: Bestand reicht nicht - auf 0 gesetzt, Händler-Hinweis ueberverkauft in K25', async () => {
await aufbau()
const b = await bestelle([{ produktId: 'a1', menge: 4 }])
// Der Haendler hat den Bestand inzwischen verringert
const p = produkt('a1')
p.bestand = 1
await updateDocument(config.collections.shopProducts, 'a1', { produkt: JSON.stringify(p) })
const erg = await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b), now: nach(60) })
assert.equal(erg.neu, true)
assert.equal(produkt('a1').bestand, 0)
assert.equal(JSON.parse(order(b.orderId).bestellung).ueberverkauft, true)
await warteAufVersand()
assert.match(sink.sent.find((m) => m.to[0].address === 'erika@brot.example').text, /Bestand reichte nicht/)
assert.equal(order(b.orderId).status, 'bezahlt')
})
test('schliesseAb: Mailfehler machen die Zahlung nicht rückgängig', async () => {
await aufbau()
const b = await bestelle()
aw.docs('customers').clear()
const erg = await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b), now: nach(60) })
assert.equal(erg.neu, true)
assert.equal(order(b.orderId).status, 'bezahlt')
assert.equal(produkt('a1').bestand, 3)
})
test('schliesseAb: Abholung ohne Lieferadresse, Telefon und Namen fehlen', async () => {
await aufbau()
const b = await bestelle([{ produktId: 'a1', menge: 1 }], { lieferart: 'abholung', land: undefined })
const erg = await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b, { shipping: null, customer_details: { email: 'x@example.com' } }), now: nach(60) })
assert.equal(erg.neu, true)
const daten = JSON.parse(order(b.orderId).bestellung)
assert.equal(daten.lieferadresse, undefined)
assert.equal(daten.rechnungsadresse, undefined)
assert.deepEqual(daten.kaeufer, { name: '', email: 'x@example.com' })
})
test('IP-Hash: nach der Zahlung weg (auch bei später Zahlung einer verworfenen Bestellung), vorher da', async () => {
await aufbau()
const r = await B.legeBestellungAn(ctx, { positionen: [{ produktId: 'a1', menge: 1 }], lieferart: 'versand', land: 'DE' }, { now: T0, ipHash: 'abcdef0123456789' })
const b = { ...r, doc: order(r.orderId), sessionId: order(r.orderId).stripeSessionId }
assert.equal(JSON.parse(b.doc.bestellung).ipHash, 'abcdef0123456789', 'solange offen, steht der IP-Hash im Datensatz')
await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b), now: nach(60) })
assert.equal(order(b.orderId).status, 'bezahlt')
assert.equal(JSON.parse(order(b.orderId).bestellung).ipHash, undefined, 'bezahlt: kein IP-Hash mehr')
assert.ok(!order(b.orderId).bestellung.includes('ipHash'))
// spaete Zahlung: verworfen (IP-Hash weg), danach bezahlt - bleibt ohne IP-Hash
const r2 = await B.legeBestellungAn(ctx, { positionen: [{ produktId: 'a1', menge: 1 }], lieferart: 'versand', land: 'DE' }, { now: T0, ipHash: 'abcdef0123456789' })
const b2 = { ...r2, doc: order(r2.orderId), sessionId: order(r2.orderId).stripeSessionId }
await B.verwirf(r2.orderId, 'abgelaufen', { now: nach(1900) })
await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b2), now: nach(2000) })
assert.equal(order(r2.orderId).status, 'bezahlt')
assert.ok(!order(r2.orderId).bestellung.includes('ipHash'))
})
test('IP-Hash: verwirf entfernt ihn, Verlauf und übrige Daten bleiben', async () => {
await aufbau()
const r = await B.legeBestellungAn(ctx, { positionen: [{ produktId: 'a1', menge: 1 }], lieferart: 'versand', land: 'DE' }, { now: T0, ipHash: 'abcdef0123456789' })
assert.equal(JSON.parse(order(r.orderId).bestellung).ipHash, 'abcdef0123456789')
const erg = await B.verwirf(r.orderId, 'abgelaufen', { now: nach(1900) })
assert.equal(erg.verworfen, true)
const daten = JSON.parse(order(r.orderId).bestellung)
assert.equal(daten.ipHash, undefined)
assert.equal(erg.order.daten.ipHash, undefined)
assert.deepEqual(daten.verlauf.map((v) => v.was), ['angelegt', 'abgelaufen'])
assert.equal(daten.positionen.length, 1)
})
test('verwirf: nur aus offen; Verlauf, Reservierung frei; G14 nur mit mail und bekannter Adresse', async () => {
await aufbau()
const a = await bestelle()
assert.equal((await B.reserviert(FEATURE, nach(10))).get('a1'), 2)
const erg = await B.verwirf(a.orderId, 'abgelaufen', { now: nach(10) })
assert.equal(erg.verworfen, true)
assert.equal(erg.order.status, 'storniert')
assert.equal(JSON.parse(order(a.orderId).bestellung).verlauf.at(-1).was, 'abgelaufen')
assert.equal((await B.reserviert(FEATURE, nach(10))).get('a1'), undefined)
assert.equal((await B.verwirf(a.orderId, 'abgelaufen', { now: nach(11) })).verworfen, false, 'zweites Mal wirkungslos')
assert.equal((await B.verwirf('gibt-es-nicht', 'abgelaufen')).order, null)
await warteAufVersand()
assert.equal(sink.sent.length, 0)
// bezahlt wird nie verworfen
const bz = await bestelle()
await B.schliesseAb({ accountId: ACCOUNT, session: zahle(bz), now: nach(20) })
assert.equal((await B.verwirf(bz.orderId, 'abgelaufen', { now: nach(30) })).verworfen, false)
assert.equal(order(bz.orderId).status, 'bezahlt')
// falsches Konto
const f = await bestelle([{ produktId: 'a1', menge: 1 }])
assert.equal((await B.verwirf(f.orderId, 'abgelaufen', { accountId: 'acct_fremd' })).verworfen, false)
assert.equal(order(f.orderId).status, 'offen')
// G14 mit der Adresse aus der Sitzung
sink.sent.length = 0
const g = await bestelle([{ produktId: 'a1', menge: 1 }])
const v = await B.verwirf(g.orderId, 'zahlung_fehlgeschlagen', { now: nach(40), mail: true, email: 'max@example.com', accountId: ACCOUNT })
assert.equal(v.verworfen, true)
await warteAufVersand()
const g14 = eintraege().filter((e) => e.art === 'G14')
assert.equal(g14.length, 1)
assert.equal(sink.sent.length, 1)
assert.equal(sink.sent[0].to[0].address, 'max@example.com')
// ohne Adresse keine Mail, kein Fehler
const h = await bestelle([{ produktId: 'a1', menge: 1 }])
assert.equal((await B.verwirf(h.orderId, 'zahlung_fehlgeschlagen', { mail: true })).verworfen, true)
await warteAufVersand()
assert.equal(eintraege().filter((e) => e.art === 'G14').length, 1)
})
test('pruefeOffene: bezahlt abschließen, abgelaufene und noch offene Sitzungen verwerfen, Lesefehler und junge Bestellungen überspringen', async () => {
await aufbau({ produkte: [artikel('a1', { bestand: 20 })] })
const bezahlt = await bestelle([{ produktId: 'a1', menge: 1 }])
const abgelaufen = await bestelle([{ produktId: 'a1', menge: 1 }])
const offen = await bestelle([{ produktId: 'a1', menge: 1 }])
const sepa = await bestelle([{ produktId: 'a1', menge: 1 }])
const fehler = await bestelle([{ produktId: 'a1', menge: 1 }])
zahle(bezahlt)
st.accountSessions(ACCOUNT).set(abgelaufen.sessionId, { ...st.accountSessions(ACCOUNT).get(abgelaufen.sessionId), status: 'expired' })
zahle(sepa, { payment_status: 'unpaid' })
const spaet = nach(30 * 60 + 30 + 6 * 60) // Frist + 6 Minuten
const jung = await (async () => {
const x = await B.legeBestellungAn(ctx, { positionen: [{ produktId: 'a1', menge: 1 }], lieferart: 'versand', land: 'DE' }, { now: nach(10 * 60) })
return x
})()
st.failNext({ method: 'GET', path: `/v1/checkout/sessions/${fehler.sessionId}`, status: 400 })
st.calls.length = 0
const z = await B.pruefeOffene(spaet)
assert.deepEqual(z, { geprueft: 5, bezahlt: 1, verworfen: 2, wartend: 1, fehler: 1 })
assert.equal(order(bezahlt.orderId).status, 'bezahlt')
assert.equal(order(abgelaufen.orderId).status, 'storniert')
assert.equal(order(offen.orderId).status, 'storniert')
assert.ok(st.calls.some((c) => c.method === 'POST' && c.path === `/v1/checkout/sessions/${offen.sessionId}/expire` && c.account === ACCOUNT))
assert.equal(st.calls.some((c) => c.path.endsWith('/expire') && c.path.includes(abgelaufen.sessionId)), false, 'bereits abgelaufene Sitzung wird nicht erneut beendet')
assert.equal(order(sepa.orderId).status, 'offen', 'SEPA unterwegs bleibt offen')
assert.equal(order(fehler.orderId).status, 'offen', 'Lesefehler: nächster Lauf')
assert.equal(order(jung.orderId).status, 'offen', 'Frist noch nicht um mehr als 5 Minuten verstrichen')
assert.match(warnLog(), /Abgleich der Bestellung/)
assert.equal(produkt('a1').bestand, 19)
// Nächster Lauf: der Lesefehler ist weg
const z2 = await B.pruefeOffene(spaet)
assert.deepEqual({ ...z2 }, { geprueft: 2, bezahlt: 0, verworfen: 1, wartend: 1, fehler: 0 })
assert.equal(order(fehler.orderId).status, 'storniert')
})
test('pruefeOffene: Bestellung ohne gespeicherte Sitzung wird verworfen; ohne offene Bestellungen passiert nichts', async () => {
await aufbau()
assert.deepEqual(await B.pruefeOffene(nach(7200)), { geprueft: 0, bezahlt: 0, verworfen: 0, wartend: 0, fehler: 0 })
const b = await bestelle()
await updateDocument(config.collections.shopOrders, b.orderId, { stripeSessionId: '' })
const z = await B.pruefeOffene(nach(7200))
assert.equal(z.verworfen, 1)
assert.equal(order(b.orderId).status, 'storniert')
})
test('minutenJob: bei SHOP_ENABLED=off aus, sonst Abgleich; läuft nicht doppelt gleichzeitig', async () => {
await aufbau()
const b = await bestelle()
zahle(b)
config.shop.mode = 'off'
assert.deepEqual(await J.minutenJob(nach(7200)), { aus: true })
assert.equal(order(b.orderId).status, 'offen')
config.shop.mode = 'pilot'
const [erster, zweiter] = await Promise.all([J.minutenJob(nach(7200)), J.minutenJob(nach(7200))])
assert.ok(erster.bezahlt === 1 || zweiter.bezahlt === 1)
assert.ok(erster.uebersprungen === true || zweiter.uebersprungen === true)
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)
})