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

613 lines
35 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, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { mailSink } from './support/mailSink.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
import { SHOP_DATETIMES } from '../server/services/shop/schema.js'
import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
// Webshop E3: Haendler-Aktionen (setzeStatus), Erstattung (erstatte), Liste, Detail, CSV und die Kundenrouten /api/shop/bestellungen*, /widerrufe*
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
shopConfig: schemaKeys('shopConfig'),
shopProducts: schemaKeys('shopProducts'),
shopOrders: schemaKeys('shopOrders'),
shopWiderrufe: schemaKeys('shopWiderrufe'),
termineOutbox: schemaKeys('termineOutbox'),
customerViewLog: schemaKeys('customerViewLog'),
},
datetimes: { ...SHOP_DATETIMES, ...TERMINE_DATETIMES },
defaultLimit: 25,
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js')
const K = await import('../server/services/shop/kontext.js')
const B = await import('../server/services/shop/bestellungen.js')
const sf = await import('../server/services/siteFeatures.js')
const { startTestApp, send } = await import('./support/testApp.js')
const { customerCookie, kundenansichtCookie, viewAsCookie, seedFixtures, customers } = await import('./support/fixtures.js')
const { default: shopRoutes, resetShopRoutesForTests } = await import('../server/routes/shop.js')
const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js')
const { STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js')
const { rechtstexteHash } = await import('../server/services/shop/rechtstexte.js')
const app = await startTestApp({ '/': kundenansichtGuard, '/api/shop': shopRoutes })
after(async () => {
setMailTransportForTests(null)
await Promise.all([app.close(), aw.close(), st.close()])
})
seedFixtures(aw)
aw.seed('websiteProjects', { $id: 'pt1', customerId: 'c1', projectName: 'Brotladen', archived: false })
aw.seed('websiteProjects', { $id: 'pt3', customerId: 'c2', projectName: 'Fremdes Projekt' })
const KUNDE = 'c1'
const PROJEKT = 'pt1'
const ACCOUNT = 'acct_haendler1'
const T0 = new Date('2026-10-05T12:00:00.000Z')
const nach = (s) => new Date(T0.getTime() + s * 1000)
const cookie = customerCookie('c1')
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)
let sink
let log
let ctx
let feature
let shopId
let zaehler = 0
async function aufbau(state = 'active') {
for (const c of ['portalFeatures', 'shopConfig', 'shopProducts', 'shopOrders', 'shopWiderrufe', 'termineOutbox', 'customerViewLog']) {
for (const id of [...aw.docs(c).keys()]) await deleteDocument(config.collections[c], id)
}
feature = await sf.ensureFeatureDoc(KUNDE, PROJEKT, 'shop')
await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields(state))
feature = await sf.getFeatureDoc(KUNDE, PROJEKT, 'shop')
shopId = feature.$id
aw.seed('shopConfig', {
$id: shopId,
customerId: KUNDE,
projectId: PROJEKT,
siteKey: feature.siteKey,
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,
})
st.refunds(ACCOUNT).clear()
st.setAccount(ACCOUNT, { charges_enabled: true, details_submitted: true, requirements: { currently_due: [] } })
K.leereCache()
ctx = await K.shopPerSiteKey(feature.siteKey, { now: T0 })
assert.ok(ctx, 'Shop-Kontext')
return ctx
}
beforeEach(async () => {
st.calls.length = 0
config.publicBaseUrl = 'https://project.webklar.com'
config.smtp.from = 'noreply@webklar.com'
config.shop.mode = 'pilot'
config.shop.pilotEmails = [customers.c1.email]
resetShopRoutesForTests()
resetOutboxForTests()
sink = mailSink()
setMailTransportForTests(sink)
log = { warn: mock.method(console, 'warn', () => {}), error: mock.method(console, 'error', () => {}) }
await aufbau()
})
afterEach(async () => {
await warteAufVersand()
mock.restoreAll()
})
const order = (id) => aw.docs('shopOrders').get(id)
const daten = (id) => JSON.parse(order(id).bestellung)
const eintraege = () => [...aw.docs('termineOutbox').values()]
const refunds = () => [...st.refunds(ACCOUNT).values()]
const POS = [{ produktId: 'a1', varianteId: null, name: 'Brot', varianteText: '', menge: 2, einzelCent: 600, summeCent: 1200, steuersatz: 0, widerruf: 'gesetzlich' }]
/** Bestellung direkt in die Ablage legen (Zustand wie nach schliesseAb). */
function bestellung(over = {}, datenOver = {}) {
zaehler += 1
const id = over.$id ?? `o${String(zaehler).padStart(3, '0')}`
const lieferart = datenOver.lieferart ?? 'versand'
const summeCent = over.summeCent ?? 1690
const d = {
positionen: POS,
summen: { zwischensummeCent: 1200, versandCent: 490, gesamtCent: summeCent, steuer: null, texte: { gesamt: '16,90 €' } },
lieferart,
land: 'DE',
anbieter: EINSTELLUNGEN.anbieter,
shopname: 'Brot & Mehr',
lieferzeit: '2–4 Werktage',
zahlungsarten: ['card'],
kaeufer: { name: 'Max Mustermann', email: 'max@example.com', telefon: '+49 170 1234567' }, // alter Datensatz mit Telefonnummer: wird nie ausgegeben
lieferadresse: lieferart === 'versand' ? { name: 'Max Mustermann', line1: 'Lieferweg 7', line2: '', postal_code: '67655', city: 'Kaiserslautern', country: 'DE' } : undefined,
zahlungsart: 'card',
verlauf: [{ at: T0.toISOString(), was: 'angelegt' }, { at: nach(60).toISOString(), was: 'bezahlt' }],
...(lieferart === 'abholung' ? { abholung: { adresse: EINSTELLUNGEN.abholung.adresse, hinweis: EINSTELLUNGEN.abholung.hinweis } } : {}),
...datenOver,
}
aw.seed('shopOrders', {
$id: id,
shopId,
customerId: KUNDE,
bestellnummer: `26-${String(zaehler).padStart(6, 'A')}`,
status: 'bezahlt',
tokenHash: 'h'.repeat(64),
stripeSessionId: `cs_${id}`,
paymentIntentId: `pi_${id}`,
summeCent,
erstelltAt: nach(zaehler).toISOString(),
ablaufAt: nach(1800).toISOString(),
bezahltAt: nach(60 + zaehler).toISOString(),
kaeuferEmail: 'max@example.com',
bestellung: JSON.stringify(d),
...over,
})
st.setPaymentIntent(ACCOUNT, `pi_${id}`, { amount: summeCent })
return id
}
// ------------------------------------------------------------------------------------------------ setzeStatus
test('versendet: aus bezahlt, Verlauf mit Dienst und Sendungsnummer, G10 an den Käufer', async () => {
const id = bestellung()
const erg = await B.setzeStatus(ctx, id, 'versendet', { versanddienst: ' DHL ', sendungsnummer: 'JJD0123 456' }, { now: nach(600) })
assert.equal(erg.order.status, 'versendet')
assert.equal(erg.mailOk, true)
assert.equal(order(id).status, 'versendet')
const v = daten(id).verlauf.at(-1)
assert.deepEqual(v, { at: nach(600).toISOString(), was: 'versendet', versanddienst: 'DHL', sendungsnummer: 'JJD0123 456' })
assert.equal(daten(id).versand.sendungsnummer, 'JJD0123 456')
await warteAufVersand()
assert.deepEqual(eintraege().map((e) => e.art), ['G10'])
const mail = sink.sent.find((m) => m.to[0].address === 'max@example.com')
assert.ok(mail.text.includes('JJD0123 456') && mail.text.includes('DHL'))
// zweiter Aufruf: 409
await assert.rejects(B.setzeStatus(ctx, id, 'versendet', {}, { now: nach(700) }), (e) => e.status === 409 && /schon als versendet/.test(e.message))
assert.equal(eintraege().length, 1)
})
test('versendet: Eingaben prüfen (Länge, Typ), nur bei Versand; abholbereit nur bei Abholung (G11)', async () => {
const v = bestellung()
await assert.rejects(B.setzeStatus(ctx, v, 'versendet', { versanddienst: 'x'.repeat(61) }), (e) => e.status === 400)
await assert.rejects(B.setzeStatus(ctx, v, 'versendet', { sendungsnummer: 'x'.repeat(81) }), (e) => e.status === 400)
await assert.rejects(B.setzeStatus(ctx, v, 'versendet', { sendungsnummer: 5 }), (e) => e.status === 400)
assert.equal(order(v).status, 'bezahlt')
await assert.rejects(B.setzeStatus(ctx, v, 'abholbereit'), (e) => e.status === 409 && /versendet/.test(e.message))
const a = bestellung({}, { lieferart: 'abholung' })
await assert.rejects(B.setzeStatus(ctx, a, 'versendet'), (e) => e.status === 409 && /abgeholt/.test(e.message))
const erg = await B.setzeStatus(ctx, a, 'abholbereit', {}, { now: nach(30) })
assert.equal(erg.order.status, 'abholbereit')
await warteAufVersand()
assert.deepEqual(eintraege().map((e) => e.art), ['G11'])
assert.ok(sink.sent.find((m) => m.to[0].address === 'max@example.com').text.includes('Mo–Fr 8–12 Uhr'))
await assert.rejects(B.setzeStatus(ctx, a, 'abholbereit'), (e) => e.status === 409)
})
test('abgeschlossen: aus bezahlt, versendet und abholbereit; nicht aus offen, erstattet, storniert; keine Mail', async () => {
const a = bestellung()
const b = bestellung({ status: 'versendet' })
const c = bestellung({ status: 'abholbereit' }, { lieferart: 'abholung' })
for (const id of [a, b, c]) assert.equal((await B.setzeStatus(ctx, id, 'abgeschlossen', {}, { now: nach(10) })).order.status, 'abgeschlossen')
assert.equal(daten(a).verlauf.at(-1).was, 'abgeschlossen')
for (const status of ['offen', 'erstattet', 'storniert', 'abgeschlossen']) {
const id = bestellung({ status })
await assert.rejects(B.setzeStatus(ctx, id, 'abgeschlossen'), (e) => e.status === 409, status)
}
await warteAufVersand()
assert.equal(eintraege().length, 0)
await assert.rejects(B.setzeStatus(ctx, a, 'quatsch'), (e) => e.status === 400)
})
test('storniert: nur aus offen - beendet die Kasse bei Stripe und verwirft; bezahlte Bestellung 409', async () => {
const id = bestellung({ status: 'offen', stripeSessionId: 'cs_offen1' })
st.accountSessions(ACCOUNT).set('cs_offen1', { id: 'cs_offen1', object: 'checkout.session', status: 'open', payment_status: 'unpaid' })
const erg = await B.setzeStatus(ctx, id, 'storniert', {}, { now: nach(100) })
assert.equal(erg.order.status, 'storniert')
assert.equal(st.accountSessions(ACCOUNT).get('cs_offen1').status, 'expired')
assert.equal(daten(id).verlauf.at(-1).was, 'vom_haendler')
const bezahlt = bestellung()
await assert.rejects(B.setzeStatus(ctx, bezahlt, 'storniert'), (e) => e.status === 409 && /noch nicht|nicht mehr/.test(e.message))
assert.equal(order(bezahlt).status, 'bezahlt')
})
test('fremde und unbekannte Bestellungen: 404, nichts verändert; auch per Shop-ID statt Kontext', async () => {
const fremd = bestellung({ shopId: 'f_anderer_shop' })
await assert.rejects(B.setzeStatus(ctx, fremd, 'abgeschlossen'), (e) => e.status === 404)
await assert.rejects(B.erstatte(ctx, fremd, {}), (e) => e.status === 404)
await assert.rejects(B.setzeStatus(ctx, 'gibtsnicht', 'abgeschlossen'), (e) => e.status === 404)
await assert.rejects(B.setzeStatus(ctx, '../x', 'abgeschlossen'), (e) => e.status === 404)
assert.equal(order(fremd).status, 'bezahlt')
assert.equal(refunds().length, 0)
const eigene = bestellung()
assert.equal((await B.setzeStatus(shopId, eigene, 'abgeschlossen')).order.status, 'abgeschlossen')
})
// ------------------------------------------------------------------------------------------------ erstatte
test('erstatte: Teilerstattung behält den Status, Restbetrag zählt, dann Rest voll => erstattet; G13 je Erstattung', async () => {
const id = bestellung({ status: 'versendet' })
const t = await B.erstatte(ctx, id, { betragCent: 500, grund: 'Brot war hart' }, { now: nach(100) })
assert.equal(t.voll, false)
assert.equal(t.order.status, 'versendet')
assert.equal(t.mailOk, true)
const r1 = refunds()
assert.equal(r1.length, 1)
assert.equal(r1[0].amount, 500)
assert.equal(r1[0].payment_intent, `pi_${id}`)
assert.deepEqual(daten(id).erstattungen, [{ rid: r1[0].id, betragCent: 500, grund: 'Brot war hart', at: nach(100).toISOString(), quelle: 'portal' }])
assert.equal(order(id).status, 'versendet')
assert.equal(daten(id).verlauf.at(-1).was, 'teilerstattet')
await assert.rejects(B.erstatte(ctx, id, { betragCent: 1191 }), (e) => e.status === 400 && /11,90/.test(e.message))
const rest = await B.erstatte(ctx, id, {}, { now: nach(200) })
assert.equal(rest.voll, true)
assert.equal(rest.erstattung.betragCent, 1190)
assert.equal(order(id).status, 'erstattet')
assert.equal(refunds().length, 2)
assert.equal(daten(id).erstattungen.length, 2)
// erstattet: nichts mehr zu holen
await assert.rejects(B.erstatte(ctx, id, {}), (e) => e.status === 409)
await warteAufVersand()
assert.deepEqual(eintraege().map((e) => e.art), ['G13', 'G13'])
const texte = sink.sent.filter((m) => m.to[0].address === 'max@example.com').map((m) => m.text)
assert.ok(texte.some((t) => t.includes('5,00') && t.includes('Brot war hart')))
// Idempotenz-Schluessel je Zaehlerstand, Aufruf im Namen des Haendlerkontos
const aufrufe = st.calls.filter((c) => c.path === '/v1/refunds')
assert.deepEqual(aufrufe.map((c) => c.idempotencyKey), [`shop-erstattung-${id}-0`, `shop-erstattung-${id}-1`])
assert.ok(aufrufe.every((c) => c.account === ACCOUNT))
})
test('erstatte: Eingaben und Zustände - Betrag ganze Cent 1..Rest, Grund ≤ 300, unbezahlt/storniert/ohne Zahlung 409', async () => {
const id = bestellung()
for (const betragCent of [0, -5, 1.5, 1691, '500']) {
await assert.rejects(B.erstatte(ctx, id, { betragCent }), (e) => e.status === 400, String(betragCent))
}
await assert.rejects(B.erstatte(ctx, id, { grund: 'x'.repeat(301) }), (e) => e.status === 400)
await assert.rejects(B.erstatte(ctx, id, { grund: 42 }), (e) => e.status === 400)
assert.equal(refunds().length, 0)
for (const status of ['offen', 'storniert']) {
await assert.rejects(B.erstatte(ctx, bestellung({ status }), {}), (e) => e.status === 409, status)
}
await assert.rejects(B.erstatte(ctx, bestellung({ paymentIntentId: '' }), {}), (e) => e.status === 409 && e.code === 'keine_zahlung')
assert.equal(refunds().length, 0)
// Ohne Betrag: der ganze Rest; Grund ganz weglassen geht
const voll = await B.erstatte(ctx, id, {}, { now: nach(5) })
assert.equal(voll.erstattung.betragCent, 1690)
assert.equal(voll.erstattung.grund, '')
assert.equal(order(id).status, 'erstattet')
})
test('erstatte: Erstattungen aus dem Stripe-Dashboard (quelle stripe) zählen zum Rest; gleiche rid wird nicht doppelt eingetragen', async () => {
const id = bestellung({}, { erstattungen: [{ rid: 're_dash', betragCent: 1000, grund: 'Stripe-Dashboard', at: nach(5).toISOString(), quelle: 'stripe' }] })
await assert.rejects(B.erstatte(ctx, id, { betragCent: 691 }), (e) => e.status === 400 && /6,90/.test(e.message))
// Stripe kennt die Dashboard-Erstattung ebenfalls (sonst waere der Rest dort groesser)
st.refunds(ACCOUNT).set('re_dash', { id: 're_dash', amount: 1000, payment_intent: `pi_${id}` })
const erg = await B.erstatte(ctx, id, {}, { now: nach(50) })
assert.equal(erg.erstattung.betragCent, 690)
assert.equal(erg.voll, true)
assert.deepEqual(daten(id).erstattungen.map((e) => e.quelle), ['stripe', 'portal'])
assert.equal(order(id).status, 'erstattet')
})
test('erstatte: zwei gleichzeitige Voll-Erstattungen - genau eine geht an Stripe, die andere 409', async () => {
const id = bestellung()
const ergebnisse = await Promise.allSettled([B.erstatte(ctx, id, {}, { now: nach(1) }), B.erstatte(ctx, id, {}, { now: nach(2) }), B.erstatte(ctx, id, {}, { now: nach(3) })])
assert.equal(ergebnisse.filter((e) => e.status === 'fulfilled').length, 1)
for (const e of ergebnisse.filter((x) => x.status === 'rejected')) assert.equal(e.reason.status, 409)
assert.equal(refunds().length, 1)
assert.equal(daten(id).erstattungen.length, 1)
await warteAufVersand()
assert.equal(eintraege().filter((e) => e.art === 'G13').length, 1)
})
test('erstatte: zwei gleichzeitige Teil-Erstattungen, die zusammen zu viel wären - die zweite wird abgelehnt', async () => {
const id = bestellung()
const ergebnisse = await Promise.allSettled([B.erstatte(ctx, id, { betragCent: 1000 }), B.erstatte(ctx, id, { betragCent: 1000 })])
assert.equal(ergebnisse.filter((e) => e.status === 'fulfilled').length, 1)
assert.equal(ergebnisse.find((e) => e.status === 'rejected').reason.status, 400)
assert.equal(refunds().length, 1)
})
test('erstatte: Stripe-Fehler => 502 mit festem Text ohne Stripe-Wortlaut, nichts gespeichert, keine Mail', async () => {
const id = bestellung()
st.setPaymentIntent(ACCOUNT, `pi_${id}`, { amount: 500 }) // Stripe kennt nur 5 € Rest => Betrag zu gross
const err = await B.erstatte(ctx, id, { betragCent: 1000, grund: 'x' }).catch((e) => e)
assert.equal(err.status, 502)
assert.equal(err.message, 'Die Erstattung hat bei Stripe nicht geklappt. Bitte versuch es noch einmal oder erstatte direkt im Stripe-Dashboard.')
assert.doesNotMatch(err.message, /greater|amount/i)
assert.equal(daten(id).erstattungen, undefined)
assert.equal(order(id).status, 'bezahlt')
await warteAufVersand()
assert.equal(eintraege().length, 0)
// danach geht es mit passendem Betrag wieder
const ok = await B.erstatte(ctx, id, { betragCent: 500 })
assert.equal(ok.erstattung.betragCent, 500)
})
// ------------------------------------------------------------------------------------------------ Liste und Detail
test('listeBestellungen: neueste zuerst, ohne Filter nur bezahlte, Statusfilter, Suche (Nummer-Anfang, Name, E-Mail), Blättern mit 50 je Seite', async () => {
const ids = []
for (let i = 0; i < 55; i += 1) ids.push(bestellung({ kaeuferEmail: `k${i}@example.com` }, { kaeufer: { name: i === 3 ? 'Erika Beispiel' : `Kunde ${i}`, email: `k${i}@example.com` } }))
const offen = bestellung({ status: 'offen' })
const storniert = bestellung({ status: 'storniert' })
const versendet = bestellung({ status: 'versendet' })
const s1 = await B.listeBestellungen(shopId, {})
assert.equal(s1.bestellungen.length, 50)
assert.equal(s1.mehr, true)
assert.equal(s1.bestellungen[0].id, versendet, 'zuletzt angelegt zuerst')
assert.ok(!s1.bestellungen.some((b) => [offen, storniert].includes(b.id)))
const s2 = await B.listeBestellungen(shopId, { seite: 2 })
assert.equal(s2.bestellungen.length, 6)
assert.equal(s2.mehr, false)
assert.equal(new Set([...s1.bestellungen, ...s2.bestellungen].map((b) => b.id)).size, 56)
assert.deepEqual((await B.listeBestellungen(shopId, { status: 'offen' })).bestellungen.map((b) => b.id), [offen])
assert.deepEqual((await B.listeBestellungen(shopId, { status: 'versendet' })).bestellungen.map((b) => b.id), [versendet])
await assert.rejects(B.listeBestellungen(shopId, { status: 'quatsch' }), (e) => e.status === 400)
const name = await B.listeBestellungen(shopId, { suche: 'erika' })
assert.deepEqual(name.bestellungen.map((b) => [b.id, b.kaeuferName]), [[ids[3], 'Erika Beispiel']])
const nr = await B.listeBestellungen(shopId, { suche: order(ids[7]).bestellnummer.toLowerCase() })
assert.deepEqual(nr.bestellungen.map((b) => b.id), [ids[7]])
assert.equal((await B.listeBestellungen(shopId, { suche: 'k12@' })).bestellungen.length, 1)
assert.equal((await B.listeBestellungen(shopId, { suche: 'gibtsnicht' })).bestellungen.length, 0)
// andere Shops sind unsichtbar
assert.equal((await B.listeBestellungen('f_anderer', {})).bestellungen.length, 0)
const zeile = s1.bestellungen[0]
assert.deepEqual(Object.keys(zeile).sort(), ['anzahlArtikel', 'bestellnummer', 'bezahltAt', 'erstattetCent', 'erstelltAt', 'id', 'kaeuferEmail', 'kaeuferName', 'lieferart', 'spaet', 'status', 'summeCent', 'summeText', 'ueberverkauft'])
})
test('bestellungKunde: alle Händlerdaten, keine Token-/Stripe-Kennungen, mögliche Aktionen und Rest', async () => {
const id = bestellung({}, { erstattungen: [{ rid: 're_1', betragCent: 190, grund: 'Kulanz', at: nach(9).toISOString(), quelle: 'portal' }], ueberverkauft: true })
const ansicht = B.bestellungKunde(await B.bestellungLesen(id))
assert.equal(ansicht.kaeufer.email, 'max@example.com')
assert.deepEqual(ansicht.kaeufer, { name: 'Max Mustermann', email: 'max@example.com' }, 'eine alte Telefonnummer im Datensatz wird nicht ausgegeben')
assert.ok(!JSON.stringify(ansicht).includes('1234567'))
assert.equal(ansicht.lieferadresse.line1, 'Lieferweg 7')
assert.equal(ansicht.erstattetCent, 190)
assert.equal(ansicht.restCent, 1500)
assert.deepEqual(ansicht.aktionen, ['versendet', 'abgeschlossen'])
assert.equal(ansicht.kannErstatten, true)
assert.equal(ansicht.ueberverkauft, true)
assert.equal(ansicht.verlauf.length, 2)
assert.equal(ansicht.positionen[0].name, 'Brot')
const text = JSON.stringify(ansicht)
assert.ok(!text.includes('hhhhhh') && !text.includes('cs_') && !text.includes('pi_'))
assert.deepEqual(B.bestellungKunde(await B.bestellungLesen(bestellung({ status: 'abholbereit' }, { lieferart: 'abholung' }))).aktionen, ['abgeschlossen'])
assert.deepEqual(B.bestellungKunde(await B.bestellungLesen(bestellung({ status: 'erstattet' }))).aktionen, [])
})
// ------------------------------------------------------------------------------------------------ CSV
test('csv: BOM, Semikolon, Kopfzeile, Beträge ohne NBSP, Berliner Datum, Formel-Injektion entschärft, Anführungszeichen verdoppelt', async () => {
const a = bestellung({ bezahltAt: '2026-10-04T22:30:00.000Z', summeCent: 1250, kaeuferEmail: '+evil@example.com' }, { kaeufer: { name: '=HYPERLINK("http://x.example";"a")', email: '+evil@example.com' } })
const b = bestellung(
{ bezahltAt: '2026-10-06T08:00:00.000Z', status: 'erstattet', kaeuferEmail: 'anna@example.com' },
{ kaeufer: { name: 'Anna; "Annie" Schmidt', email: 'anna@example.com' }, erstattungen: [{ rid: 're_x', betragCent: 1690, grund: '', at: T0.toISOString(), quelle: 'portal' }] }
)
bestellung({ status: 'offen' })
bestellung({ status: 'storniert' })
const erg = await B.csv(shopId, {}, { now: nach(0) })
assert.ok(erg.csv.startsWith('\uFEFFBestellnummer;Datum;Status;Name;E-Mail;Lieferart;Adresse;Positionen;Versand;Gesamt;Erstattet\r\n'))
assert.equal(erg.anzahl, 2)
const zeilen = erg.csv.slice(1).trimEnd().split('\r\n')
assert.equal(zeilen.length, 3)
assert.equal(erg.dateiname, 'webshop-bestellungen-anfang-2026-10-05.csv')
assert.ok(zeilen[1].startsWith(`${order(a).bestellnummer};05.10.2026 00:30;Bezahlt;`), zeilen[1]) // 22:30 UTC = 00:30 Berlin am Folgetag
assert.ok(zeilen[1].includes(`;"'=HYPERLINK(""http://x.example"";""a"")";'+evil@example.com;Versand;Lieferweg 7, 67655 Kaiserslautern, DE;2 × Brot;4,90;12,50;0,00`), zeilen[1])
assert.ok(zeilen[2].includes(';Erstattet;"Anna; ""Annie"" Schmidt";anna@example.com;'), zeilen[2])
assert.ok(zeilen[2].endsWith(';4,90;16,90;16,90'), zeilen[2])
assert.doesNotMatch(erg.csv, /\u00a0|€/)
for (const z of zeilen.slice(1)) assert.doesNotMatch(z, /(^|;)[=+@]/)
assert.ok(order(b))
})
test('csv: Zeitraum von/bis (Berliner Kalendertage, inklusiv), Dateiname, ungültige Eingaben 400', async () => {
bestellung({ bezahltAt: '2026-10-04T21:59:00.000Z' }) // 04.10. 23:59 Berlin
const b = bestellung({ bezahltAt: '2026-10-04T22:00:00.000Z' }) // 05.10. 00:00 Berlin
const c = bestellung({ bezahltAt: '2026-10-05T21:59:00.000Z' }) // 05.10. 23:59 Berlin
bestellung({ bezahltAt: '2026-10-05T22:00:00.000Z' }) // 06.10. 00:00 Berlin
const erg = await B.csv(shopId, { von: '2026-10-05', bis: '2026-10-05' })
assert.equal(erg.dateiname, 'webshop-bestellungen-2026-10-05-2026-10-05.csv')
const nummern = erg.csv.split('\r\n').slice(1, -1).map((z) => z.split(';')[0])
assert.deepEqual(nummern, [order(b).bestellnummer, order(c).bestellnummer])
assert.equal((await B.csv(shopId, { von: '2026-10-06' })).anzahl, 1)
for (const eingabe of [{ von: '05.10.2026' }, { bis: '2026-02-30' }, { von: '2026-10-06', bis: '2026-10-05' }, { von: 5 }]) {
await assert.rejects(B.csv(shopId, eingabe), (e) => e.status === 400, JSON.stringify(eingabe))
}
assert.equal((await B.csv('f_anderer', {})).anzahl, 0)
})
// ------------------------------------------------------------------------------------------------ Routen
const lesen = (pfad, projectId = PROJEKT, opts = { cookie }) => send('GET', `/api/shop/${pfad}${pfad.includes('?') ? '&' : '?'}projectId=${projectId}`, opts)
const schreiben = (pfad, body = {}, opts = { cookie }) => send('POST', `/api/shop/${pfad}`, { ...opts, body: { projectId: PROJEKT, ...body } })
test('Routen: Liste, Detail, Aktion, Erstatten - Antworten ohne Token und Stripe-Kennungen', async () => {
const id = bestellung()
bestellung({ status: 'versendet' })
const liste = await lesen('bestellungen')
assert.equal(liste.status, 200)
assert.equal(liste.body.bestellungen.length, 2)
assert.deepEqual([liste.body.seite, liste.body.mehr], [1, false])
assert.equal((await lesen('bestellungen?status=versendet')).body.bestellungen.length, 1)
assert.equal((await lesen('bestellungen?status=quatsch')).status, 400)
assert.equal((await lesen('bestellungen?suche=max')).body.bestellungen.length, 2)
const detail = await lesen(`bestellungen/${id}`)
assert.equal(detail.status, 200)
assert.equal(detail.body.bestellung.kaeufer.name, 'Max Mustermann')
assert.ok(!JSON.stringify(detail.body).includes('tokenHash') && !JSON.stringify(detail.body).includes('pi_'))
const versand = await schreiben(`bestellungen/${id}/aktion`, { aktion: 'versendet', versanddienst: 'DHL', sendungsnummer: 'ABC123' })
assert.equal(versand.status, 200)
assert.equal(versand.body.bestellung.status, 'versendet')
assert.equal(versand.body.mailOk, true)
const nochmal = await schreiben(`bestellungen/${id}/aktion`, { aktion: 'versendet' })
assert.deepEqual([nochmal.status, nochmal.body.code], [409, 'falscher_status'])
assert.equal((await schreiben(`bestellungen/${id}/aktion`, { aktion: 'quatsch' })).status, 400)
assert.equal((await schreiben(`bestellungen/${id}/aktion`, {})).status, 400)
const teil = await schreiben(`bestellungen/${id}/erstatten`, { betragCent: 300, grund: 'Kulanz' })
assert.equal(teil.status, 200)
assert.deepEqual([teil.body.voll, teil.body.bestellung.erstattetCent, teil.body.bestellung.status], [false, 300, 'versendet'])
assert.equal(teil.body.erstattung.quelle, 'portal')
const zuViel = await schreiben(`bestellungen/${id}/erstatten`, { betragCent: 99999 })
assert.equal(zuViel.status, 400)
const rest = await schreiben(`bestellungen/${id}/erstatten`, {})
assert.deepEqual([rest.status, rest.body.voll, rest.body.bestellung.status], [200, true, 'erstattet'])
// Stripe-Fehler: 502, fester Text
const kaputt = bestellung()
st.setPaymentIntent(ACCOUNT, `pi_${kaputt}`, { amount: 100 })
const fehler = await schreiben(`bestellungen/${kaputt}/erstatten`, { betragCent: 500 })
assert.equal(fehler.status, 502)
assert.match(fehler.body.error, /^Die Erstattung hat bei Stripe nicht geklappt\./)
})
test('Routen: parallele Erstattungen über HTTP erstatten nie doppelt', async () => {
const id = bestellung()
const antworten = await Promise.all([1, 2, 3].map(() => schreiben(`bestellungen/${id}/erstatten`, {})))
assert.deepEqual(antworten.map((a) => a.status).sort(), [200, 409, 409])
assert.equal(refunds().length, 1)
})
test('Routen: fremde Bestellung 404, fremdes Projekt 403, ohne Session 401, nicht eingerichtet 409', async () => {
const fremd = bestellung({ shopId: 'f_anderer_shop' })
assert.equal((await lesen(`bestellungen/${fremd}`)).status, 404)
assert.equal((await schreiben(`bestellungen/${fremd}/aktion`, { aktion: 'abgeschlossen' })).status, 404)
assert.equal((await schreiben(`bestellungen/${fremd}/erstatten`, {})).status, 404)
assert.equal((await lesen('bestellungen/nix')).status, 404)
assert.equal(order(fremd).status, 'bezahlt')
assert.equal(refunds().length, 0)
assert.equal((await lesen('bestellungen', 'pt3')).status, 403)
assert.equal((await send('GET', `/api/shop/bestellungen?projectId=${PROJEKT}`)).status, 401)
assert.equal((await send('POST', '/api/shop/bestellungen/x/aktion', { body: { projectId: PROJEKT } })).status, 401)
config.shop.mode = 'off'
assert.equal((await lesen('bestellungen')).body.code, 'nicht_verfuegbar')
})
test('Routen: Kundenansicht darf lesen (Liste, Detail, CSV, Widerrufe), aber weder Status ändern noch erstatten (403)', async () => {
const id = bestellung()
const KV = { cookie: kundenansichtCookie({ customerId: 'c1' }), headers: { 'X-Kundenansicht': 'a'.repeat(32) } }
assert.equal((await lesen('bestellungen', PROJEKT, KV)).status, 200)
assert.equal((await lesen(`bestellungen/${id}`, PROJEKT, KV)).status, 200)
assert.equal((await lesen('bestellungen.csv', PROJEKT, KV)).status, 200)
assert.equal((await lesen('widerrufe', PROJEKT, KV)).status, 200)
for (const [pfad, body] of [[`bestellungen/${id}/aktion`, { aktion: 'abgeschlossen' }], [`bestellungen/${id}/erstatten`, {}], ['widerrufe/w1/erledigt', {}]]) {
const res = await schreiben(pfad, body, KV)
assert.equal(res.status, 403, pfad)
}
// Der Router selbst sperrt ebenfalls (ohne den vorgeschalteten Guard), auch bei "Ansehen als"
const express = (await import('express')).default
const { sessionMiddleware } = await import('../server/middleware/session.js')
const roh = express()
roh.use(sessionMiddleware())
roh.use(express.json())
roh.use('/api/shop', shopRoutes)
const server = await new Promise((resolve) => {
const s = roh.listen(0, '127.0.0.1', () => resolve(s))
})
try {
const url = `http://127.0.0.1:${server.address().port}`
for (const c of [kundenansichtCookie({ customerId: 'c1' }), viewAsCookie('c1')]) {
for (const pfad of [`bestellungen/${id}/aktion`, `bestellungen/${id}/erstatten`, 'widerrufe/w1/erledigt']) {
const res = await fetch(`${url}/api/shop/${pfad}`, { method: 'POST', headers: { 'content-type': 'application/json', cookie: c }, body: JSON.stringify({ projectId: PROJEKT, aktion: 'abgeschlossen' }) })
assert.equal(res.status, 403, pfad)
}
}
} finally {
await new Promise((resolve) => server.close(resolve))
}
assert.equal(order(id).status, 'bezahlt')
assert.equal(refunds().length, 0)
})
test('Routen: CSV als Download mit BOM, Dateiname und Zeitraum; ungültiges Datum 400', async () => {
bestellung({ bezahltAt: '2026-10-05T10:00:00.000Z' }, { kaeufer: { name: '@SUM(A1)', email: 'a@example.com' } })
bestellung({ bezahltAt: '2026-09-01T10:00:00.000Z' })
const res = await fetch(`${app.url}/api/shop/bestellungen.csv?projectId=${PROJEKT}&von=2026-10-01&bis=2026-10-31`, { headers: { cookie } })
assert.equal(res.status, 200)
assert.match(res.headers.get('content-type'), /^text\/csv; charset=utf-8/)
assert.equal(res.headers.get('content-disposition'), 'attachment; filename="webshop-bestellungen-2026-10-01-2026-10-31.csv"')
const bytes = Buffer.from(await res.arrayBuffer())
assert.deepEqual([...bytes.subarray(0, 3)], [0xef, 0xbb, 0xbf], 'BOM')
const text = bytes.toString('utf8')
assert.ok(text.includes('Bestellnummer;Datum;'))
assert.equal(text.trimEnd().split('\r\n').length, 2)
assert.ok(text.includes(";'@SUM(A1);"))
assert.equal((await lesen('bestellungen.csv?von=morgen')).status, 400)
assert.equal((await send('GET', `/api/shop/bestellungen.csv?projectId=${PROJEKT}`)).status, 401)
})
test('Routen M2: deaktivierter Shop - Lesen, CSV, Status ändern, Erstatten und „Widerruf erledigt“ gehen weiter; Einstellungen und Artikel bleiben gesperrt', async () => {
const id = bestellung()
aw.seed('shopWiderrufe', { $id: 'w1', shopId, customerId: KUNDE, bestellnummer: '26-A', name: 'Alt', email: 'alt@example.com', nachricht: '', eingangAt: nach(1).toISOString() })
await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields('disabled'))
assert.equal((await lesen('bestellungen')).status, 200)
assert.equal((await lesen(`bestellungen/${id}`)).status, 200)
assert.equal((await lesen('bestellungen.csv')).status, 200)
assert.equal((await lesen('widerrufe')).status, 200)
const versand = await schreiben(`bestellungen/${id}/aktion`, { aktion: 'versendet' })
assert.equal(versand.status, 200)
assert.equal(order(id).status, 'versendet')
const teil = await schreiben(`bestellungen/${id}/erstatten`, { betragCent: 300 })
assert.deepEqual([teil.status, teil.body.voll], [200, false])
assert.equal(refunds().length, 1)
const rest = await schreiben(`bestellungen/${id}/erstatten`, {})
assert.deepEqual([rest.status, rest.body.voll], [200, true])
const erledigt = await schreiben('widerrufe/w1/erledigt')
assert.equal(erledigt.status, 200)
assert.ok(erledigt.body.widerruf.bearbeitetAt)
// Alles andere, was verwaltet oder geoeffnet wird, bleibt gesperrt
for (const [methode, pfad, body] of [['POST', 'produkte', { produkt: { name: 'X', preisCent: 100 } }], ['PUT', 'einstellungen', { einstellungen: {} }], ['POST', 'oeffnen', { offen: true }]]) {
const res = await send(methode, `/api/shop/${pfad}`, { cookie, body: { projectId: PROJEKT, ...body } })
assert.deepEqual([res.status, res.body.code], [409, 'nicht_eingerichtet'], pfad)
}
})
test('Routen: Widerrufe - Liste (neueste zuerst, nur eigener Shop) und als erledigt markieren', async () => {
aw.seed('shopWiderrufe', { $id: 'w_alt', shopId, customerId: KUNDE, bestellnummer: '26-A', name: 'Alt', email: 'alt@example.com', nachricht: '', eingangAt: nach(1).toISOString() })
aw.seed('shopWiderrufe', { $id: 'w_neu', shopId, customerId: KUNDE, bestellnummer: '26-B', name: 'Neu', email: 'neu@example.com', nachricht: 'Hallo', eingangAt: nach(99).toISOString() })
aw.seed('shopWiderrufe', { $id: 'w_fremd', shopId: 'f_anderer', customerId: 'c2', bestellnummer: '26-C', name: 'Fremd', email: 'f@example.com', nachricht: '', eingangAt: nach(200).toISOString() })
const liste = await lesen('widerrufe')
assert.equal(liste.status, 200)
assert.deepEqual(liste.body.widerrufe.map((w) => w.id), ['w_neu', 'w_alt'])
const erledigt = await schreiben('widerrufe/w_neu/erledigt')
assert.equal(erledigt.status, 200)
assert.ok(erledigt.body.widerruf.bearbeitetAt)
assert.equal((await schreiben('widerrufe/w_fremd/erledigt')).status, 404)
assert.equal(aw.docs('shopWiderrufe').get('w_fremd').bearbeitetAt ?? null, null)
})