kleinunternehmer ist ab jetzt null (nicht gewaehlt), lieferzeit leer. istVollstaendig verlangt eine bewusste Wahl (true oder false), der Shop kann sonst nicht geoeffnet werden. Im Reiter Einstellungen gibt es zwei Pflicht-Radiobuttons ohne Vorauswahl; Preise, Artikel und Muster rechnen bei null wie bei Regelbesteuerung. Tests und Fixtures angepasst. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
612 lines
31 KiB
JavaScript
612 lines
31 KiB
JavaScript
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'),
|
||
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 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', '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', telefon: '+49 170 1234567' })
|
||
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: 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', telefon: '' })
|
||
})
|
||
|
||
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)
|
||
})
|