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>
438 lines
25 KiB
JavaScript
438 lines
25 KiB
JavaScript
import { test, after, beforeEach } from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||
import { startFakeStripe } from './support/fakeStripe.js'
|
||
import { useTestEnv } from './support/testEnv.js'
|
||
import { schemaKeys } from './support/schema.js'
|
||
import { SHOP_DATETIMES } from '../server/services/shop/schema.js'
|
||
|
||
// Bestellung anlegen (E1): Berechnung nur aus der Datenbank, Reservierung unter Sperre, Stripe-Parameter auf dem Haendlerkonto,
|
||
// Rueck-URL-Pruefung, Fehlerwege (geschlossen, ausverkauft, Stripe-Fehler, Klarna nicht freigeschaltet).
|
||
const aw = await startAppwriteMock({
|
||
schema: {
|
||
portalFeatures: schemaKeys('portalFeatures'),
|
||
shopConfig: schemaKeys('shopConfig'),
|
||
shopProducts: schemaKeys('shopProducts'),
|
||
shopOrders: schemaKeys('shopOrders'),
|
||
},
|
||
datetimes: SHOP_DATETIMES,
|
||
})
|
||
const st = await startFakeStripe()
|
||
after(async () => {
|
||
await aw.close()
|
||
await st.close()
|
||
})
|
||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||
|
||
const { config } = await import('../server/config.js')
|
||
const { withLocks } = await import('../server/services/keyedLock.js')
|
||
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||
const K = await import('../server/services/shop/kontext.js')
|
||
const B = await import('../server/services/shop/bestellungen.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')
|
||
|
||
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 = {}, einstellungen = EINSTELLUNGEN) => ({
|
||
$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, produktEingabe = {}) => {
|
||
const r = normalisiereProdukt({ name: `Artikel ${id}`, preisCent: 1250, ...produktEingabe }, { 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) }
|
||
}
|
||
|
||
async function aufbau({ shop = {}, einstellungen = EINSTELLUNGEN, produkte = [artikel('a1')], projekt = {} } = {}) {
|
||
for (const c of ['portalFeatures', 'shopConfig', 'shopProducts', 'shopOrders', 'websiteProjects']) {
|
||
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', ...projekt })
|
||
aw.seed('portalFeatures', featureDoc)
|
||
aw.seed('shopConfig', shopDoc(shop, einstellungen))
|
||
for (const p of produkte) aw.seed('shopProducts', p)
|
||
st.setAccount(ACCOUNT, { charges_enabled: true, details_submitted: true, requirements: { currently_due: [] } })
|
||
K.leereCache()
|
||
return K.shopPerSiteKey(SITE_KEY, { now: T0 })
|
||
}
|
||
beforeEach(() => {
|
||
st.calls.length = 0
|
||
config.publicBaseUrl = PORTAL
|
||
K.leereCache()
|
||
})
|
||
|
||
const sessionCalls = () => st.calls.filter((c) => c.path === '/v1/checkout/sessions' && c.method === 'POST')
|
||
const orders = () => [...aw.docs('shopOrders').values()]
|
||
const eingabe = (over = {}) => ({ positionen: [{ produktId: 'a1', menge: 2 }], lieferart: 'versand', land: 'DE', rueckUrl: 'https://www.brot.example/laden?x=1#warenkorb', ...over })
|
||
const rejectsMit = (promise, status, code) =>
|
||
assert.rejects(promise, (err) => {
|
||
assert.equal(err.status, status, err.message)
|
||
assert.equal(err.code, code)
|
||
return true
|
||
})
|
||
|
||
test('neueBestellnummer: JJ-XXXXXX ohne 0, 1, O, I; Jahr nach Berliner Zeit', () => {
|
||
for (let i = 0; i < 200; i += 1) assert.match(B.neueBestellnummer(T0), /^26-[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/)
|
||
// Silvester 23:30 UTC ist in Berlin schon Neujahr
|
||
assert.match(B.neueBestellnummer(new Date('2026-12-31T23:30:00.000Z')), /^27-/)
|
||
assert.match(B.neueBestellnummer(new Date('2026-12-31T22:30:00.000Z')), /^26-/)
|
||
})
|
||
|
||
test('tokenPasst: nur der passende Token, kein Hash als Token', () => {
|
||
const token = 'abc123'
|
||
const order = { tokenHash: B.tokenHash(token) }
|
||
assert.equal(B.tokenPasst(order, token), true)
|
||
assert.equal(B.tokenPasst(order, 'abc124'), false)
|
||
assert.equal(B.tokenPasst(order, order.tokenHash), false)
|
||
assert.equal(B.tokenPasst(order, ''), false)
|
||
assert.equal(B.tokenPasst(order, undefined), false)
|
||
assert.equal(B.tokenPasst({}, token), false)
|
||
assert.equal(B.tokenPasst({ tokenHash: '' }, ''), false)
|
||
})
|
||
|
||
test('pruefeRueckUrl: eigene Hosts und Portal ja, fremde, http, Zugangsdaten nein; wk_* und Fragment fallen weg', async () => {
|
||
const ctx = await aufbau()
|
||
const fallback = `${PORTAL}/shop/${SITE_KEY}`
|
||
const p = (u) => B.pruefeRueckUrl(u, ctx)
|
||
assert.equal(p('https://www.brot.example/laden?x=1&wk_t=alt&wk_abbruch=1#top'), 'https://www.brot.example/laden?x=1')
|
||
assert.equal(p('https://brot.example/'), fallback) // Apex nur, wenn das Projekt weitere Hosts hat (analyseDomains)
|
||
assert.equal(p(`${PORTAL}/shop/${SITE_KEY}/seite`), `${PORTAL}/shop/${SITE_KEY}/seite`)
|
||
for (const schlecht of ['https://boese.example/laden', 'http://www.brot.example/laden', 'https://www.brot.example.boese.example/', 'https://user:pw@www.brot.example/', 'https://www.brot.example:8443/', 'javascript:alert(1)', '//boese.example', '/relativ', '', undefined, null, 42, 'x'.repeat(3000)]) {
|
||
assert.equal(p(schlecht), fallback, String(schlecht).slice(0, 40))
|
||
}
|
||
// Ohne Projekt gilt nur das Portal
|
||
assert.equal(B.pruefeRueckUrl('https://www.brot.example/', { ...ctx, project: null }), fallback)
|
||
// Portal-Adresse http (Tests): http auf dem Portal-Host erlaubt, auf fremden Hosts nicht
|
||
config.publicBaseUrl = 'http://127.0.0.1:4000'
|
||
assert.equal(B.pruefeRueckUrl('http://127.0.0.1:4000/shop/x?wk_t=1', ctx), 'http://127.0.0.1:4000/shop/x')
|
||
assert.equal(B.pruefeRueckUrl('http://www.brot.example/', ctx), 'http://127.0.0.1:4000/shop/' + SITE_KEY)
|
||
})
|
||
|
||
test('M9: Wartezeit auf die Bestandssperre verkuerzt die Sitzungsdauer nicht - expires_at und ablaufAt werden vor dem Stripe-Aufruf nachgefuehrt', async () => {
|
||
const ctx = await aufbau()
|
||
let los
|
||
const gehalten = new Promise((r) => (los = r))
|
||
const sperre = withLocks([`shop-bestand:${FEATURE}`], () => gehalten)
|
||
const lauf = B.legeBestellungAn(ctx, eingabe(), { now: T0 })
|
||
await new Promise((r) => setTimeout(r, 2100))
|
||
los()
|
||
await sperre
|
||
await lauf
|
||
const [call] = sessionCalls()
|
||
const ende = Number(call.body.expires_at)
|
||
const grundwert = Math.ceil(T0.getTime() / 1000) + 30 * 60 + 30
|
||
assert.ok(ende >= grundwert + 2, `expires_at ${ende} soll mindestens 2 s nach ${grundwert} liegen`)
|
||
assert.ok(ende <= grundwert + 6)
|
||
assert.equal(new Date(orders()[0].ablaufAt).getTime(), ende * 1000, 'ablaufAt = Ende der Stripe-Sitzung')
|
||
})
|
||
|
||
test('Normalfall: Bestellung offen, Stripe-Kasse auf dem Haendlerkonto mit allen Parametern', async () => {
|
||
const ctx = await aufbau()
|
||
const r = await B.legeBestellungAn(ctx, eingabe(), { now: T0 })
|
||
assert.match(r.bestellnummer, /^26-[A-Z2-9]{6}$/)
|
||
assert.match(r.url, /^https:\/\/checkout\.stripe\.test\/c\/pay\/cs_/)
|
||
|
||
const [order] = orders()
|
||
assert.equal(order.$id, r.orderId)
|
||
assert.equal(order.status, 'offen')
|
||
assert.equal(order.bestellnummer, r.bestellnummer)
|
||
assert.equal(order.shopId, FEATURE)
|
||
assert.equal(order.customerId, KUNDE)
|
||
assert.equal(order.summeCent, 2 * 1250 + 490)
|
||
assert.equal(new Date(order.erstelltAt).toISOString(), T0.toISOString())
|
||
assert.equal(new Date(order.ablaufAt).getTime(), T0.getTime() + (30 * 60 + 30) * 1000)
|
||
assert.match(order.stripeSessionId, /^cs_/)
|
||
assert.match(order.tokenHash, /^[0-9a-f]{64}$/)
|
||
const daten = JSON.parse(order.bestellung)
|
||
assert.equal(daten.lieferart, 'versand')
|
||
assert.equal(daten.land, 'DE')
|
||
assert.equal(daten.positionen[0].menge, 2)
|
||
assert.deepEqual({ z: daten.summen.zwischensummeCent, v: daten.summen.versandCent, g: daten.summen.gesamtCent }, { z: 2500, v: 490, g: 2990 })
|
||
assert.equal(daten.anbieter.name, 'Brot & Mehr GmbH')
|
||
assert.equal(daten.shopname, 'Brot & Mehr')
|
||
assert.equal(daten.lieferzeit, EINSTELLUNGEN.lieferzeit)
|
||
assert.deepEqual(daten.zahlungsarten, ['card'])
|
||
assert.equal(daten.rechtstexteHash, HASH)
|
||
assert.equal(daten.abholung, undefined)
|
||
assert.equal(daten.verlauf.length, 1)
|
||
assert.equal(daten.verlauf[0].was, 'angelegt')
|
||
|
||
const [call] = sessionCalls()
|
||
assert.equal(sessionCalls().length, 1)
|
||
assert.equal(call.account, ACCOUNT)
|
||
assert.equal(call.idempotencyKey, `shop-order-${r.orderId}`)
|
||
const b = call.body
|
||
assert.equal(b.mode, 'payment')
|
||
assert.equal(b['line_items[0][quantity]'], '2')
|
||
assert.equal(b['line_items[0][price_data][currency]'], 'eur')
|
||
assert.equal(b['line_items[0][price_data][unit_amount]'], '1250')
|
||
assert.equal(b['line_items[0][price_data][product_data][name]'], 'Artikel a1')
|
||
assert.equal(b['line_items[0][price_data][product_data][images][0]'], undefined)
|
||
assert.equal(b['shipping_options[0][shipping_rate_data][type]'], 'fixed_amount')
|
||
assert.equal(b['shipping_options[0][shipping_rate_data][fixed_amount][amount]'], '490')
|
||
assert.equal(b['shipping_options[0][shipping_rate_data][fixed_amount][currency]'], 'eur')
|
||
assert.equal(b['shipping_options[0][shipping_rate_data][display_name]'], 'Versand nach Deutschland')
|
||
assert.equal(b['shipping_address_collection[allowed_countries][0]'], 'DE')
|
||
assert.equal(b.billing_address_collection, 'required')
|
||
assert.equal(b['phone_number_collection[enabled]'], 'true')
|
||
assert.equal(b['payment_method_types[0]'], 'card')
|
||
assert.equal(b['payment_method_types[1]'], undefined)
|
||
assert.equal(b.locale, 'de')
|
||
assert.equal(Number(b.expires_at), Math.ceil(T0.getTime() / 1000) + 30 * 60 + 30)
|
||
assert.equal(b.client_reference_id, r.orderId)
|
||
for (const prefix of ['metadata', 'payment_intent_data[metadata]']) {
|
||
assert.equal(b[`${prefix}[wkShop]`], '1')
|
||
assert.equal(b[`${prefix}[shopId]`], FEATURE)
|
||
assert.equal(b[`${prefix}[orderId]`], r.orderId)
|
||
assert.equal(b[`${prefix}[bestellnummer]`], r.bestellnummer)
|
||
}
|
||
assert.equal(b['payment_intent_data[description]'], `Bestellung ${r.bestellnummer} – Brot & Mehr`)
|
||
const success = new URL(b.success_url)
|
||
assert.equal(success.origin + success.pathname, 'https://www.brot.example/laden')
|
||
assert.equal(success.searchParams.get('x'), '1')
|
||
assert.equal(success.searchParams.get('wk_bestellung'), r.orderId)
|
||
assert.equal(success.hash, '')
|
||
const token = success.searchParams.get('wk_t')
|
||
assert.match(token, /^[A-Za-z0-9_-]{43}$/)
|
||
assert.equal(B.tokenPasst(order, token), true)
|
||
assert.equal(order.tokenHash.includes(token), false)
|
||
const cancel = new URL(b.cancel_url)
|
||
assert.equal(cancel.searchParams.get('wk_abbruch'), '1')
|
||
assert.equal(cancel.searchParams.get('x'), '1')
|
||
assert.equal(cancel.searchParams.get('wk_t'), null)
|
||
|
||
// bestellungLesen
|
||
const gelesen = await B.bestellungLesen(r.orderId)
|
||
assert.equal(gelesen.daten.shopname, 'Brot & Mehr')
|
||
assert.equal(gelesen.status, 'offen')
|
||
assert.equal(await B.bestellungLesen('gibt-es-nicht'), null)
|
||
assert.equal(await B.bestellungLesen('../x'), null)
|
||
assert.deepEqual(B.bestellDaten({ bestellung: '{kaputt' }), {})
|
||
})
|
||
|
||
test('Abholung, Variante, Bild und Landeswahl: Namen, Versandzeile und Bild-URL', async () => {
|
||
const ctx = await aufbau({
|
||
produkte: [
|
||
artikel('a1', { bilder: ['abc-123'], varianten: { name: 'Größe', werte: [{ name: 'M', preisCent: 2000 }, { name: 'L', preisCent: 2200 }] } }),
|
||
artikel('a2'),
|
||
],
|
||
})
|
||
const m = ctx && JSON.parse(aw.docs('shopProducts').get('a1').produkt).varianten.werte.find((w) => w.name === 'L')
|
||
const abholung = await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', varianteId: m.id, menge: 1 }], lieferart: 'abholung', land: undefined }), { now: T0 })
|
||
const a = sessionCalls()[0].body
|
||
assert.equal(a['line_items[0][price_data][product_data][name]'], 'Artikel a1 – L')
|
||
assert.equal(a['line_items[0][price_data][unit_amount]'], '2200')
|
||
assert.equal(a['line_items[0][price_data][product_data][images][0]'], `${PORTAL}/api/public/shop/v1/${SITE_KEY}/bild/abc-123-l.webp`)
|
||
assert.equal(a['shipping_options[0][shipping_rate_data][fixed_amount][amount]'], '0')
|
||
assert.equal(a['shipping_options[0][shipping_rate_data][display_name]'], 'Abholung')
|
||
assert.equal(a['shipping_address_collection[allowed_countries][0]'], undefined)
|
||
const daten = JSON.parse(aw.docs('shopOrders').get(abholung.orderId).bestellung)
|
||
assert.deepEqual(daten.abholung, { adresse: 'Hauptstraße 5, 67655 Kaiserslautern', hinweis: 'Mo–Fr 8–12 Uhr' })
|
||
assert.equal(daten.land, null)
|
||
|
||
st.calls.length = 0
|
||
await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a2', menge: 1 }], land: 'AT' }), { now: T0 })
|
||
const v = sessionCalls()[0].body
|
||
assert.equal(v['shipping_options[0][shipping_rate_data][display_name]'], 'Versand nach Österreich')
|
||
assert.equal(v['shipping_options[0][shipping_rate_data][fixed_amount][amount]'], '790')
|
||
assert.equal(v['shipping_address_collection[allowed_countries][0]'], 'AT')
|
||
|
||
// Bild-URL nur mit https-Portal
|
||
st.calls.length = 0
|
||
config.publicBaseUrl = 'http://127.0.0.1:4000'
|
||
await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', varianteId: m.id, menge: 1 }], rueckUrl: undefined }), { now: T0 })
|
||
assert.equal(sessionCalls()[0].body['line_items[0][price_data][product_data][images][0]'], undefined)
|
||
assert.ok(sessionCalls()[0].body.success_url.startsWith(`http://127.0.0.1:4000/shop/${SITE_KEY}?wk_bestellung=`))
|
||
})
|
||
|
||
test('Preise nur aus der Datenbank: Preis, Summe und Versand vom Browser zaehlen nicht', async () => {
|
||
const ctx = await aufbau()
|
||
await B.legeBestellungAn(
|
||
ctx,
|
||
eingabe({ positionen: [{ produktId: 'a1', menge: 1, preisCent: 1, einzelCent: 1, summeCent: 1 }], versandCent: 0, gesamtCent: 1, summeCent: 1 }),
|
||
{ now: T0 }
|
||
)
|
||
const b = sessionCalls()[0].body
|
||
assert.equal(b['line_items[0][price_data][unit_amount]'], '1250')
|
||
assert.equal(b['shipping_options[0][shipping_rate_data][fixed_amount][amount]'], '490')
|
||
assert.equal(orders()[0].summeCent, 1740)
|
||
})
|
||
|
||
test('Berechnungsfehler: unbekannter Artikel 409, falsche Menge 400, Lieferart 400; nichts angelegt, kein Stripe-Aufruf', async () => {
|
||
const ctx = await aufbau()
|
||
await rejectsMit(B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'gibtsnicht', menge: 1 }] }), { now: T0 }), 409, 'nicht_verfuegbar')
|
||
await rejectsMit(B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 0 }] }), { now: T0 }), 400, 'menge')
|
||
await rejectsMit(B.legeBestellungAn(ctx, eingabe({ lieferart: 'post' }), { now: T0 }), 400, 'lieferart')
|
||
await rejectsMit(B.legeBestellungAn(ctx, eingabe({ positionen: [] }), { now: T0 }), 400, 'leer')
|
||
await rejectsMit(B.legeBestellungAn(ctx, eingabe({ land: 'FR' }), { now: T0 }), 400, 'land')
|
||
await rejectsMit(B.legeBestellungAn(ctx, undefined, { now: T0 }), 400, 'leer')
|
||
const err = await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'gibtsnicht', menge: 1 }] }), { now: T0 }).catch((e) => e)
|
||
assert.match(err.message, /nicht mehr verfügbar/)
|
||
assert.equal(orders().length, 0)
|
||
assert.equal(st.calls.length, 0)
|
||
})
|
||
|
||
test('Reservierung: der letzte Artikel geht nur einmal weg, abgelaufene Reservierungen geben ihn wieder frei', async () => {
|
||
const ctx = await aufbau({ produkte: [artikel('a1', { bestand: 1 })] })
|
||
const erste = await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 1 }] }), { now: T0 })
|
||
assert.deepEqual([...(await B.reserviert(FEATURE, nach(10))).entries()], [['a1', 1]])
|
||
const err = await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 1 }] }), { now: nach(60) }).catch((e) => e)
|
||
assert.equal(err.status, 409)
|
||
assert.equal(err.code, 'ausverkauft')
|
||
assert.match(err.message, /ausverkauft/)
|
||
assert.equal(orders().length, 1)
|
||
assert.equal(sessionCalls().length, 1)
|
||
|
||
// Nach Ablauf (30 min + 30 s) zaehlt die Reservierung nicht mehr
|
||
assert.equal((await B.reserviert(FEATURE, nach(31 * 60))).size, 0)
|
||
const zweite = await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 1 }] }), { now: nach(31 * 60) })
|
||
assert.notEqual(zweite.orderId, erste.orderId)
|
||
})
|
||
|
||
test('reserviert: nur offene Bestellungen dieses Shops, Varianten mit Doppelpunkt-Schluessel, Mengen summiert', async () => {
|
||
await aufbau()
|
||
const ablauf = nach(1800).toISOString()
|
||
const pos = (produktId, menge, varianteId = null) => ({ produktId, varianteId, menge })
|
||
const bestellung = (...positionen) => JSON.stringify({ positionen })
|
||
aw.seed('shopOrders', { $id: 'o1', shopId: FEATURE, status: 'offen', ablaufAt: ablauf, bestellung: bestellung(pos('p1', 2), pos('p2', 1, 'v1')) })
|
||
aw.seed('shopOrders', { $id: 'o2', shopId: FEATURE, status: 'offen', ablaufAt: ablauf, bestellung: bestellung(pos('p1', 3), pos('p2', 4, 'v1'), pos('p2', 1, 'v2')) })
|
||
aw.seed('shopOrders', { $id: 'o3', shopId: FEATURE, status: 'bezahlt', ablaufAt: ablauf, bestellung: bestellung(pos('p1', 9)) })
|
||
aw.seed('shopOrders', { $id: 'o4', shopId: 'anderer', status: 'offen', ablaufAt: ablauf, bestellung: bestellung(pos('p1', 9)) })
|
||
aw.seed('shopOrders', { $id: 'o5', shopId: FEATURE, status: 'offen', ablaufAt: nach(-5).toISOString(), bestellung: bestellung(pos('p1', 9)) })
|
||
aw.seed('shopOrders', { $id: 'o6', shopId: FEATURE, status: 'offen', ablaufAt: ablauf, bestellung: '{kaputt' })
|
||
const karte = await B.reserviert(FEATURE, T0)
|
||
assert.deepEqual(Object.fromEntries(karte), { p1: 5, 'p2:v1': 5, 'p2:v2': 1 })
|
||
})
|
||
|
||
test('Gleichzeitig: zwei parallele Bestellungen fuer den letzten Artikel, genau eine gewinnt', async () => {
|
||
const ctx = await aufbau({ produkte: [artikel('a1', { bestand: 1 })] })
|
||
const lauf = () => B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 1 }] }), { now: T0 })
|
||
const ergebnisse = await Promise.allSettled([lauf(), lauf(), lauf()])
|
||
assert.equal(ergebnisse.filter((x) => x.status === 'fulfilled').length, 1)
|
||
const abgelehnt = ergebnisse.filter((x) => x.status === 'rejected')
|
||
assert.equal(abgelehnt.length, 2)
|
||
for (const x of abgelehnt) assert.equal(x.reason.code, 'ausverkauft')
|
||
assert.equal(orders().length, 1)
|
||
assert.equal(sessionCalls().length, 1)
|
||
})
|
||
|
||
test('Geschlossener Shop: 409 geschlossen, nichts angelegt, kein Stripe-Aufruf', async () => {
|
||
for (const shop of [{ geoeffnet: false }, { stripeStatus: JSON.stringify({ charges_enabled: false }) }, { stripeAccountId: '' }]) {
|
||
const ctx = await aufbau({ shop })
|
||
const err = await B.legeBestellungAn(ctx, eingabe(), { now: T0 }).catch((e) => e)
|
||
assert.equal(err.status, 409, JSON.stringify(shop))
|
||
assert.equal(err.code, 'geschlossen')
|
||
assert.equal(err.message, 'Der Shop ist gerade geschlossen.')
|
||
assert.equal(orders().length, 0)
|
||
assert.equal(st.calls.length, 0)
|
||
}
|
||
// Funktion nicht aktiv
|
||
const ctx = await aufbau()
|
||
await rejectsMit(B.legeBestellungAn({ ...ctx, doc: { ...ctx.doc, state: 'disabled' } }, eingabe(), { now: T0 }), 409, 'geschlossen')
|
||
// Kein lieferbarer Artikel mehr
|
||
const leer = await aufbau({ produkte: [artikel('a1', { aktiv: false })] })
|
||
await rejectsMit(B.legeBestellungAn(leer, eingabe(), { now: T0 }), 409, 'geschlossen')
|
||
})
|
||
|
||
test('Fremde Rueck-URL: Fallback auf die Shop-Seite im Portal', async () => {
|
||
const ctx = await aufbau()
|
||
await B.legeBestellungAn(ctx, eingabe({ rueckUrl: 'https://boese.example/phishing?x=1' }), { now: T0 })
|
||
const b = sessionCalls()[0].body
|
||
const s = new URL(b.success_url)
|
||
assert.equal(s.origin + s.pathname, `${PORTAL}/shop/${SITE_KEY}`)
|
||
assert.equal(new URL(b.cancel_url).origin + new URL(b.cancel_url).pathname, `${PORTAL}/shop/${SITE_KEY}`)
|
||
assert.ok(!b.success_url.includes('boese'))
|
||
})
|
||
|
||
test('Stripe lehnt ab: Bestellung storniert (Reservierung frei), 503 mit Kaufertext', async () => {
|
||
const ctx = await aufbau({ produkte: [artikel('a1', { bestand: 1 })] })
|
||
st.failNext({ method: 'POST', path: '/v1/checkout/sessions', status: 400, message: 'Invalid something' })
|
||
const err = await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 1 }] }), { now: T0 }).catch((e) => e)
|
||
assert.equal(err.status, 503)
|
||
assert.equal(err.code, 'zahlung_nicht_moeglich')
|
||
assert.equal(err.message, 'Die Zahlung kann gerade nicht gestartet werden. Bitte versuchen Sie es später noch einmal.')
|
||
assert.equal(sessionCalls().length, 1)
|
||
const [order] = orders()
|
||
assert.equal(order.status, 'storniert')
|
||
const daten = JSON.parse(order.bestellung)
|
||
assert.deepEqual(daten.verlauf.map((v) => v.was), ['angelegt', 'stripe_fehler'])
|
||
assert.equal(order.stripeSessionId ?? '', '')
|
||
assert.equal((await B.reserviert(FEATURE, T0)).size, 0)
|
||
// Der Artikel ist wieder bestellbar
|
||
await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 1 }] }), { now: T0 })
|
||
})
|
||
|
||
test('Klarna nicht freigeschaltet: einmal mit Karte wiederholen, Hinweis fuer den Haendler speichern', async () => {
|
||
const ctx = await aufbau({ einstellungen: { ...EINSTELLUNGEN, zahlungsarten: ['card', 'klarna'] } })
|
||
assert.deepEqual(ctx.shop.einstellungen.zahlungsarten, ['card', 'klarna'])
|
||
st.failNext({ method: 'POST', path: '/v1/checkout/sessions', status: 400, message: 'The payment method type provided: klarna is invalid. Please ensure the provided type is activated in your dashboard (payment_method_types).' })
|
||
const r = await B.legeBestellungAn(ctx, eingabe(), { now: T0 })
|
||
const calls = sessionCalls()
|
||
assert.equal(calls.length, 2)
|
||
assert.equal(calls[0].body['payment_method_types[1]'], 'klarna')
|
||
assert.equal(calls[1].body['payment_method_types[0]'], 'card')
|
||
assert.equal(calls[1].body['payment_method_types[1]'], undefined)
|
||
assert.equal(calls[1].account, ACCOUNT)
|
||
assert.notEqual(calls[1].idempotencyKey, calls[0].idempotencyKey)
|
||
assert.equal(calls[1].body.client_reference_id, r.orderId)
|
||
assert.equal(calls[1].body.success_url, calls[0].body.success_url)
|
||
const [order] = orders()
|
||
assert.equal(order.status, 'offen')
|
||
assert.match(order.stripeSessionId, /^cs_/)
|
||
const status = JSON.parse(aw.docs('shopConfig').get(FEATURE).stripeStatus)
|
||
assert.equal(status.charges_enabled, true)
|
||
assert.match(status.hinweis, /^Klarna\/SEPA ist in deinem Stripe-Konto nicht aktiviert/)
|
||
// Zwischenspeicher geleert: der naechste Kontext enthaelt den Hinweis
|
||
assert.match((await K.shopPerSiteKey(SITE_KEY, { now: T0 })).shop.stripeStatus.hinweis, /Klarna/)
|
||
})
|
||
|
||
test('Fehler bei reiner Kartenzahlung wird nicht wiederholt', async () => {
|
||
const ctx = await aufbau()
|
||
st.failNext({ method: 'POST', path: '/v1/checkout/sessions', status: 400, message: 'bad payment_method_types' })
|
||
await rejectsMit(B.legeBestellungAn(ctx, eingabe(), { now: T0 }), 503, 'zahlung_nicht_moeglich')
|
||
assert.equal(sessionCalls().length, 1)
|
||
assert.equal(orders()[0].status, 'storniert')
|
||
})
|
||
|
||
test('Anderer Stripe-Fehler trotz Klarna-Auswahl: keine Wiederholung mit Karte', async () => {
|
||
const ctx = await aufbau({ einstellungen: { ...EINSTELLUNGEN, zahlungsarten: ['card', 'sepa_debit'] } })
|
||
st.failNext({ method: 'POST', path: '/v1/checkout/sessions', status: 400, message: 'No such account: acct_x' })
|
||
await rejectsMit(B.legeBestellungAn(ctx, eingabe(), { now: T0 }), 503, 'zahlung_nicht_moeglich')
|
||
assert.equal(sessionCalls().length, 1)
|
||
assert.equal(orders()[0].status, 'storniert')
|
||
assert.equal(JSON.parse(aw.docs('shopConfig').get(FEATURE).stripeStatus).hinweis, undefined)
|
||
})
|