Files
Webklar-Kundenbereich/test/shopBestellungen.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

458 lines
26 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 } 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')
// M-6: Datenminimierung - keine Telefonnummer, Rechnungsanschrift nur bei Bedarf
assert.equal(b.billing_address_collection, 'auto')
assert.equal(b['phone_number_collection[enabled]'], 'false')
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('Begrenzung offener Bestellungen je IP-Hash: drei offen in 30 Minuten, danach 429; wieder frei, sobald eine nicht mehr offen ist', async () => {
const ctx = await aufbau({ produkte: [artikel('a1', { bestand: 50 })] })
const mit = (ip, now) => B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 1 }] }), { now, ipHash: ip })
const ids = []
for (let i = 0; i < 3; i += 1) ids.push((await mit('aaaaaaaaaaaaaaaa', nach(i))).orderId)
assert.ok(orders().every((o) => JSON.parse(o.bestellung).ipHash === 'aaaaaaaaaaaaaaaa'), 'offen: IP-Hash steht im Datensatz')
const err = await mit('aaaaaaaaaaaaaaaa', nach(10)).catch((e) => e)
assert.equal(err.status, 429)
assert.equal(err.code, 'zu_viele_offen')
assert.ok((await mit('bbbbbbbbbbbbbbbb', nach(11))).orderId, 'andere IP unberuehrt')
// Eine verworfene Bestellung traegt keinen IP-Hash mehr und zaehlt nicht mehr
assert.equal((await B.verwirf(ids[0], 'zahlung_fehlgeschlagen', { now: nach(20) })).verworfen, true)
assert.equal(JSON.parse(orders().find((o) => o.$id === ids[0]).bestellung).ipHash, undefined)
assert.ok((await mit('aaaaaaaaaaaaaaaa', nach(21))).orderId, 'nach verwirf wieder eine offene moeglich')
const err2 = await mit('aaaaaaaaaaaaaaaa', nach(22)).catch((e) => e)
assert.equal(err2.code, 'zu_viele_offen')
})
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)
})