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>
458 lines
26 KiB
JavaScript
458 lines
26 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')
|
||
// 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)
|
||
})
|