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