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) })