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>
613 lines
35 KiB
JavaScript
613 lines
35 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 { 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)
|
||
})
|