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 E2: Abschluss einer bezahlten Sitzung (schliesseAb), Verwerfen, Abgleich offener Bestellungen (pruefeOffene), Minutenjob. // Mails landen in der Senke (Kaeufer ueber termineOutbox, Haendler direkt), nichts verlaesst den Prozess. const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures'), shopConfig: schemaKeys('shopConfig'), shopProducts: schemaKeys('shopProducts'), shopOrders: schemaKeys('shopOrders'), shopRechtstexteArchiv: schemaKeys('shopRechtstexteArchiv'), termineOutbox: schemaKeys('termineOutbox'), }, 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 { archiviereRechtstexte } = await import('../server/services/shop/archiv.js') const B = await import('../server/services/shop/bestellungen.js') const J = await import('../server/services/shop/jobs.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') after(async () => { setMailTransportForTests(null) await aw.close() await st.close() }) const KUNDE = 'c_kunde01' const PROJEKT = 'p_projekt01' const FEATURE = 'f_p_projekt01_shop' const SITE_KEY = 'abcdefghijklmnop' const ACCOUNT = 'acct_haendler1' const T0 = new Date('2026-10-05T12:00:00.000Z') const nach = (s) => new Date(T0.getTime() + s * 1000) const PORTAL = 'https://project.webklar.com' 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 featureDoc = { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'shop', siteKey: SITE_KEY, state: 'active', enabled: true } const shopDoc = (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 log let ctx async function aufbau({ produkte = [artikel('a1', { bestand: 5 })], shop = {} } = {}) { for (const c of ['portalFeatures', 'shopConfig', 'shopProducts', 'shopOrders', 'shopRechtstexteArchiv', 'websiteProjects', 'termineOutbox', 'customers']) { for (const id of [...aw.docs(c).keys()]) await deleteDocument(config.collections[c], id) } aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Brotladen', liveDomain: 'https://www.brot.example' }) aw.seed('portalFeatures', featureDoc) aw.seed('shopConfig', shopDoc(shop)) aw.seed('customers', { $id: KUNDE, name: 'Erika Muster', email: 'erika@brot.example' }) for (const p of produkte) aw.seed('shopProducts', p) st.setAccount(ACCOUNT, { charges_enabled: true, details_submitted: true, requirements: { currently_due: [] } }) K.leereCache() ctx = await K.shopPerSiteKey(SITE_KEY, { now: T0 }) return ctx } beforeEach(() => { st.calls.length = 0 config.publicBaseUrl = PORTAL config.smtp.from = 'noreply@webklar.com' config.shop.mode = 'on' resetOutboxForTests() sink = mailSink() setMailTransportForTests(sink) log = { warn: mock.method(console, 'warn', () => {}), error: mock.method(console, 'error', () => {}) } K.leereCache() }) afterEach(async () => { await warteAufVersand() mock.restoreAll() }) const orders = () => [...aw.docs('shopOrders').values()] const order = (id) => aw.docs('shopOrders').get(id) const produkt = (id) => JSON.parse(aw.docs('shopProducts').get(id).produkt) const eintraege = () => [...aw.docs('termineOutbox').values()] const warnLog = () => log.warn.mock.calls.map((a) => a.arguments.map(String).join(' ')).join('\n') const kunde = { name: 'Max Mustermann', email: 'max@example.com', phone: '+49 170 1234567', address: { line1: 'Rechnungsweg 1', line2: null, postal_code: '12345', city: 'Musterstadt', country: 'DE' } } const versandAn = { name: 'Max Mustermann', address: { line1: 'Lieferweg 7', line2: 'Hinterhaus', postal_code: '67655', city: 'Kaiserslautern', country: 'DE', state: null } } /** Bestellung anlegen (offen) und die zugehoerige Stripe-Sitzung liefern. */ async function bestelle(positionen = [{ produktId: 'a1', menge: 2 }], over = {}) { const r = await B.legeBestellungAn(ctx, { positionen, lieferart: 'versand', land: 'DE', ...over }, { now: T0 }) const doc = order(r.orderId) return { ...r, doc, sessionId: doc.stripeSessionId } } /** Sitzung als bezahlt markieren und so zurueckgeben, wie der Webhook sie schickt. */ const zahle = (b, felder = {}) => st.completeSession(ACCOUNT, b.sessionId, { payment_status: 'paid', amount_total: b.doc.summeCent, customer_details: kunde, shipping: versandAn, payment_intent: 'pi_test1', payment_method_types: ['card'], ...felder, }) test('schliesseAb: Normalfall - bezahlt, Käuferdaten, Bestand abgezogen, G09 an den Käufer und K25 an den Händler ohne BCC', async () => { await aufbau() const b = await bestelle() const session = zahle(b) const erg = await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(120) }) assert.equal(erg.neu, true) assert.equal(erg.order.status, 'bezahlt') const doc = order(b.orderId) assert.equal(doc.status, 'bezahlt') assert.equal(new Date(doc.bezahltAt).toISOString(), nach(120).toISOString()) assert.equal(doc.paymentIntentId, 'pi_test1') assert.equal(doc.kaeuferEmail, 'max@example.com') const daten = JSON.parse(doc.bestellung) assert.deepEqual(daten.kaeufer, { name: 'Max Mustermann', email: 'max@example.com' }, 'Stripe liefert eine Telefonnummer, gespeichert wird sie nicht') assert.ok(!doc.bestellung.includes('1234567'), 'keine Telefonnummer irgendwo im Bestell-JSON') assert.deepEqual(daten.lieferadresse, { name: 'Max Mustermann', line1: 'Lieferweg 7', line2: 'Hinterhaus', postal_code: '67655', city: 'Kaiserslautern', country: 'DE' }) assert.equal(daten.rechnungsadresse.line1, 'Rechnungsweg 1') assert.equal(daten.zahlungsart, 'card') assert.deepEqual(daten.verlauf.map((v) => v.was), ['angelegt', 'bezahlt']) assert.equal(daten.ueberverkauft, undefined) assert.equal(produkt('a1').bestand, 3) await warteAufVersand() const [g09] = eintraege() assert.equal(eintraege().length, 1) assert.equal(g09.art, 'G09') assert.equal(g09.bookingId, b.orderId) assert.equal(g09.featureId, FEATURE) const mails = sink.sent assert.equal(mails.length, 2) const kaeufer = mails.find((m) => m.to[0].address === 'max@example.com') const haendler = mails.find((m) => m.to[0].address === 'erika@brot.example') assert.ok(kaeufer && haendler) assert.match(kaeufer.subject, new RegExp(`Ihre Bestellung ${doc.bestellnummer} bei Brot & Mehr`)) assert.ok(kaeufer.text.includes('Lieferweg 7') && kaeufer.text.includes('Hinterhaus')) assert.match(haendler.text, /Max Mustermann/) assert.ok(!haendler.bcc && !kaeufer.bcc, 'kein BCC') assert.doesNotMatch(haendler.text, /Bestand reichte nicht/) assert.doesNotMatch(haendler.text, /Lastschrift/) assert.equal(order(b.orderId).status, 'bezahlt') }) test('schliesseAb I-6: G09 enthält die Rechtstexte der Bestellung (Archiv zum Hash), auch wenn der Händler sie danach geändert und neu bestätigt hat', async () => { await aufbau() const b = await bestelle() await archiviereRechtstexte({ shopId: FEATURE, customerId: KUNDE, hash: HASH, at: T0.toISOString(), von: 'k@example.invalid', texte: { ...TEXTE, agb: 'AGB ZUM ZEITPUNKT DER BESTELLUNG', versandUndZahlung: 'x', gewaehrleistung: 'GEWÄHRLEISTUNG DAMALS' } }) // neue Fassung bestätigt, bevor bezahlt wird (SEPA: Tage später) const neu = { ...TEXTE, agb: 'NEUE AGB NACH DER BESTELLUNG' } aw.seed('shopConfig', shopDoc({ rechtstexte: JSON.stringify({ entwurf: neu, bestaetigt: { texte: neu, hash: 'e'.repeat(64), at: '2026-10-06T10:00:00.000Z', von: 'k@example.invalid' } }) })) K.leereCache() assert.equal(JSON.parse(order(b.orderId).bestellung).rechtstexteHash, HASH) await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b), now: nach(120) }) await warteAufVersand() const g09 = sink.sent.find((m) => m.to[0].address === 'max@example.com') assert.match(g09.text, /AGB ZUM ZEITPUNKT DER BESTELLUNG/) assert.match(g09.text, /GEWÄHRLEISTUNG DAMALS/) assert.doesNotMatch(g09.text, /NEUE AGB/) }) test('schliesseAb: idempotent - zweiter Aufruf ändert nichts, keine zweite Mail, Bestand nur einmal', async () => { await aufbau() const b = await bestelle() const session = zahle(b) const [a, z] = await Promise.all([B.schliesseAb({ accountId: ACCOUNT, session, now: nach(60) }), B.schliesseAb({ accountId: ACCOUNT, session, now: nach(61) })]) assert.deepEqual([a.neu, z.neu].sort(), [false, true]) const dritte = await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(300) }) assert.equal(dritte.neu, false) assert.equal(dritte.order.status, 'bezahlt') await warteAufVersand() assert.equal(eintraege().length, 1) assert.equal(sink.sent.length, 2) assert.equal(produkt('a1').bestand, 3) assert.equal(JSON.parse(order(b.orderId).bestellung).verlauf.filter((v) => v.was === 'bezahlt').length, 1) }) test('schliesseAb: falsches Konto, fremde oder unbekannte Sitzung ändern nichts', async () => { await aufbau() const b = await bestelle() const session = zahle(b) assert.deepEqual(await B.schliesseAb({ accountId: 'acct_fremd', session, now: nach(60) }), { order: null, neu: false }) assert.match(warnLog(), /gehört nicht zum Stripe-Konto/) assert.deepEqual(await B.schliesseAb({ accountId: ACCOUNT, session: { ...session, metadata: { ...session.metadata, wkShop: undefined } }, now: nach(60) }), { order: null, neu: false }) assert.deepEqual(await B.schliesseAb({ accountId: ACCOUNT, session: { ...session, metadata: { wkShop: '1', orderId: 'gibt-es-nicht' } }, now: nach(60) }), { order: null, neu: false }) assert.deepEqual(await B.schliesseAb({ accountId: ACCOUNT, session: { ...session, metadata: { wkShop: '1' } }, now: nach(60) }), { order: null, neu: false }) assert.deepEqual(await B.schliesseAb({ accountId: ACCOUNT, session: null, now: nach(60) }), { order: null, neu: false }) assert.equal(order(b.orderId).status, 'offen') assert.equal(produkt('a1').bestand, 5) await warteAufVersand() assert.equal(sink.sent.length, 0) }) test('schliesseAb: SEPA noch nicht bezahlt ist wirkungslos, bis async_payment_succeeded kommt; SEPA-Hinweis in K25', async () => { await aufbau() const b = await bestelle() const unbezahlt = zahle(b, { payment_status: 'unpaid', payment_method_types: ['sepa_debit'] }) const erg = await B.schliesseAb({ accountId: ACCOUNT, session: unbezahlt, now: nach(60) }) assert.equal(erg.neu, false) assert.equal(erg.order.status, 'offen') assert.equal(produkt('a1').bestand, 5) await warteAufVersand() assert.equal(sink.sent.length, 0) const bezahlt = zahle(b, { payment_status: 'paid', payment_method_types: ['sepa_debit'] }) const spaeter = await B.schliesseAb({ accountId: ACCOUNT, session: bezahlt, now: nach(3 * 86400) }) assert.equal(spaeter.neu, true) assert.equal(JSON.parse(order(b.orderId).bestellung).zahlungsart, 'sepa_debit') await warteAufVersand() assert.match(sink.sent.find((m) => m.to[0].address === 'erika@brot.example').text, /Lastschrift/) }) test('meldeEingang I-1: nur bei unbezahlter, abgeschlossener Sitzung einer offenen Bestellung - einmal G15 an die Adresse der Sitzung, Marker, kein Vertragstext', async () => { await aufbau() const b = await bestelle() const unbezahlt = zahle(b, { payment_status: 'unpaid', payment_method_types: ['sepa_debit'] }) assert.deepEqual(await B.meldeEingang({ accountId: ACCOUNT, session: { ...unbezahlt, payment_status: 'paid' }, now: nach(60) }), { gesendet: false }, 'bezahlt ist Sache von schliesseAb') assert.deepEqual(await B.meldeEingang({ accountId: 'acct_fremd', session: unbezahlt, now: nach(60) }), { gesendet: false }, 'fremdes Konto') assert.deepEqual(await B.meldeEingang({ accountId: ACCOUNT, session: { ...unbezahlt, customer_details: null }, now: nach(60) }), { gesendet: false }, 'ohne Adresse') await warteAufVersand() assert.equal(sink.sent.length, 0) assert.deepEqual(await B.meldeEingang({ accountId: ACCOUNT, session: unbezahlt, now: nach(60) }), { gesendet: true }) assert.deepEqual(await B.meldeEingang({ accountId: ACCOUNT, session: unbezahlt, now: nach(120) }), { gesendet: false }, 'nur einmal') await warteAufVersand() assert.equal(sink.sent.length, 1) assert.equal(sink.sent[0].to[0].address, 'max@example.com') assert.match(sink.sent[0].subject, /^Wir haben Ihre Bestellung \S+ erhalten$/) assert.doesNotMatch(sink.sent[0].text, /Kaufvertrag ist damit geschlossen/) assert.equal(order(b.orderId).status, 'offen') assert.equal(JSON.parse(order(b.orderId).bestellung).mailsEingangAt, nach(60).toISOString()) // Später bezahlt: G09 folgt, der Marker bleibt erhalten await B.schliesseAb({ accountId: ACCOUNT, session: { ...unbezahlt, payment_status: 'paid' }, now: nach(3 * 86400) }) await warteAufVersand() assert.equal(JSON.parse(order(b.orderId).bestellung).mailsEingangAt, nach(60).toISOString()) assert.deepEqual(await B.meldeEingang({ accountId: ACCOUNT, session: unbezahlt, now: nach(4 * 86400) }), { gesendet: false }, 'nicht mehr offen') }) test('meldeEingang I-1: scheitert das Einreihen, bleibt der Marker leer und die Versuche werden gezählt (höchstens 5)', async () => { await aufbau() const b = await bestelle() const unbezahlt = zahle(b, { payment_status: 'unpaid', payment_method_types: ['sepa_debit'] }) await deleteDocument(config.collections.portalFeatures, FEATURE) // Shop-Kontext fehlt: das Einreihen scheitert K.leereCache() for (let i = 0; i < 7; i += 1) assert.deepEqual(await B.meldeEingang({ accountId: ACCOUNT, session: unbezahlt, now: nach(60 + i) }), { gesendet: false }) const d = JSON.parse(order(b.orderId).bestellung) assert.equal(d.mailsEingangAt, undefined) assert.equal(d.mailsVersuche.eingang, 5) assert.match(warnLog(), /G15 zur Bestellung/) }) test('pruefeOffene I-1: SEPA unterwegs - der Minutenjob holt die Eingangsmail G15 nach, ein zweiter Lauf schickt keine zweite', async () => { await aufbau() const sepa = await bestelle([{ produktId: 'a1', menge: 1 }]) zahle(sepa, { payment_status: 'unpaid', payment_method_types: ['sepa_debit'] }) const spaet = nach(30 * 60 + 30 + 6 * 60) const z = await B.pruefeOffene(spaet) assert.deepEqual(z, { geprueft: 1, bezahlt: 0, verworfen: 0, wartend: 1, fehler: 0 }) await warteAufVersand() assert.equal(sink.sent.length, 1) assert.equal(sink.sent[0].to[0].address, 'max@example.com') await B.pruefeOffene(nach(30 * 60 + 30 + 8 * 60)) await warteAufVersand() assert.equal(sink.sent.length, 1) assert.equal(order(sepa.orderId).status, 'offen') }) test('schliesseAb: späte Zahlung nach Ablauf wird angenommen, andere Stornos nicht', async () => { await aufbau() const a = await bestelle() assert.equal((await B.verwirf(a.orderId, 'abgelaufen', { now: nach(1900) })).verworfen, true) assert.equal(order(a.orderId).status, 'storniert') const erg = await B.schliesseAb({ accountId: ACCOUNT, session: zahle(a), now: nach(2000) }) assert.equal(erg.neu, true) assert.equal(order(a.orderId).status, 'bezahlt') const daten = JSON.parse(order(a.orderId).bestellung) assert.equal(daten.spaet, true) assert.deepEqual(daten.verlauf.map((v) => v.was), ['angelegt', 'abgelaufen', 'bezahlt']) assert.equal(produkt('a1').bestand, 3) const c = await bestelle([{ produktId: 'a1', menge: 1 }]) assert.equal((await B.verwirf(c.orderId, 'zahlung_fehlgeschlagen', { now: nach(10) })).verworfen, true) const nein = await B.schliesseAb({ accountId: ACCOUNT, session: zahle(c), now: nach(20) }) assert.equal(nein.neu, false) assert.equal(order(c.orderId).status, 'storniert') assert.equal(produkt('a1').bestand, 3) }) test('schliesseAb: abweichender Betrag - trotzdem bezahlt, Vermerk im Verlauf, Warnung ohne Käuferdaten', async () => { await aufbau() const b = await bestelle() const erg = await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b, { amount_total: b.doc.summeCent + 100 }), now: nach(60) }) assert.equal(erg.neu, true) const daten = JSON.parse(order(b.orderId).bestellung) const vermerk = daten.verlauf.find((v) => v.was === 'betrag_abweichend') assert.deepEqual({ e: vermerk.erwartetCent, b: vermerk.bezahltCent }, { e: b.doc.summeCent, b: b.doc.summeCent + 100 }) assert.match(warnLog(), /weicht vom Bestellwert ab/) assert.doesNotMatch(warnLog(), /max@example|Mustermann|Lieferweg/) }) test('schliesseAb: Variantenbestand wird in der Variante abgezogen, Artikel ohne Bestand bleiben unberührt', async () => { await aufbau({ produkte: [ artikel('a1', { bestand: 10, varianten: { name: 'Größe', werte: [{ name: 'M', preisCent: 2000, bestand: 4 }, { name: 'L', preisCent: 2200, bestand: 6 }] } }), artikel('a2'), ], }) const werte = produkt('a1').varianten.werte const L = werte.find((w) => w.name === 'L') const M = werte.find((w) => w.name === 'M') const b = await bestelle([{ produktId: 'a1', varianteId: L.id, menge: 2 }, { produktId: 'a1', varianteId: M.id, menge: 1 }, { produktId: 'a2', menge: 3 }]) await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b), now: nach(60) }) const danach = produkt('a1') assert.equal(danach.varianten.werte.find((w) => w.name === 'L').bestand, 4) assert.equal(danach.varianten.werte.find((w) => w.name === 'M').bestand, 3) assert.equal(danach.bestand, null, 'bei Varianten führen die Varianten den Bestand') assert.equal(produkt('a2').bestand, null) }) test('schliesseAb: Bestand reicht nicht - auf 0 gesetzt, Händler-Hinweis ueberverkauft in K25', async () => { await aufbau() const b = await bestelle([{ produktId: 'a1', menge: 4 }]) // Der Haendler hat den Bestand inzwischen verringert const p = produkt('a1') p.bestand = 1 await updateDocument(config.collections.shopProducts, 'a1', { produkt: JSON.stringify(p) }) const erg = await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b), now: nach(60) }) assert.equal(erg.neu, true) assert.equal(produkt('a1').bestand, 0) assert.equal(JSON.parse(order(b.orderId).bestellung).ueberverkauft, true) await warteAufVersand() assert.match(sink.sent.find((m) => m.to[0].address === 'erika@brot.example').text, /Bestand reichte nicht/) assert.equal(order(b.orderId).status, 'bezahlt') }) test('schliesseAb: Mailfehler machen die Zahlung nicht rückgängig', async () => { await aufbau() const b = await bestelle() aw.docs('customers').clear() const erg = await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b), now: nach(60) }) assert.equal(erg.neu, true) assert.equal(order(b.orderId).status, 'bezahlt') assert.equal(produkt('a1').bestand, 3) }) test('schliesseAb: Abholung ohne Lieferadresse, Telefon und Namen fehlen', async () => { await aufbau() const b = await bestelle([{ produktId: 'a1', menge: 1 }], { lieferart: 'abholung', land: undefined }) const erg = await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b, { shipping: null, customer_details: { email: 'x@example.com' } }), now: nach(60) }) assert.equal(erg.neu, true) const daten = JSON.parse(order(b.orderId).bestellung) assert.equal(daten.lieferadresse, undefined) assert.equal(daten.rechnungsadresse, undefined) assert.deepEqual(daten.kaeufer, { name: '', email: 'x@example.com' }) }) test('IP-Hash: nach der Zahlung weg (auch bei später Zahlung einer verworfenen Bestellung), vorher da', async () => { await aufbau() const r = await B.legeBestellungAn(ctx, { positionen: [{ produktId: 'a1', menge: 1 }], lieferart: 'versand', land: 'DE' }, { now: T0, ipHash: 'abcdef0123456789' }) const b = { ...r, doc: order(r.orderId), sessionId: order(r.orderId).stripeSessionId } assert.equal(JSON.parse(b.doc.bestellung).ipHash, 'abcdef0123456789', 'solange offen, steht der IP-Hash im Datensatz') await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b), now: nach(60) }) assert.equal(order(b.orderId).status, 'bezahlt') assert.equal(JSON.parse(order(b.orderId).bestellung).ipHash, undefined, 'bezahlt: kein IP-Hash mehr') assert.ok(!order(b.orderId).bestellung.includes('ipHash')) // spaete Zahlung: verworfen (IP-Hash weg), danach bezahlt - bleibt ohne IP-Hash const r2 = await B.legeBestellungAn(ctx, { positionen: [{ produktId: 'a1', menge: 1 }], lieferart: 'versand', land: 'DE' }, { now: T0, ipHash: 'abcdef0123456789' }) const b2 = { ...r2, doc: order(r2.orderId), sessionId: order(r2.orderId).stripeSessionId } await B.verwirf(r2.orderId, 'abgelaufen', { now: nach(1900) }) await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b2), now: nach(2000) }) assert.equal(order(r2.orderId).status, 'bezahlt') assert.ok(!order(r2.orderId).bestellung.includes('ipHash')) }) test('IP-Hash: verwirf entfernt ihn, Verlauf und übrige Daten bleiben', async () => { await aufbau() const r = await B.legeBestellungAn(ctx, { positionen: [{ produktId: 'a1', menge: 1 }], lieferart: 'versand', land: 'DE' }, { now: T0, ipHash: 'abcdef0123456789' }) assert.equal(JSON.parse(order(r.orderId).bestellung).ipHash, 'abcdef0123456789') const erg = await B.verwirf(r.orderId, 'abgelaufen', { now: nach(1900) }) assert.equal(erg.verworfen, true) const daten = JSON.parse(order(r.orderId).bestellung) assert.equal(daten.ipHash, undefined) assert.equal(erg.order.daten.ipHash, undefined) assert.deepEqual(daten.verlauf.map((v) => v.was), ['angelegt', 'abgelaufen']) assert.equal(daten.positionen.length, 1) }) test('verwirf: nur aus offen; Verlauf, Reservierung frei; G14 nur mit mail und bekannter Adresse', async () => { await aufbau() const a = await bestelle() assert.equal((await B.reserviert(FEATURE, nach(10))).get('a1'), 2) const erg = await B.verwirf(a.orderId, 'abgelaufen', { now: nach(10) }) assert.equal(erg.verworfen, true) assert.equal(erg.order.status, 'storniert') assert.equal(JSON.parse(order(a.orderId).bestellung).verlauf.at(-1).was, 'abgelaufen') assert.equal((await B.reserviert(FEATURE, nach(10))).get('a1'), undefined) assert.equal((await B.verwirf(a.orderId, 'abgelaufen', { now: nach(11) })).verworfen, false, 'zweites Mal wirkungslos') assert.equal((await B.verwirf('gibt-es-nicht', 'abgelaufen')).order, null) await warteAufVersand() assert.equal(sink.sent.length, 0) // bezahlt wird nie verworfen const bz = await bestelle() await B.schliesseAb({ accountId: ACCOUNT, session: zahle(bz), now: nach(20) }) assert.equal((await B.verwirf(bz.orderId, 'abgelaufen', { now: nach(30) })).verworfen, false) assert.equal(order(bz.orderId).status, 'bezahlt') // falsches Konto const f = await bestelle([{ produktId: 'a1', menge: 1 }]) assert.equal((await B.verwirf(f.orderId, 'abgelaufen', { accountId: 'acct_fremd' })).verworfen, false) assert.equal(order(f.orderId).status, 'offen') // G14 mit der Adresse aus der Sitzung sink.sent.length = 0 const g = await bestelle([{ produktId: 'a1', menge: 1 }]) const v = await B.verwirf(g.orderId, 'zahlung_fehlgeschlagen', { now: nach(40), mail: true, email: 'max@example.com', accountId: ACCOUNT }) assert.equal(v.verworfen, true) await warteAufVersand() const g14 = eintraege().filter((e) => e.art === 'G14') assert.equal(g14.length, 1) assert.equal(sink.sent.length, 1) assert.equal(sink.sent[0].to[0].address, 'max@example.com') // ohne Adresse keine Mail, kein Fehler const h = await bestelle([{ produktId: 'a1', menge: 1 }]) assert.equal((await B.verwirf(h.orderId, 'zahlung_fehlgeschlagen', { mail: true })).verworfen, true) await warteAufVersand() assert.equal(eintraege().filter((e) => e.art === 'G14').length, 1) }) test('pruefeOffene: bezahlt abschließen, abgelaufene und noch offene Sitzungen verwerfen, Lesefehler und junge Bestellungen überspringen', async () => { await aufbau({ produkte: [artikel('a1', { bestand: 20 })] }) const bezahlt = await bestelle([{ produktId: 'a1', menge: 1 }]) const abgelaufen = await bestelle([{ produktId: 'a1', menge: 1 }]) const offen = await bestelle([{ produktId: 'a1', menge: 1 }]) const sepa = await bestelle([{ produktId: 'a1', menge: 1 }]) const fehler = await bestelle([{ produktId: 'a1', menge: 1 }]) zahle(bezahlt) st.accountSessions(ACCOUNT).set(abgelaufen.sessionId, { ...st.accountSessions(ACCOUNT).get(abgelaufen.sessionId), status: 'expired' }) zahle(sepa, { payment_status: 'unpaid' }) const spaet = nach(30 * 60 + 30 + 6 * 60) // Frist + 6 Minuten const jung = await (async () => { const x = await B.legeBestellungAn(ctx, { positionen: [{ produktId: 'a1', menge: 1 }], lieferart: 'versand', land: 'DE' }, { now: nach(10 * 60) }) return x })() st.failNext({ method: 'GET', path: `/v1/checkout/sessions/${fehler.sessionId}`, status: 400 }) st.calls.length = 0 const z = await B.pruefeOffene(spaet) assert.deepEqual(z, { geprueft: 5, bezahlt: 1, verworfen: 2, wartend: 1, fehler: 1 }) assert.equal(order(bezahlt.orderId).status, 'bezahlt') assert.equal(order(abgelaufen.orderId).status, 'storniert') assert.equal(order(offen.orderId).status, 'storniert') assert.ok(st.calls.some((c) => c.method === 'POST' && c.path === `/v1/checkout/sessions/${offen.sessionId}/expire` && c.account === ACCOUNT)) assert.equal(st.calls.some((c) => c.path.endsWith('/expire') && c.path.includes(abgelaufen.sessionId)), false, 'bereits abgelaufene Sitzung wird nicht erneut beendet') assert.equal(order(sepa.orderId).status, 'offen', 'SEPA unterwegs bleibt offen') assert.equal(order(fehler.orderId).status, 'offen', 'Lesefehler: nächster Lauf') assert.equal(order(jung.orderId).status, 'offen', 'Frist noch nicht um mehr als 5 Minuten verstrichen') assert.match(warnLog(), /Abgleich der Bestellung/) assert.equal(produkt('a1').bestand, 19) // Nächster Lauf: der Lesefehler ist weg const z2 = await B.pruefeOffene(spaet) assert.deepEqual({ ...z2 }, { geprueft: 2, bezahlt: 0, verworfen: 1, wartend: 1, fehler: 0 }) assert.equal(order(fehler.orderId).status, 'storniert') }) test('pruefeOffene: Bestellung ohne gespeicherte Sitzung wird verworfen; ohne offene Bestellungen passiert nichts', async () => { await aufbau() assert.deepEqual(await B.pruefeOffene(nach(7200)), { geprueft: 0, bezahlt: 0, verworfen: 0, wartend: 0, fehler: 0 }) const b = await bestelle() await updateDocument(config.collections.shopOrders, b.orderId, { stripeSessionId: '' }) const z = await B.pruefeOffene(nach(7200)) assert.equal(z.verworfen, 1) assert.equal(order(b.orderId).status, 'storniert') }) test('minutenJob: bei SHOP_ENABLED=off aus, sonst Abgleich; läuft nicht doppelt gleichzeitig', async () => { await aufbau() const b = await bestelle() zahle(b) config.shop.mode = 'off' assert.deepEqual(await J.minutenJob(nach(7200)), { aus: true }) assert.equal(order(b.orderId).status, 'offen') config.shop.mode = 'pilot' const [erster, zweiter] = await Promise.all([J.minutenJob(nach(7200)), J.minutenJob(nach(7200))]) assert.ok(erster.bezahlt === 1 || zweiter.bezahlt === 1) assert.ok(erster.uebersprungen === true || zweiter.uebersprungen === true) assert.equal(order(b.orderId).status, 'bezahlt') assert.deepEqual(await J.minutenJob(nach(7300)), { geprueft: 0, bezahlt: 0, verworfen: 0, wartend: 0, fehler: 0 }) }) // --------------------------------------------------------------------------------------------------------------------- // Review A: I1 (Statuswechsel und Bestandsabzug unter einer Sperre) und I2 (fehlende Schritte nachholen) test('schliesseAb I1: Statuswechsel und Bestandsabzug laufen unter der Bestandssperre - eine parallele Bestellung kann den letzten Artikel nicht noch einmal bekommen', async () => { await aufbau({ produkte: [artikel('a1', { bestand: 2 }), artikel('a2', {})] }) const b = await bestelle([{ produktId: 'a1', menge: 2 }]) const session = zahle(b) const echtesFetch = globalThis.fetch let konkurrenz = null globalThis.fetch = async (url, opt = {}) => { const antwort = await echtesFetch(url, opt) const pfad = String(url) if (!konkurrenz && opt.method === 'PATCH' && pfad.includes(`/${config.collections.shopOrders}/documents/${b.orderId}`) && String(opt.body).includes('"bezahlt"')) { // Die Bestellung ist jetzt bezahlt, der Bestand noch nicht abgezogen: genau jetzt kommt ein zweiter Kaeufer konkurrenz = B.legeBestellungAn(ctx, { positionen: [{ produktId: 'a1', menge: 2 }], lieferart: 'versand', land: 'DE' }, { now: nach(61) }).then( (r) => ({ ok: true, r }), (e) => ({ ok: false, e }) ) await new Promise((r) => setTimeout(r, 80)) } return antwort } try { await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(60) }) const zweiter = await konkurrenz assert.equal(zweiter.ok, false, 'der letzte Artikel darf nicht noch einmal reserviert werden') assert.equal(zweiter.e.code, 'ausverkauft') } finally { globalThis.fetch = echtesFetch } assert.equal(produkt('a1').bestand, 0) assert.equal(orders().filter((o) => o.status === 'offen').length, 0) }) test('schliesseAb I2: scheitert der Bestandsabzug, bleibt der Artikel reserviert; der zweite Lauf holt den Abzug einmal nach und verschickt die Mails einmal', async () => { await aufbau({ produkte: [artikel('a1', { bestand: 5 })] }) const b = await bestelle([{ produktId: 'a1', menge: 2 }]) const session = zahle(b) aw.failNext({ collection: config.collections.shopProducts, method: 'PATCH', status: 500 }) await assert.rejects(B.schliesseAb({ accountId: ACCOUNT, session, now: nach(60) })) assert.equal(order(b.orderId).status, 'bezahlt') const daten = JSON.parse(order(b.orderId).bestellung) assert.equal(daten.bestandAbgezogenAt, null) assert.equal(produkt('a1').bestand, 5) assert.equal((await B.reserviert(FEATURE, nach(70))).get('a1'), 2, 'noch nicht abgezogen => weiter reserviert') await warteAufVersand() assert.equal(sink.sent.length, 0) const wieder = await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(120) }) assert.equal(wieder.neu, false) assert.equal(produkt('a1').bestand, 3) const nachher = JSON.parse(order(b.orderId).bestellung) assert.ok(nachher.bestandAbgezogenAt && nachher.mailsKaeuferAt && nachher.mailsHaendlerAt) assert.equal((await B.reserviert(FEATURE, nach(130))).get('a1'), undefined) await warteAufVersand() assert.equal(sink.sent.length, 2) await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(180) }) await warteAufVersand() assert.equal(produkt('a1').bestand, 3, 'Bestand nur einmal') assert.equal(sink.sent.length, 2, 'Mails nur einmal') assert.equal(eintraege().length, 1) }) let kaputt = false const kaputtSchalter = () => (kaputt = false) || setMailTransportForTests({ sent: sink.sent, sendMail: (mail) => (kaputt ? Promise.reject(new Error('smtp kaputt')) : sink.sendMail(mail)) }) test('schliesseAb I2: G09 und K25 werden einzeln nachgeholt, sobald sie scheitern, und nie doppelt verschickt', async () => { await aufbau() kaputtSchalter() const b = await bestelle() const session = zahle(b) aw.failNext({ collection: config.collections.termineOutbox, method: 'POST', status: 500 }) kaputt = true const erst = await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(60) }) assert.equal(erst.neu, true) await warteAufVersand() let daten = JSON.parse(order(b.orderId).bestellung) assert.equal(daten.mailsKaeuferAt, null, 'G09 nicht eingereiht => offen') assert.equal(daten.mailsHaendlerAt, null, 'K25 nicht gesendet => offen') assert.equal(sink.sent.length, 0) kaputt = false await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(120) }) await warteAufVersand() daten = JSON.parse(order(b.orderId).bestellung) assert.ok(daten.mailsKaeuferAt && daten.mailsHaendlerAt) assert.equal(sink.sent.length, 2) assert.equal(produkt('a1').bestand, 3, 'Bestand blieb bei einmal abgezogen') await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(240) }) await warteAufVersand() assert.equal(sink.sent.length, 2) assert.equal(eintraege().length, 1) }) test('schliesseAb I2: ein dauerhaft scheiternder Mailschritt wird nicht endlos wiederholt', async () => { await aufbau() kaputtSchalter() const b = await bestelle() const session = zahle(b) kaputt = true for (let i = 0; i < 8; i += 1) await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(60 + i) }) const daten = JSON.parse(order(b.orderId).bestellung) assert.equal(daten.mailsHaendlerAt, null) assert.ok(daten.mailsVersuche.haendler <= 5) assert.equal(B.abschlussAusstehend(order(b.orderId)), false, 'nach dem Versuchslimit nichts mehr ausstehend') }) test('pruefeOffene I2: bezahlte Bestellungen mit fehlenden Schritten werden vom Minutenjob nachgeholt', async () => { await aufbau({ produkte: [artikel('a1', { bestand: 5 })] }) const b = await bestelle([{ produktId: 'a1', menge: 2 }]) const session = zahle(b) aw.failNext({ collection: config.collections.shopProducts, method: 'PATCH', status: 500 }) await assert.rejects(B.schliesseAb({ accountId: ACCOUNT, session, now: nach(60) })) const z = await B.pruefeOffene(nach(60 + 6 * 60)) assert.equal(z.fehler, 0) assert.equal(produkt('a1').bestand, 3) await warteAufVersand() assert.equal(sink.sent.length, 2) })