diff --git a/server/services/shop/widerrufe.js b/server/services/shop/widerrufe.js new file mode 100644 index 0000000..2339dfb --- /dev/null +++ b/server/services/shop/widerrufe.js @@ -0,0 +1,219 @@ +import { config } from '../../config.js' +import { createDocument, getDocument, listDocuments, Query, updateDocument } from '../appwriteAdmin.js' +import { withLocks } from '../keyedLock.js' +import { darfWiderrufsversuch, zaehle } from '../termine/limits.js' +import { pruefeNow } from '../termine/util.js' +import { datumKurz, uhrzeit } from '../termine/zeit.js' +import { bestellDaten } from './bestellungen.js' +import { anHaendler, anKaeufer, haendlerK26 } from './mails.js' + +/** + * Widerrufe von Kaeufern (§ 356a BGB; Spezifikation 2026-10-05-webshop-design.md Abschnitt 5.4; Plan Task E5). + * + * Der Kaeufer erklaert den Widerruf ueber die oeffentliche Widerrufsseite des Shops (Name, Bestellnummer als Vertragsbezeichnung, + * E-Mail als Kontakt, optionale Nachricht). erfasseWiderruf speichert die Erklaerung (shopWiderrufe), haengt sie an die passende + * Bestellung (gleiche Nummer und gleiche E-Mail) und bestaetigt den Eingang sofort mit G12 (Datum und Uhrzeit, Berliner Zeit, auf + * dem dauerhaften Datentraeger E-Mail). K26 informiert den Haendler. Mailfehler lassen die Anfrage nie scheitern: dann bleibt + * bestaetigungAt leer. Die Bestellnummer muss nicht existieren (der Widerruf ist trotzdem wirksam erklaert). + * Gilt fuer Shops im Zustand active und disabled, auch wenn der Shop gerade geschlossen ist; die Pruefung der Funktion + * (off/unbekannt) liegt in der Route (Task F3). + * Fehler: Error mit status und Kaeufertext (Sie-Form) in message; 429 bei Grenzen, 404 bei fremden Widerrufen. + */ + +const STUNDE_MS = 60 * 60 * 1000 +const MAX_JE_SHOP_UND_STUNDE = 20 +const SEITE = 50 +const MAX_JSON = 60000 +const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/ +const NUMMER_RE = /^[A-Z0-9-]{1,32}$/ +/** Eine einzelne Adresse: kein Leerraum, keine Listen-/Namensformen. */ +const EMAIL_RE = /^[^\s@,;:<>()[\]"\\]+@[^\s@,;:<>()[\]"\\]+\.[^\s@,;:<>()[\]"\\.]{2,}$/ + +const ZU_VIELE = 'Zu viele Versuche. Bitte versuchen Sie es später noch einmal.' +const NICHT_GEFUNDEN = 'Der Widerruf wurde nicht gefunden.' + +const col = () => config.collections.shopWiderrufe + +function fehler(status, text) { + return Object.assign(new Error(text), { status }) +} + +// eslint-disable-next-line no-control-regex +const STEUER_ALLE = /[\u0000-\u001F\u007F-\u009F\u2028\u2029]/g +// eslint-disable-next-line no-control-regex +const STEUER_OHNE_UMBRUCH = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u2028\u2029]/g + +/** Einzeiliger Text: Steuerzeichen raus, Leerraum zusammengefasst. */ +const einzeilig = (wert) => (typeof wert === 'string' ? wert.replace(STEUER_ALLE, ' ').replace(/\s+/g, ' ').trim() : '') +/** Mehrzeiliger Text: Steuerzeichen (ausser Zeilenumbruch und Tab) raus. */ +const mehrzeilig = (wert) => (typeof wert === 'string' ? wert.replace(/\r\n?/g, '\n').replace(STEUER_OHNE_UMBRUCH, '').trim() : '') + +// ---------------------------------------------------------------- Pruefung + +/** + * Prueft und bereinigt die Eingabe des Widerrufsformulars. + * @param {{ name?: string, email?: string, bestellnummer?: string, nachricht?: string }} eingabe + * @returns {{ widerruf: { name: string, email: string, bestellnummer: string, nachricht: string } } | { fehler: Record }} + */ +export function normalisiereWiderruf(eingabe) { + const roh = eingabe !== null && typeof eingabe === 'object' && !Array.isArray(eingabe) ? eingabe : {} + const f = {} + + const name = einzeilig(roh.name) + if (name.length < 2) f.name = 'Bitte geben Sie Ihren Namen an.' + else if (name.length > 200) f.name = 'Der Name darf höchstens 200 Zeichen lang sein.' + + // Steuerzeichen werden zu Leerzeichen: Leerraum im Inneren macht die Adresse ungueltig (keine zweite Adresse einschmuggeln) + const email = typeof roh.email === 'string' ? roh.email.replace(STEUER_ALLE, ' ').trim().toLowerCase() : '' + if (email === '') f.email = 'Bitte geben Sie Ihre E-Mail-Adresse an.' + else if (email.length > 254 || !EMAIL_RE.test(email)) f.email = 'Bitte geben Sie eine gültige E-Mail-Adresse an.' + + const nummer = einzeilig(roh.bestellnummer).toUpperCase() + if (nummer === '') f.bestellnummer = 'Bitte geben Sie die Bestellnummer an.' + else if (!NUMMER_RE.test(nummer)) f.bestellnummer = 'Die Bestellnummer darf nur aus Buchstaben, Ziffern und Bindestrichen bestehen und höchstens 32 Zeichen lang sein.' + + const nachricht = mehrzeilig(roh.nachricht) + if (nachricht.length > 2000) f.nachricht = 'Die Nachricht darf höchstens 2000 Zeichen lang sein.' + + if (Object.keys(f).length > 0) return { fehler: f } + return { widerruf: { name, email, bestellnummer: nummer, nachricht } } +} + +// ---------------------------------------------------------------- Erfassen + +/** "TT.MM.JJJJ, HH:MM Uhr" (Berliner Zeit). */ +export const eingangText = (zeit) => `${datumKurz(zeit)}, ${uhrzeit(zeit)} Uhr` + +/** Die Bestellung zu Nummer und E-Mail im selben Shop (oder null). Anonymisierte Bestellungen (leere E-Mail) passen nie. */ +async function passendeBestellung(shopId, bestellnummer, email) { + const docs = await listDocuments(config.collections.shopOrders, [ + Query.equal('shopId', shopId), + Query.equal('bestellnummer', bestellnummer), + Query.limit(10), + ]) + return docs.find((d) => typeof d.kaeuferEmail === 'string' && d.kaeuferEmail.trim().toLowerCase() === email) ?? null +} + +/** Verlauf der Bestellung um den Widerruf ergaenzen (Bestleistung: Fehler werden verschluckt). */ +async function verlaufErgaenzen(orderId, now) { + try { + await withLocks([`shop-order:${orderId}`], async () => { + const frisch = await getDocument(config.collections.shopOrders, orderId) + const daten = bestellDaten(frisch) + const verlauf = [...(Array.isArray(daten.verlauf) ? daten.verlauf : []), { at: now.toISOString(), was: 'widerruf' }] + const json = JSON.stringify({ ...daten, verlauf }) + if (json.length > MAX_JSON) return + await updateDocument(config.collections.shopOrders, orderId, { bestellung: json }) + }) + } catch (err) { + console.warn(`[shop-widerrufe] Verlauf nicht ergänzt: ${err?.name} ${String(err?.status ?? '').slice(0, 10)}`.trim()) + } +} + +/** + * Nimmt einen Widerruf entgegen. + * @param {{ doc: object, shop: object }} ctx Shop-Kontext (kontext.js); doc.state active oder disabled + * @param {object} eingabe { name, email, bestellnummer, nachricht } + * @param {{ now?: Date, ip?: string }} [optionen] + * @returns {Promise<{ ok: true, eingang: string, id: string }>} + * @throws {Error} status 429 (Grenzen) oder 400 mit fehler: { feld: Text } + */ +export async function erfasseWiderruf(ctx, eingabe, { now = new Date(), ip } = {}) { + pruefeNow(now, 'erfasseWiderruf') + const shopId = ctx?.shop?.id + if (typeof shopId !== 'string' || shopId === '') throw new TypeError('erfasseWiderruf: ctx.shop.id fehlt') + + if (!darfWiderrufsversuch(ip, now) || !zaehle(`shop-widerruf:${shopId}`, MAX_JE_SHOP_UND_STUNDE, STUNDE_MS, now)) { + throw fehler(429, ZU_VIELE) + } + + const geprueft = normalisiereWiderruf(eingabe) + if (geprueft.fehler) throw Object.assign(fehler(400, Object.values(geprueft.fehler)[0]), { fehler: geprueft.fehler }) + const { name, email, bestellnummer, nachricht } = geprueft.widerruf + + const order = await passendeBestellung(shopId, bestellnummer, email) + + const doc = await createDocument(col(), { + shopId, + customerId: String(ctx.shop.customerId ?? '').slice(0, 36), + bestellnummer, + name, + email, + nachricht, + eingangAt: now.toISOString(), + orderId: order?.$id ?? '', + }) + if (order) await verlaufErgaenzen(order.$id, now) + + // G12: Eingangsbestaetigung auf dauerhaftem Datentraeger, sofort; bestaetigungAt nur, wenn die Warteschlange sie angenommen hat + try { + const g12 = await anKaeufer('G12', ctx, order, { an: email, bookingId: doc.$id, name, bestellnummer, eingang: now, nachricht }, now) + if (g12?.ok) await updateDocument(col(), doc.$id, { bestaetigungAt: now.toISOString() }) + } catch (err) { + console.warn(`[shop-widerrufe] G12 nicht bestätigt: ${err?.name}`) + } + + try { + const { values, blocks } = haendlerK26(ctx, { name, email, bestellnummer, nachricht, eingang: now }, now) + await anHaendler('K26', ctx, values, blocks) + } catch (err) { + console.warn(`[shop-widerrufe] K26 nicht gesendet: ${err?.name}`) + } + + return { ok: true, eingang: eingangText(now), id: doc.$id } +} + +// ---------------------------------------------------------------- Verwalten + +const ansicht = (d) => ({ + id: d.$id, + bestellnummer: d.bestellnummer ?? '', + name: d.name ?? '', + email: d.email ?? '', + nachricht: d.nachricht ?? '', + eingangAt: d.eingangAt ?? '', + bestaetigungAt: d.bestaetigungAt || null, + bearbeitetAt: d.bearbeitetAt || null, + orderId: d.orderId || null, +}) + +/** + * Widerrufe des Shops, neueste zuerst, 50 je Seite. + * @param {string} shopId + * @param {{ seite?: number }} [optionen] seite ab 1 + * @returns {Promise<{ widerrufe: object[], seite: number, mehr: boolean }>} + */ +export async function listeWiderrufe(shopId, { seite = 1 } = {}) { + const nr = Number.isInteger(seite) && seite >= 1 ? seite : 1 + const filter = [Query.equal('shopId', shopId), Query.orderDesc('eingangAt')] + // Der Aufruf kennt keinen Offset: Seite fuer Seite per Cursor bis zur gewuenschten + let cursor = null + let docs = [] + for (let i = 1; i <= nr; i += 1) { + docs = await listDocuments(col(), [...filter, ...(cursor ? [Query.cursorAfter(cursor)] : []), Query.limit(SEITE + 1)]) + if (i < nr) { + if (docs.length <= SEITE) return { widerrufe: [], seite: nr, mehr: false } + cursor = docs[SEITE - 1].$id + } + } + return { widerrufe: docs.slice(0, SEITE).map(ansicht), seite: nr, mehr: docs.length > SEITE } +} + +/** + * Markiert einen Widerruf als bearbeitet (nur im eigenen Shop, sonst 404). Ein zweiter Aufruf aendert den Zeitpunkt nicht. + * @returns {Promise} der Widerruf + */ +export async function markiereBearbeitet(shopId, id, { now = new Date() } = {}) { + pruefeNow(now, 'markiereBearbeitet') + if (typeof id !== 'string' || !ID_RE.test(id)) throw fehler(404, NICHT_GEFUNDEN) + let doc + try { + doc = await getDocument(col(), id) + } catch (err) { + if (err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found') throw fehler(404, NICHT_GEFUNDEN) + throw err + } + if (doc.shopId !== shopId) throw fehler(404, NICHT_GEFUNDEN) + if (doc.bearbeitetAt) return ansicht(doc) + return ansicht(await updateDocument(col(), id, { bearbeitetAt: now.toISOString() })) +} diff --git a/test/shopWiderrufe.test.js b/test/shopWiderrufe.test.js new file mode 100644 index 0000000..132f915 --- /dev/null +++ b/test/shopWiderrufe.test.js @@ -0,0 +1,320 @@ +import { test, after, beforeEach, afterEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.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' + +// Webshop E5: Widerrufe (§ 356a BGB, services/shop/widerrufe.js). Mails landen in der Warteschlange (termineOutbox) bzw. der +// Mailsenke; nichts verlässt den Prozess. +const aw = await startAppwriteMock({ + schema: { + termineOutbox: schemaKeys('termineOutbox'), + shopOrders: schemaKeys('shopOrders'), + shopWiderrufe: schemaKeys('shopWiderrufe'), + }, + datetimes: { ...TERMINE_DATETIMES, ...SHOP_DATETIMES }, + defaultLimit: 25, +}) +after(() => aw.close()) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) + +const { config } = await import('../server/config.js') +const { deleteDocument } = 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 { resetTermineLimitsForTests } = await import('../server/services/termine/limits.js') +const { STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js') +const W = await import('../server/services/shop/widerrufe.js') +const { normalisiereWiderruf, erfasseWiderruf, listeWiderrufe, markiereBearbeitet } = W +after(() => setMailTransportForTests(null)) + +const JETZT = new Date('2026-10-05T12:03:00.000Z') // 14:03 Berlin (MESZ) +const nach = (ms) => new Date(JETZT.getTime() + ms) +const SITE_KEY = 'abcdefghijklmnop' +const BASIS_VORHER = config.publicBaseUrl +const ABSENDER_VORHER = config.smtp.from + +const ctxMit = (state = 'active', id = 'f_shop01') => ({ + doc: { $id: id, customerId: 'c1', projectId: 'p1', featureKey: 'shop', siteKey: SITE_KEY, state }, + shop: { + id, + customerId: 'c1', + projectId: 'p1', + siteKey: SITE_KEY, + geoeffnet: false, + einstellungen: { + ...JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN)), + shopname: 'Töpferei Muster', + anbieter: { name: 'Erika Muster', strasse: 'Hauptstr. 1', plz: '67722', ort: 'Winnweiler', land: 'DE', email: 'info@toepferei.example', telefon: '', ustId: '' }, + }, + rechtstexte: { entwurf: {}, bestaetigt: { texte: { agb: 'AGB', widerrufsbelehrung: 'Belehrung', widerrufsformular: 'Formular', impressumUrl: '', datenschutzUrl: '' }, hash: 'h1', at: JETZT.toISOString(), von: 'c1' } }, + }, +}) + +const EINGABE = { name: 'Max Mustermann', email: 'Max@Example.com', bestellnummer: '26-k7qm2x', nachricht: 'Ich widerrufe den Vertrag über die Tasse.' } + +const bestellung = (over = {}) => ({ + $id: 'o_bestellung01', + shopId: 'f_shop01', + customerId: 'c1', + bestellnummer: '26-K7QM2X', + status: 'bezahlt', + kaeuferEmail: 'max@example.com', + summeCent: 1200, + erstelltAt: JETZT.toISOString(), + bezahltAt: JETZT.toISOString(), + bestellung: JSON.stringify({ positionen: [{ name: 'Tasse', menge: 1, summeCent: 1200, widerruf: 'gesetzlich' }], verlauf: [{ at: JETZT.toISOString(), was: 'angelegt' }] }), + ...over, +}) + +let sink +let log +beforeEach(async () => { + for (const sammlung of ['termineOutbox', 'customers', 'shopOrders', 'shopWiderrufe']) { + for (const id of aw.docs(sammlung).keys()) await deleteDocument(config.collections[sammlung], id) + } + resetOutboxForTests() + resetTermineLimitsForTests() + config.smtp.from = 'noreply@webklar.com' + config.publicBaseUrl = 'https://project.webklar.com' + sink = mailSink() + setMailTransportForTests(sink) + log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}) } + aw.seed('customers', { $id: 'c1', name: 'Erika Muster', email: 'erika@toepferei.example' }) +}) +afterEach(async () => { + await warteAufVersand() + mock.restoreAll() + config.smtp.from = ABSENDER_VORHER + config.publicBaseUrl = BASIS_VORHER +}) + +const widerrufe = () => [...aw.docs('shopWiderrufe').values()] +const outbox = () => [...aw.docs('termineOutbox').values()] +const fehlerVon = async (promise) => { + try { + await promise + } catch (err) { + return err + } + assert.fail('hätte werfen müssen') +} + +// ---------------------------------------------------------------- Prüfung + +test('normalisiereWiderruf: gültige Eingabe wird bereinigt (E-Mail klein, Bestellnummer groß, Steuerzeichen raus)', () => { + const erg = normalisiereWiderruf({ name: ' Max \u0000 Mustermann\n', email: ' Max@Example.com ', bestellnummer: ' 26-k7qm2x ', nachricht: 'Zeile 1\r\nZeile\u0007 2\u0000 ' }) + assert.deepEqual(erg, { widerruf: { name: 'Max Mustermann', email: 'max@example.com', bestellnummer: '26-K7QM2X', nachricht: 'Zeile 1\nZeile 2' } }) + assert.equal(normalisiereWiderruf({ name: 'Ab', email: 'a@b.de', bestellnummer: 'X' }).widerruf.nachricht, '', 'Nachricht ist freiwillig') +}) + +test('normalisiereWiderruf: Fehler je Feld in Sie-Form', () => { + const grenzen = normalisiereWiderruf({ name: 'M', email: 'keine-adresse', bestellnummer: '', nachricht: 'x'.repeat(2001) }) + assert.deepEqual(Object.keys(grenzen.fehler).sort(), ['bestellnummer', 'email', 'nachricht', 'name']) + for (const text of Object.values(grenzen.fehler)) assert.match(text, /Bitte|darf/) + assert.equal(normalisiereWiderruf({ name: 'x'.repeat(201), email: 'a@b.de', bestellnummer: '1' }).fehler.name.includes('200'), true) + assert.ok(normalisiereWiderruf({ name: 'Ab', email: 'a@b.de', bestellnummer: 'x'.repeat(33) }).fehler.bestellnummer) + assert.ok(normalisiereWiderruf({ name: 'Ab', email: 'a@b.de', bestellnummer: '26_K7 QM' }).fehler.bestellnummer, 'nur Buchstaben, Ziffern, Bindestrich') + assert.ok(normalisiereWiderruf({ name: 'Ab', email: `${'a'.repeat(250)}@b.de`, bestellnummer: '1' }).fehler.email, 'über 254 Zeichen') + assert.equal(normalisiereWiderruf({ name: 'Ab', email: 'a@b.de', bestellnummer: '1', nachricht: 'x'.repeat(2000) }).fehler, undefined) + // mehrere Adressen / Namensform / eingeschmuggelter Zeilenumbruch + for (const email of ['a@b.de, c@d.de', 'a@b.de;c@d.de', 'Max ', 'a@b.de\nc@d.de', 'a b@c.de', 'a@b']) { + assert.ok(normalisiereWiderruf({ name: 'Ab', email, bestellnummer: '1' }).fehler?.email, email) + } + assert.deepEqual(Object.keys(normalisiereWiderruf(null).fehler).sort(), ['bestellnummer', 'email', 'name']) + assert.ok(normalisiereWiderruf({ name: 5, email: {}, bestellnummer: [] }).fehler.name, 'falsche Typen sind leer') +}) + +// ---------------------------------------------------------------- Erfassen + +test('passende Bestellung: orderId gesetzt, Verlauf ergänzt, G12 in der Warteschlange mit Datum und Uhrzeit, K26 an den Händler', async () => { + aw.seed('shopOrders', bestellung()) + const erg = await erfasseWiderruf(ctxMit(), EINGABE, { now: JETZT, ip: '203.0.113.5' }) + await warteAufVersand() + + assert.equal(erg.ok, true) + assert.equal(erg.eingang, '05.10.2026, 14:03 Uhr') + const [doc] = widerrufe() + assert.equal(erg.id, doc.$id) + assert.deepEqual( + { shopId: doc.shopId, customerId: doc.customerId, bestellnummer: doc.bestellnummer, name: doc.name, email: doc.email, orderId: doc.orderId }, + { shopId: 'f_shop01', customerId: 'c1', bestellnummer: '26-K7QM2X', name: 'Max Mustermann', email: 'max@example.com', orderId: 'o_bestellung01' }, + ) + assert.equal(doc.nachricht, EINGABE.nachricht) + assert.equal(new Date(doc.eingangAt).getTime(), JETZT.getTime()) + assert.equal(new Date(doc.bestaetigungAt).getTime(), JETZT.getTime(), 'Warteschlange hat G12 angenommen') + + const verlauf = JSON.parse(aw.docs('shopOrders').get('o_bestellung01').bestellung).verlauf + assert.deepEqual(verlauf.map((v) => v.was), ['angelegt', 'widerruf']) + assert.equal(verlauf[1].at, JETZT.toISOString()) + + // G12 an den Käufer (Warteschlange): bookingId = Bestell-ID (mails.js bevorzugt sie vor der Widerruf-ID), Absender Shopname, Antwort an den Händler + const [g12] = outbox() + assert.equal(g12.art, 'G12') + assert.equal(g12.featureId, 'f_shop01') + assert.equal(g12.bookingId, 'o_bestellung01') + assert.equal(g12.fromName, 'Töpferei Muster') + assert.equal(g12.replyTo, 'info@toepferei.example') + const kaeufer = sink.sent.find((m) => m.to.some((t) => t.address === 'max@example.com')) + assert.ok(kaeufer, 'G12 versandt') + for (const teil of ['26-K7QM2X', 'Max Mustermann', '05.10.2026, 14:03 Uhr', 'Ich widerrufe den Vertrag über die Tasse.']) assert.ok(kaeufer.text.includes(teil), teil) + + // K26 an den Händler, ohne BCC + const haendler = sink.sent.find((m) => m.to.some((t) => t.address === 'erika@toepferei.example')) + assert.ok(haendler, 'K26 versandt') + assert.ok(!haendler.bcc) + for (const teil of ['Max Mustermann', 'max@example.com', '26-K7QM2X', '05.10.2026, 14:03 Uhr']) assert.ok(haendler.text.includes(teil), teil) + assert.equal(sink.sent.length, 2) +}) + +test('keine passende Bestellung (falsche Nummer, fremde E-Mail, anderer Shop): trotzdem gespeichert und bestätigt, ohne orderId und ohne Verlauf', async () => { + aw.seed('shopOrders', bestellung()) + aw.seed('shopOrders', bestellung({ $id: 'o_fremd', shopId: 'f_anderer', bestellnummer: '26-FREMD1', kaeuferEmail: 'max@example.com' })) + const faelle = [ + { bestellnummer: '26-ZZZZZZ' }, + { email: 'jemand@example.com' }, + { bestellnummer: '26-FREMD1' }, // existiert nur in einem anderen Shop + ] + for (const [i, abweichung] of faelle.entries()) { + const erg = await erfasseWiderruf(ctxMit(), { ...EINGABE, ...abweichung }, { now: nach(i * 1000), ip: `203.0.113.${i + 10}` }) + assert.equal(erg.ok, true) + assert.equal(widerrufe().find((d) => d.$id === erg.id).orderId, '') + } + await warteAufVersand() + assert.equal(JSON.parse(aw.docs('shopOrders').get('o_bestellung01').bestellung).verlauf.length, 1) + assert.equal(JSON.parse(aw.docs('shopOrders').get('o_fremd').bestellung).verlauf.length, 1) + assert.equal(outbox().length, 3) + assert.ok(outbox().every((e) => e.art === 'G12')) + assert.deepEqual(outbox().map((e) => e.bookingId).sort(), widerrufe().map((d) => d.$id).sort(), 'ohne Bestellung gilt die Widerruf-ID') + assert.equal(widerrufe().every((d) => d.bestaetigungAt), true, 'Bestätigung auch ohne passende Bestellung') +}) + +test('E-Mail-Vergleich ohne Beachtung der Groß-/Kleinschreibung; anonymisierte Bestellung (leere E-Mail) passt nie', async () => { + aw.seed('shopOrders', bestellung({ kaeuferEmail: 'MAX@Example.COM' })) + const erg = await erfasseWiderruf(ctxMit(), EINGABE, { now: JETZT, ip: '203.0.113.5' }) + assert.equal(widerrufe().find((d) => d.$id === erg.id).orderId, 'o_bestellung01') + + await deleteDocument(config.collections.shopOrders, 'o_bestellung01') + aw.seed('shopOrders', bestellung({ kaeuferEmail: '' })) + const erg2 = await erfasseWiderruf(ctxMit(), { ...EINGABE, email: 'x@example.com' }, { now: nach(1000), ip: '203.0.113.5' }) + assert.equal(widerrufe().find((d) => d.$id === erg2.id).orderId, '') +}) + +test('ungültige Eingabe: Fehler 400 mit Feldtexten, nichts gespeichert, keine Mail', async () => { + const err = await fehlerVon(erfasseWiderruf(ctxMit(), { ...EINGABE, name: '' }, { now: JETZT, ip: '203.0.113.5' })) + assert.equal(err.status, 400) + assert.ok(err.fehler.name) + assert.equal(widerrufe().length, 0) + assert.equal(outbox().length, 0) +}) + +test('Grenzen: 10 je IP und Stunde ⇒ 429, danach wieder frei; 20 je Shop und Stunde; andere Shops unberührt', async () => { + const ip = '203.0.113.7' + for (let i = 0; i < 10; i += 1) await erfasseWiderruf(ctxMit(), EINGABE, { now: nach(i * 1000), ip }) + const err = await fehlerVon(erfasseWiderruf(ctxMit(), EINGABE, { now: nach(11000), ip })) + assert.equal(err.status, 429) + assert.equal(err.message, 'Zu viele Versuche. Bitte versuchen Sie es später noch einmal.') + assert.equal(widerrufe().length, 10) + // eine andere IP geht noch + await erfasseWiderruf(ctxMit(), EINGABE, { now: nach(12000), ip: '203.0.113.8' }) + // nach einer Stunde ist die IP wieder frei + await erfasseWiderruf(ctxMit(), EINGABE, { now: nach(61 * 60 * 1000), ip }) + + // 20 je Shop: bisher 12 Versuche in der ersten Stunde (11 + 1 in der zweiten) ⇒ in der zweiten Stunde weiter mit wechselnden IPs + resetTermineLimitsForTests() + const t = nach(3 * 60 * 60 * 1000) + for (let i = 0; i < 20; i += 1) await erfasseWiderruf(ctxMit(), EINGABE, { now: t, ip: `198.51.100.${i + 1}` }) + const shopErr = await fehlerVon(erfasseWiderruf(ctxMit(), EINGABE, { now: t, ip: '198.51.100.99' })) + assert.equal(shopErr.status, 429) + await erfasseWiderruf(ctxMit('active', 'f_shop02'), EINGABE, { now: t, ip: '198.51.100.99' }) +}) + +test('ein ungültiger Versuch zählt gegen die Grenze (kein Raten ohne Limit)', async () => { + const ip = '203.0.113.9' + for (let i = 0; i < 10; i += 1) await fehlerVon(erfasseWiderruf(ctxMit(), { ...EINGABE, email: 'x' }, { now: JETZT, ip })) + assert.equal((await fehlerVon(erfasseWiderruf(ctxMit(), EINGABE, { now: JETZT, ip }))).status, 429) +}) + +test('Mailfehler kippen die Anfrage nicht: Warteschlange verweigert G12 ⇒ ok, bestaetigungAt leer; K26-Versand scheitert ⇒ ok', async () => { + // G12: die Warteschlange verweigert den Eintrag (fehlende featureId) + const ohneFeature = ctxMit() + ohneFeature.doc.$id = '' + const erg = await erfasseWiderruf(ohneFeature, EINGABE, { now: JETZT, ip: '203.0.113.5' }) + assert.equal(erg.ok, true) + assert.equal(erg.eingang, '05.10.2026, 14:03 Uhr') + const doc = widerrufe().find((d) => d.$id === erg.id) + assert.equal(doc.bestaetigungAt ?? '', '', 'nicht bestätigt, weil nichts eingereiht wurde') + assert.equal(outbox().length, 0) + await warteAufVersand() + + // K26: SMTP wirft + setMailTransportForTests({ sendMail: async () => Promise.reject(Object.assign(new Error('smtp: max@example.com abgelehnt'), { code: 'ECONNECTION' })) }) + const erg2 = await erfasseWiderruf(ctxMit(), EINGABE, { now: nach(1000), ip: '203.0.113.5' }) + assert.equal(erg2.ok, true) + await warteAufVersand() + assert.ok(widerrufe().find((d) => d.$id === erg2.id).bestaetigungAt, 'G12 war eingereiht') + assert.ok(!log.warn.mock.calls.some((a) => a.arguments.map(String).join(' ').includes('max@example.com')), 'keine Adresse im Log') +}) + +test('K26 ohne Kunden-E-Mail scheitert still, Widerruf und G12 bleiben', async () => { + for (const id of aw.docs('customers').keys()) await deleteDocument(config.collections.customers, id) + const erg = await erfasseWiderruf(ctxMit(), EINGABE, { now: JETZT, ip: '203.0.113.5' }) + assert.equal(erg.ok, true) + await warteAufVersand() + assert.equal(outbox().length, 1) +}) + +test('geschlossener oder deaktivierter Shop: Widerruf wird trotzdem angenommen', async () => { + for (const [i, state] of ['active', 'disabled'].entries()) { + const ctx = ctxMit(state) + ctx.shop.geoeffnet = false + const erg = await erfasseWiderruf(ctx, EINGABE, { now: nach(i * 1000), ip: `203.0.113.${20 + i}` }) + assert.equal(erg.ok, true, state) + } + await warteAufVersand() + assert.equal(widerrufe().length, 2) + assert.equal(outbox().length, 2) +}) + +// ---------------------------------------------------------------- Verwalten + +test('listeWiderrufe: neueste zuerst, 50 je Seite, nur der eigene Shop', async () => { + for (let i = 0; i < 53; i += 1) { + aw.seed('shopWiderrufe', { $id: `w_${String(i).padStart(3, '0')}`, shopId: 'f_shop01', customerId: 'c1', bestellnummer: `26-N${i}`, name: 'Max', email: 'max@example.com', nachricht: '', eingangAt: nach(i * 60000).toISOString() }) + } + aw.seed('shopWiderrufe', { $id: 'w_fremd', shopId: 'f_shop02', customerId: 'c2', bestellnummer: '26-X', name: 'Fremd', email: 'f@example.com', nachricht: '', eingangAt: nach(999 * 60000).toISOString() }) + + const s1 = await listeWiderrufe('f_shop01') + assert.equal(s1.widerrufe.length, 50) + assert.equal(s1.mehr, true) + assert.equal(s1.seite, 1) + assert.equal(s1.widerrufe[0].id, 'w_052') + assert.equal(s1.widerrufe[49].id, 'w_003') + assert.ok(!s1.widerrufe.some((w) => w.id === 'w_fremd')) + + const s2 = await listeWiderrufe('f_shop01', { seite: 2 }) + assert.deepEqual(s2.widerrufe.map((w) => w.id), ['w_002', 'w_001', 'w_000']) + assert.equal(s2.mehr, false) + assert.deepEqual((await listeWiderrufe('f_shop01', { seite: 3 })).widerrufe, []) + assert.deepEqual((await listeWiderrufe('f_leer')).widerrufe, []) +}) + +test('markiereBearbeitet: setzt bearbeitetAt einmal; fremder Shop oder unbekannte ID ⇒ 404 und keine Änderung', async () => { + aw.seed('shopWiderrufe', { $id: 'w_eigen', shopId: 'f_shop01', customerId: 'c1', bestellnummer: '26-A', name: 'Max', email: 'max@example.com', nachricht: '', eingangAt: JETZT.toISOString() }) + aw.seed('shopWiderrufe', { $id: 'w_fremd', shopId: 'f_shop02', customerId: 'c2', bestellnummer: '26-B', name: 'Fremd', email: 'f@example.com', nachricht: '', eingangAt: JETZT.toISOString() }) + + const fremd = await fehlerVon(markiereBearbeitet('f_shop01', 'w_fremd', { now: nach(1000) })) + assert.equal(fremd.status, 404) + assert.ok(!aw.docs('shopWiderrufe').get('w_fremd').bearbeitetAt) + for (const id of ['w_gibtsnicht', '../x', '', null]) assert.equal((await fehlerVon(markiereBearbeitet('f_shop01', id, { now: nach(1000) }))).status, 404, String(id)) + + const erg = await markiereBearbeitet('f_shop01', 'w_eigen', { now: nach(2000) }) + assert.equal(erg.id, 'w_eigen') + assert.equal(new Date(erg.bearbeitetAt).getTime(), nach(2000).getTime()) + const nochmal = await markiereBearbeitet('f_shop01', 'w_eigen', { now: nach(9000) }) + assert.equal(new Date(nochmal.bearbeitetAt).getTime(), nach(2000).getTime(), 'zweiter Aufruf ändert nichts') + assert.equal((await listeWiderrufe('f_shop01')).widerrufe[0].bearbeitetAt, erg.bearbeitetAt) +})