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>
542 lines
26 KiB
JavaScript
542 lines
26 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 { schemaKeys } from './support/schema.js'
|
||
import { useTestEnv } from './support/testEnv.js'
|
||
import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
|
||
import { SHOP_DATETIMES } from '../server/services/shop/schema.js'
|
||
|
||
// Oeffentliche Schnittstelle des Webshops, Kasse (F2): POST /pruefen, POST /bestellung, GET /bestellung/:id. Stripe ist der Nachbau,
|
||
// Mails landen in der Mailsenke; Preise kommen immer aus der Datenbank.
|
||
const aw = await startAppwriteMock({
|
||
schema: {
|
||
portalFeatures: schemaKeys('portalFeatures'),
|
||
shopConfig: schemaKeys('shopConfig'),
|
||
shopProducts: schemaKeys('shopProducts'),
|
||
shopOrders: schemaKeys('shopOrders'),
|
||
shopWiderrufe: schemaKeys('shopWiderrufe'),
|
||
termineOutbox: schemaKeys('termineOutbox'),
|
||
},
|
||
datetimes: { ...TERMINE_DATETIMES, ...SHOP_DATETIMES },
|
||
defaultLimit: 25,
|
||
})
|
||
const st = await startFakeStripe()
|
||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||
|
||
const { config } = await import('../server/config.js')
|
||
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||
const { startTestApp, send } = await import('./support/testApp.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 { 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 { formToken } = await import('../server/services/shop/token.js')
|
||
const { euro } = await import('../server/services/shop/geld.js')
|
||
const { resetTermineLimitsForTests } = await import('../server/services/termine/limits.js')
|
||
const { default: router, setzeUhrFuerTests, resetPublicShopForTests } = await import('../server/routes/publicShop.js')
|
||
|
||
const app = await startTestApp({ '/api/public/shop/v1': router })
|
||
after(async () => {
|
||
setMailTransportForTests(null)
|
||
await Promise.all([app.close(), aw.close(), st.close()])
|
||
})
|
||
|
||
const BASIS = '/api/public/shop/v1'
|
||
const JETZT = new Date('2026-10-05T12:03:00.000Z')
|
||
const KUNDE = 'c_kunde01'
|
||
const PROJEKT = 'p_projekt01'
|
||
const FEATURE = 'f_p_projekt01_shop'
|
||
const SITE_KEY = 'abcdefghijklmnop'
|
||
const ACCOUNT = 'acct_haendler1'
|
||
const O_LIVE = 'https://brot.example'
|
||
const O_PORTAL = 'https://portal.example'
|
||
const VORHER = { mode: config.shop.mode, pilot: [...config.shop.pilotEmails], basis: config.publicBaseUrl, formMin: config.shop.formMinSekunden, minAntwort: config.shop.minAntwortMs, from: config.smtp.from }
|
||
|
||
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 feature = (over = {}) => ({ $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'shop', siteKey: SITE_KEY, state: 'active', enabled: true, ...over })
|
||
const shop = (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 ip
|
||
let n = 0
|
||
|
||
async function aufbau({ state = 'active', geoeffnet = true, produkte = [artikel('a1')] } = {}) {
|
||
for (const col of ['portalFeatures', 'shopConfig', 'shopProducts', 'shopOrders', 'shopWiderrufe', 'termineOutbox', 'websiteProjects', 'customers']) {
|
||
for (const id of [...aw.docs(col).keys()]) await deleteDocument(config.collections[col], id)
|
||
}
|
||
K.leereCache()
|
||
resetOutboxForTests()
|
||
resetTermineLimitsForTests()
|
||
aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Brotladen', liveDomain: 'brot.example' })
|
||
aw.seed('customers', { $id: KUNDE, name: 'Erika Muster', email: 'inhaber@brot.example' })
|
||
aw.seed('portalFeatures', feature({ state }))
|
||
aw.seed('shopConfig', shop({ geoeffnet }))
|
||
for (const p of produkte) aw.seed('shopProducts', p)
|
||
st.setAccount(ACCOUNT, { charges_enabled: true, details_submitted: true, requirements: { currently_due: [] } })
|
||
}
|
||
|
||
const token = (alterMs = 10000) => formToken(SITE_KEY, new Date(JETZT.getTime() - alterMs))
|
||
const warenkorb = (over = {}) => ({ positionen: [{ produktId: 'a1', menge: 2 }], lieferart: 'versand', land: 'DE', ...over })
|
||
const kopf = (res, name) => res.headers.get(name)
|
||
const post = (pfad, body, { origin = O_LIVE, ip: adresse = ip, headers = {} } = {}) =>
|
||
send('POST', BASIS + pfad, { body, headers: { 'x-forwarded-for': adresse, ...(origin ? { origin } : {}), ...headers } })
|
||
const pruefen = (body = warenkorb(), opts) => post(`/${SITE_KEY}/pruefen`, body, opts)
|
||
const bestellen = (body = {}, opts) =>
|
||
post(`/${SITE_KEY}/bestellung`, { ...warenkorb(), rueckUrl: `${O_LIVE}/laden?x=1#top`, formToken: token(), ...body }, opts)
|
||
const status = (id, t, { ip: adresse = ip } = {}) =>
|
||
send('GET', `${BASIS}/${SITE_KEY}/bestellung/${id}${t === undefined ? '' : `?t=${encodeURIComponent(t)}`}`, { headers: { 'x-forwarded-for': adresse } })
|
||
const orders = () => [...aw.docs('shopOrders').values()]
|
||
const sessionCalls = () => st.calls.filter((c) => c.path === '/v1/checkout/sessions' && c.method === 'POST')
|
||
const tokenAusUrl = (url) => new URL(url).searchParams.get('wk_t')
|
||
|
||
beforeEach(async () => {
|
||
ip = `10.3.${Math.floor(++n / 250)}.${(n % 250) + 1}`
|
||
config.shop.mode = 'on'
|
||
config.shop.pilotEmails = []
|
||
config.shop.formMinSekunden = 3
|
||
config.shop.minAntwortMs = 0
|
||
config.publicBaseUrl = O_PORTAL
|
||
config.smtp.from = 'noreply@webklar.com'
|
||
setzeUhrFuerTests(() => JETZT)
|
||
sink = mailSink()
|
||
setMailTransportForTests(sink)
|
||
mock.method(console, 'warn', () => {})
|
||
st.calls.length = 0
|
||
await aufbau()
|
||
})
|
||
afterEach(async () => {
|
||
await warteAufVersand()
|
||
mock.restoreAll()
|
||
resetPublicShopForTests()
|
||
config.shop.mode = VORHER.mode
|
||
config.shop.pilotEmails = VORHER.pilot
|
||
config.shop.formMinSekunden = VORHER.formMin
|
||
config.shop.minAntwortMs = VORHER.minAntwort
|
||
config.publicBaseUrl = VORHER.basis
|
||
config.smtp.from = VORHER.from
|
||
})
|
||
|
||
const VERBOTEN = ['customerId', 'projectId', 'featureId', '$id', KUNDE, PROJEKT, FEATURE, ACCOUNT, 'inhaber@brot.example', 'stripeSessionId', 'tokenHash']
|
||
const ohneInternes = (body) => {
|
||
const s = JSON.stringify(body)
|
||
for (const v of VERBOTEN) assert.ok(!s.includes(v), `interner Wert in der Antwort: ${v}`)
|
||
}
|
||
|
||
// ---------------------------------------------------------------- pruefen
|
||
|
||
test('pruefen: Texte vom Server, Preisfelder des Browsers werden ignoriert, nichts wird angelegt', async () => {
|
||
const res = await pruefen(warenkorb({ positionen: [{ produktId: 'a1', menge: 2, preisCent: 1, einzelCent: 1, summeCent: 1, name: 'Hack' }], gesamtCent: 1 }))
|
||
assert.equal(res.status, 200)
|
||
assert.deepEqual(res.body, {
|
||
ok: true,
|
||
positionen: [{ produktId: 'a1', varianteId: null, name: 'Artikel a1', varianteText: '', menge: 2, einzelText: euro(1250), summeText: euro(2500), widerrufText: '' }],
|
||
texte: { zwischensumme: euro(2500), versand: euro(490), gesamt: euro(2990), steuerHinweis: 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.' },
|
||
lieferart: 'versand',
|
||
land: 'DE',
|
||
})
|
||
assert.equal(kopf(res, 'access-control-allow-origin'), O_LIVE)
|
||
assert.equal(kopf(res, 'cache-control'), 'no-store')
|
||
ohneInternes(res.body)
|
||
assert.equal(orders().length, 0)
|
||
assert.equal(sessionCalls().length, 0)
|
||
})
|
||
|
||
test('pruefen: Abholung ohne Land, widerrufText aus dem Artikel', async () => {
|
||
await aufbau({ produkte: [artikel('a1', { widerruf: 'verderblich' })] })
|
||
const res = await pruefen(warenkorb({ lieferart: 'abholung', land: undefined }))
|
||
assert.equal(res.status, 200)
|
||
assert.equal(res.body.texte.versand, 'Abholung – kostenlos')
|
||
assert.equal(res.body.land, null)
|
||
assert.equal(res.body.positionen[0].widerrufText, 'Vom Widerruf ausgeschlossen: schnell verderbliche Ware')
|
||
})
|
||
|
||
test('pruefen: geschlossener Shop (geoeffnet false oder disabled): 409 geschlossen', async () => {
|
||
for (const variante of [{ geoeffnet: false }, { state: 'disabled' }]) {
|
||
await aufbau(variante)
|
||
const res = await pruefen()
|
||
assert.equal(res.status, 409, JSON.stringify(variante))
|
||
assert.deepEqual(res.body, { fehler: { _: 'Der Shop ist gerade geschlossen.' }, code: 'geschlossen' })
|
||
}
|
||
})
|
||
|
||
test('pruefen: ausverkauft 409 mit produktId, zu große Menge, unbekannter Artikel, kaputter Rumpf', async () => {
|
||
await aufbau({ produkte: [artikel('a1', { bestand: 1 })] })
|
||
const aus = await pruefen(warenkorb({ positionen: [{ produktId: 'a1', menge: 2 }] }))
|
||
assert.equal(aus.status, 409)
|
||
assert.equal(aus.body.code, 'ausverkauft')
|
||
assert.equal(aus.body.produktId, 'a1')
|
||
assert.match(aus.body.fehler._, /nur noch 1/)
|
||
|
||
const menge = await pruefen(warenkorb({ positionen: [{ produktId: 'a1', menge: 1000 }] }))
|
||
assert.equal(menge.status, 400)
|
||
assert.equal(menge.body.code, 'menge')
|
||
|
||
const unbekannt = await pruefen(warenkorb({ positionen: [{ produktId: 'gibtsnicht', menge: 1 }] }))
|
||
assert.equal(unbekannt.status, 409)
|
||
assert.equal(unbekannt.body.code, 'nicht_verfuegbar')
|
||
|
||
const leer = await pruefen(warenkorb({ positionen: [] }))
|
||
assert.equal(leer.status, 400)
|
||
assert.equal(leer.body.code, 'leer')
|
||
|
||
const kaputt = await pruefen([1, 2])
|
||
assert.equal(kaputt.status, 400)
|
||
})
|
||
|
||
test('pruefen: offene Bestellungen reservieren den Bestand', async () => {
|
||
await aufbau({ produkte: [artikel('a1', { bestand: 3 })] })
|
||
assert.equal((await bestellen({ positionen: [{ produktId: 'a1', menge: 2 }] })).status, 200)
|
||
const res = await pruefen(warenkorb({ positionen: [{ produktId: 'a1', menge: 2 }] }))
|
||
assert.equal(res.status, 409)
|
||
assert.equal(res.body.code, 'ausverkauft')
|
||
})
|
||
|
||
test('pruefen: fremde Origin 403, unbekannter Shop aktiv:false, Grenze 60 je Minute', async () => {
|
||
const fremd = await pruefen(warenkorb(), { origin: 'https://fremd.example' })
|
||
assert.equal(fremd.status, 403)
|
||
assert.equal(kopf(fremd, 'access-control-allow-origin'), null)
|
||
const ohneOrigin = await pruefen(warenkorb(), { origin: null })
|
||
assert.equal(ohneOrigin.status, 403)
|
||
|
||
const unbekannt = await post('/zzzzzzzzzzzzzzzz/pruefen', warenkorb())
|
||
assert.equal(unbekannt.status, 200)
|
||
assert.deepEqual(unbekannt.body, { aktiv: false })
|
||
|
||
const eigene = `10.9.9.${(n % 250) + 1}`
|
||
for (let i = 0; i < 60; i += 1) assert.equal((await pruefen(warenkorb(), { ip: eigene })).status, 200, `Anfrage ${i + 1}`)
|
||
const zuViele = await pruefen(warenkorb(), { ip: eigene })
|
||
assert.equal(zuViele.status, 429)
|
||
assert.equal(kopf(zuViele, 'retry-after'), '60')
|
||
})
|
||
|
||
// ---------------------------------------------------------------- bestellung
|
||
|
||
test('bestellung: legt offene Bestellung an und liefert die Stripe-Adresse; Konto des Händlers, Preise aus der Datenbank', async () => {
|
||
const res = await bestellen({
|
||
positionen: [{ produktId: 'a1', menge: 2, preisCent: 1, einzelCent: 1, summeCent: 1 }],
|
||
gesamtCent: 1,
|
||
summeCent: 1,
|
||
versandCent: 0,
|
||
kontakt_url: '',
|
||
})
|
||
assert.equal(res.status, 200)
|
||
assert.deepEqual(Object.keys(res.body), ['url'])
|
||
assert.match(res.body.url, /^https:\/\/checkout\.stripe\.test\/c\/pay\/cs_/)
|
||
assert.equal(kopf(res, 'access-control-allow-origin'), O_LIVE)
|
||
assert.equal(kopf(res, 'cache-control'), 'no-store')
|
||
ohneInternes(res.body)
|
||
|
||
const [order] = orders()
|
||
assert.equal(order.status, 'offen')
|
||
assert.equal(order.summeCent, 2 * 1250 + 490)
|
||
const [call] = sessionCalls()
|
||
assert.equal(call.account, ACCOUNT)
|
||
assert.equal(call.body['line_items[0][price_data][unit_amount]'], '1250')
|
||
assert.equal(call.body['line_items[0][quantity]'], '2')
|
||
assert.equal(call.body['shipping_options[0][shipping_rate_data][fixed_amount][amount]'], '490')
|
||
assert.match(call.body.success_url, /^https:\/\/brot\.example\/laden\?x=1&wk_bestellung=/)
|
||
assert.equal(call.body.cancel_url, 'https://brot.example/laden?x=1&wk_abbruch=1')
|
||
})
|
||
|
||
test('bestellung: Honigtopf oder zu schnell abgeschickt ⇒ scheinbarer Erfolg ohne Bestellung und ohne Stripe', async () => {
|
||
const honig = await bestellen({ kontakt_url: 'https://spam.example' })
|
||
assert.equal(honig.status, 200)
|
||
assert.deepEqual(honig.body, { url: 'https://brot.example/laden?x=1' })
|
||
const schnell = await bestellen({ formToken: token(500) })
|
||
assert.equal(schnell.status, 200)
|
||
assert.deepEqual(schnell.body, { url: 'https://brot.example/laden?x=1' })
|
||
// fremde Rueck-URL ⇒ Standardseite des Portals
|
||
const fremd = await bestellen({ kontakt_url: 'x', rueckUrl: 'https://boese.example/' })
|
||
assert.deepEqual(fremd.body, { url: `${O_PORTAL}/shop/${SITE_KEY}` })
|
||
assert.equal(orders().length, 0)
|
||
assert.equal(sessionCalls().length, 0)
|
||
})
|
||
|
||
test('bestellung: fehlendes, fremdes oder abgelaufenes Token ⇒ 400 Seite neu laden, nichts angelegt', async () => {
|
||
for (const formToken of [undefined, '', 'x.y', formToken_(SITE_KEY, 'anderersitekey1'), token(3 * 60 * 60 * 1000)]) {
|
||
const res = await bestellen({ formToken })
|
||
assert.equal(res.status, 400)
|
||
assert.deepEqual(res.body, { fehler: { _: 'Bitte laden Sie die Seite neu.' }, code: 'neu_laden' })
|
||
}
|
||
assert.equal(orders().length, 0)
|
||
})
|
||
function formToken_(_siteKey, fremder) {
|
||
return formToken(fremder, new Date(JETZT.getTime() - 10000))
|
||
}
|
||
|
||
test('bestellung: geschlossener Shop ⇒ 409 geschlossen; ausverkauft ⇒ 409 mit produktId; Stripe nicht aufgerufen', async () => {
|
||
await aufbau({ geoeffnet: false })
|
||
const zu = await bestellen()
|
||
assert.equal(zu.status, 409)
|
||
assert.deepEqual(zu.body, { fehler: { _: 'Der Shop ist gerade geschlossen.' }, code: 'geschlossen' })
|
||
|
||
await aufbau({ produkte: [artikel('a1', { bestand: 1 })] })
|
||
const aus = await bestellen({ positionen: [{ produktId: 'a1', menge: 5 }] })
|
||
assert.equal(aus.status, 409)
|
||
assert.equal(aus.body.code, 'ausverkauft')
|
||
assert.equal(aus.body.produktId, 'a1')
|
||
assert.equal(orders().length, 0)
|
||
assert.equal(sessionCalls().length, 0)
|
||
})
|
||
|
||
test('bestellung: Fehler von Stripe ⇒ 503 mit Text für Käufer, ohne Rohtext; Bestellung storniert', async () => {
|
||
st.failNext({ method: 'POST', path: '/v1/checkout/sessions', status: 400, message: 'geheimer Stripe-Rohtext' })
|
||
const res = await bestellen()
|
||
assert.equal(res.status, 503)
|
||
assert.equal(res.body.code, 'zahlung_nicht_moeglich')
|
||
assert.ok(!JSON.stringify(res.body).includes('geheimer'))
|
||
assert.equal(orders()[0].status, 'storniert')
|
||
})
|
||
|
||
test('bestellung: fremde Origin 403 und unbekannter Shop aktiv:false; kein Rumpf ⇒ 400', async () => {
|
||
const fremd = await bestellen({}, { origin: 'https://fremd.example' })
|
||
assert.equal(fremd.status, 403)
|
||
assert.equal(kopf(fremd, 'access-control-allow-origin'), null)
|
||
const unbekannt = await post('/zzzzzzzzzzzzzzzz/bestellung', { ...warenkorb(), formToken: token() })
|
||
assert.deepEqual(unbekannt.body, { aktiv: false })
|
||
const kaputt = await post(`/${SITE_KEY}/bestellung`, [1])
|
||
assert.equal(kaputt.status, 400)
|
||
assert.equal(orders().length, 0)
|
||
})
|
||
|
||
test('bestellung: Grenze 10 je IP und 10 Minuten (Retry-After 600), Grenze 300 je Shop und Stunde (3600) für alle Anfragen', async () => {
|
||
const eigene = `10.8.8.${(n % 250) + 1}`
|
||
for (let i = 0; i < 10; i += 1) assert.equal((await bestellen({ kontakt_url: 'x' }, { ip: eigene })).status, 200, `Versuch ${i + 1}`)
|
||
const zuViele = await bestellen({ kontakt_url: 'x' }, { ip: eigene })
|
||
assert.equal(zuViele.status, 429)
|
||
assert.equal(kopf(zuViele, 'retry-after'), '600')
|
||
|
||
resetTermineLimitsForTests()
|
||
let ok = 0
|
||
for (let i = 0; i < 300; i += 1) {
|
||
const res = await bestellen({ kontakt_url: 'x' }, { ip: `10.7.${Math.floor(i / 200)}.${(i % 200) + 1}` })
|
||
if (res.status === 200) ok += 1
|
||
}
|
||
assert.equal(ok, 300)
|
||
const shopGrenze = await bestellen({ kontakt_url: 'x' }, { ip: '10.7.9.9' })
|
||
assert.equal(shopGrenze.status, 429)
|
||
assert.equal(kopf(shopGrenze, 'retry-after'), '3600')
|
||
})
|
||
|
||
test('bestellung I4: höchstens 3 offene Bestellungen je IP und Shop in 30 Minuten; andere IPs, abgeschlossene und alte Bestellungen zählen nicht', async () => {
|
||
await aufbau({ produkte: [artikel('a1', {})] })
|
||
const eigene = `10.9.9.${(n % 250) + 1}`
|
||
for (let i = 0; i < 3; i += 1) assert.equal((await bestellen({}, { ip: eigene })).status, 200, `Bestellung ${i + 1}`)
|
||
assert.equal(orders().length, 3)
|
||
const vierte = await bestellen({}, { ip: eigene })
|
||
assert.equal(vierte.status, 429)
|
||
assert.equal(vierte.body.code, 'zu_viele_offen')
|
||
assert.equal(vierte.body.fehler._, 'Sie haben bereits offene Bestellungen. Bitte schließen Sie diese zuerst ab oder warten Sie 30 Minuten.')
|
||
assert.equal(kopf(vierte, 'retry-after'), '1800')
|
||
assert.equal(orders().length, 3, 'keine vierte Bestellung')
|
||
assert.equal(sessionCalls().length, 3, 'keine vierte Stripe-Sitzung')
|
||
|
||
// Der Hash steht in der Bestellung, die Adresse nicht
|
||
const json = orders()[0].bestellung
|
||
assert.match(JSON.parse(json).ipHash, /^[0-9a-f]{16}$/)
|
||
assert.ok(!JSON.stringify(orders()).includes(eigene), 'die IP-Adresse selbst wird nirgends gespeichert')
|
||
assert.equal(new Set(orders().map((o) => JSON.parse(o.bestellung).ipHash)).size, 1)
|
||
|
||
// Eine andere IP ist nicht betroffen
|
||
assert.equal((await bestellen({}, { ip: '10.9.8.1' })).status, 200)
|
||
// Eine storniert/abgeschlossene Bestellung zählt nicht mehr
|
||
const erste = orders()[0]
|
||
aw.seed('shopOrders', { ...erste, status: 'storniert' })
|
||
assert.equal((await bestellen({}, { ip: eigene })).status, 200)
|
||
assert.equal((await bestellen({}, { ip: eigene })).status, 429)
|
||
// Nach 30 Minuten sind die alten Bestellungen aus dem Fenster
|
||
setzeUhrFuerTests(() => new Date(JETZT.getTime() + 31 * 60 * 1000))
|
||
const spaeter = await bestellen({ formToken: formToken(SITE_KEY, new Date(JETZT.getTime() + 20 * 60 * 1000)) }, { ip: eigene })
|
||
assert.equal(spaeter.status, 200)
|
||
})
|
||
|
||
test('bestellung I4: Honigtopf-Anfragen und Fehlversuche legen nichts an und zählen nicht als offene Bestellung', async () => {
|
||
const eigene = `10.9.7.${(n % 250) + 1}`
|
||
for (let i = 0; i < 5; i += 1) await bestellen({ kontakt_url: 'x' }, { ip: eigene })
|
||
await bestellen({ positionen: [] }, { ip: eigene })
|
||
assert.equal(orders().length, 0)
|
||
assert.equal((await bestellen({}, { ip: eigene })).status, 200)
|
||
})
|
||
|
||
test('bestellung: Mindestantwortzeit gilt auch bei Fehlern', async () => {
|
||
config.shop.minAntwortMs = 200
|
||
const t0 = performance.now()
|
||
const res = await bestellen({ formToken: '' })
|
||
assert.equal(res.status, 400)
|
||
assert.ok(performance.now() - t0 >= 190)
|
||
})
|
||
|
||
test('Preflight für pruefen und bestellung: erlaubte Origin ja, fremde nein', async () => {
|
||
for (const was of ['pruefen', 'bestellung']) {
|
||
const ok = await send('OPTIONS', `${BASIS}/${SITE_KEY}/${was}`, { headers: { origin: O_LIVE, 'x-forwarded-for': ip } })
|
||
assert.equal(ok.status, 204)
|
||
assert.equal(kopf(ok, 'access-control-allow-origin'), O_LIVE)
|
||
const fremd = await send('OPTIONS', `${BASIS}/${SITE_KEY}/${was}`, { headers: { origin: 'https://fremd.example', 'x-forwarded-for': ip } })
|
||
assert.equal(fremd.status, 403)
|
||
}
|
||
})
|
||
|
||
// ---------------------------------------------------------------- Status
|
||
|
||
async function offeneBestellung() {
|
||
const res = await bestellen()
|
||
assert.equal(res.status, 200)
|
||
const [order] = orders()
|
||
const [call] = sessionCalls()
|
||
return { orderId: order.$id, t: tokenAusUrl(call.body.success_url), sessionId: order.stripeSessionId, order }
|
||
}
|
||
|
||
test('Status: offene Bestellung mit gutem Token ⇒ offen, ohne Käuferdaten und ohne IDs', async () => {
|
||
const { orderId, t } = await offeneBestellung()
|
||
const res = await status(orderId, t)
|
||
assert.equal(res.status, 200)
|
||
assert.equal(res.body.status, 'offen')
|
||
assert.match(res.body.bestellnummer, /^26-[A-Z2-9]{6}$/)
|
||
assert.deepEqual(Object.keys(res.body).sort(), ['bestellnummer', 'email', 'gesamtText', 'lieferart', 'status'])
|
||
assert.equal(res.body.gesamtText, euro(2990))
|
||
assert.equal(res.body.lieferart, 'versand')
|
||
assert.equal(res.body.email, '')
|
||
assert.equal(kopf(res, 'access-control-allow-origin'), '*')
|
||
assert.equal(kopf(res, 'cache-control'), 'no-store')
|
||
assert.ok(!JSON.stringify(res.body).includes(orderId))
|
||
ohneInternes(res.body)
|
||
})
|
||
|
||
test('Status: schlechter oder fehlender Token, fremde Bestellung, kaputte ID ⇒ immer dieselbe 404', async () => {
|
||
const { orderId, t } = await offeneBestellung()
|
||
const erwartet = { fehler: { _: 'Bestellung nicht gefunden.' } }
|
||
const faelle = [
|
||
await status(orderId, 'falsch-falsch-falsch-falsch'),
|
||
await status(orderId, undefined),
|
||
await status(orderId, ''),
|
||
await status(orderId, 'x'),
|
||
await status('o_gibtesnicht', t),
|
||
await status('..%2f..%2fetc', t),
|
||
await status('a'.repeat(80), t),
|
||
]
|
||
for (const res of faelle) {
|
||
assert.equal(res.status, 404)
|
||
assert.deepEqual(res.body, erwartet)
|
||
}
|
||
// Bestellung eines anderen Shops mit richtigem Token
|
||
const anderer = 'f_p_projekt02_shop'
|
||
aw.seed('shopOrders', { ...orders()[0], $id: 'o_fremd01', shopId: anderer })
|
||
const fremd = await status('o_fremd01', t)
|
||
assert.equal(fremd.status, 404)
|
||
assert.deepEqual(fremd.body, erwartet)
|
||
// Hash als Token geht nicht
|
||
assert.equal((await status(orderId, orders().find((o) => o.$id === orderId).tokenHash)).status, 404)
|
||
})
|
||
|
||
test('Status: offen ⇒ bezahlt, sobald Stripe die Sitzung als bezahlt meldet; E-Mail verdeckt, kein Name und keine Adresse', async () => {
|
||
const { orderId, t, sessionId } = await offeneBestellung()
|
||
const vorher = await status(orderId, t)
|
||
assert.equal(vorher.body.status, 'offen')
|
||
|
||
st.completeSession(ACCOUNT, sessionId, {
|
||
payment_status: 'paid',
|
||
amount_total: 2990,
|
||
customer_details: { name: 'Max Mustermann', email: 'Max@Example.com', phone: '0170 1234567', address: { line1: 'Musterweg 1', postal_code: '67655', city: 'Kaiserslautern', country: 'DE' } },
|
||
shipping: { name: 'Max Mustermann', address: { line1: 'Musterweg 1', postal_code: '67655', city: 'Kaiserslautern', country: 'DE' } },
|
||
payment_intent: 'pi_test1',
|
||
})
|
||
const res = await status(orderId, t)
|
||
assert.equal(res.status, 200)
|
||
assert.equal(res.body.status, 'bezahlt')
|
||
assert.equal(res.body.email, 'M***@Example.com')
|
||
const s = JSON.stringify(res.body)
|
||
for (const verboten of ['Mustermann', 'Musterweg', 'Max@', '0170', 'pi_test1', sessionId]) assert.ok(!s.includes(verboten), verboten)
|
||
ohneInternes(res.body)
|
||
assert.equal(orders()[0].status, 'bezahlt')
|
||
|
||
// Zweiter Abruf: bleibt bezahlt, kein zweiter Abschluss (eine Mail an den Käufer)
|
||
await warteAufVersand()
|
||
const mailsVorher = sink.sent.length
|
||
const nochmal = await status(orderId, t)
|
||
assert.equal(nochmal.body.status, 'bezahlt')
|
||
await warteAufVersand()
|
||
assert.equal(sink.sent.length, mailsVorher)
|
||
})
|
||
|
||
test('Status I2: bezahlte Bestellung mit fehlendem Bestandsabzug wird beim nächsten Abruf nachgeholt', async () => {
|
||
await aufbau({ produkte: [artikel('a1', { bestand: 10 })] })
|
||
const { orderId, t, sessionId } = await offeneBestellung()
|
||
st.completeSession(ACCOUNT, sessionId, {
|
||
payment_status: 'paid',
|
||
amount_total: 2990,
|
||
customer_details: { name: 'Max Mustermann', email: 'max@example.com' },
|
||
shipping: { name: 'Max Mustermann', address: { line1: 'Musterweg 1', postal_code: '67655', city: 'Kaiserslautern', country: 'DE' } },
|
||
payment_intent: 'pi_test1',
|
||
})
|
||
aw.failNext({ collection: config.collections.shopProducts, method: 'PATCH', status: 500 })
|
||
const erster = await status(orderId, t)
|
||
assert.equal(erster.status, 200)
|
||
assert.equal(orders()[0].status, 'bezahlt')
|
||
assert.equal(JSON.parse(orders()[0].bestellung).bestandAbgezogenAt, null)
|
||
const zweiter = await status(orderId, t)
|
||
assert.equal(zweiter.body.status, 'bezahlt')
|
||
assert.ok(JSON.parse(orders()[0].bestellung).bestandAbgezogenAt)
|
||
await warteAufVersand()
|
||
const mails = sink.sent.length
|
||
await status(orderId, t)
|
||
await warteAufVersand()
|
||
assert.equal(sink.sent.length, mails, 'Mails nur einmal')
|
||
})
|
||
|
||
test('Status: nicht bezahlte Sitzung (z. B. SEPA unterwegs) bleibt offen; Stripe-Fehler beim Abgleich ⇒ trotzdem Antwort', async () => {
|
||
const { orderId, t, sessionId } = await offeneBestellung()
|
||
st.completeSession(ACCOUNT, sessionId, { payment_status: 'unpaid', amount_total: 2990 })
|
||
assert.equal((await status(orderId, t)).body.status, 'offen')
|
||
st.failNext({ method: 'GET', path: /checkout\/sessions\//, status: 400, message: 'kaputt' })
|
||
const res = await status(orderId, t)
|
||
assert.equal(res.status, 200)
|
||
assert.equal(res.body.status, 'offen')
|
||
})
|
||
|
||
test('Status: abgeschalteter Shop (disabled) zeigt Bestellungen weiter; Grenze 120 je Minute', async () => {
|
||
const { orderId, t } = await offeneBestellung()
|
||
aw.seed('portalFeatures', feature({ state: 'disabled' }))
|
||
K.leereCache()
|
||
assert.equal((await status(orderId, t)).status, 200)
|
||
|
||
const eigene = `10.6.6.${(n % 250) + 1}`
|
||
for (let i = 0; i < 120; i += 1) assert.equal((await status(orderId, 'x', { ip: eigene })).status, 404)
|
||
const zuViele = await status(orderId, t, { ip: eigene })
|
||
assert.equal(zuViele.status, 429)
|
||
assert.equal(kopf(zuViele, 'retry-after'), '60')
|
||
})
|