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 E4: Mails des Shops (services/shop/mails.js). Käufermails laufen durch die Warteschlange des Terminplaners // (termineOutbox), Händlermails gehen direkt an das Kundenkonto. Mails landen in der Senke, nichts verlässt den Prozess. const aw = await startAppwriteMock({ schema: { termineOutbox: schemaKeys('termineOutbox'), shopRechtstexteArchiv: schemaKeys('shopRechtstexteArchiv') }, 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 { STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js') const { gewaehrleistungsHinweis } = await import('../server/services/shop/rechtstexte.js') const M = await import('../server/services/shop/mails.js') const { archiviereRechtstexte, holeArchivierteRechtstexte, archivId } = await import('../server/services/shop/archiv.js') const { normalisiereRechtstexte, MAX_AGB, MAX_WIDERRUFSBELEHRUNG, MAX_WIDERRUFSFORMULAR } = await import('../server/services/shop/rechtstexte.js') const { anKaeufer, anHaendler, kaeuferWerte, haendlerK25, haendlerK26 } = M after(() => setMailTransportForTests(null)) const JETZT = new Date('2026-10-05T12:03:00.000Z') // 14:03 Berlin const SITE_KEY = 'abcdefghijklmnop' const ABSENDER_VORHER = config.smtp.from const BASIS_VORHER = config.publicBaseUrl const einstellungen = (over = {}) => ({ ...JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN)), kleinunternehmer: true, lieferzeit: '2–4 Werktage', shopname: 'Töpferei Muster', anbieter: { name: 'Erika Muster', strasse: 'Hauptstr. 1', plz: '67722', ort: 'Winnweiler', land: 'DE', email: 'info@toepferei.example', telefon: '', ustId: '' }, lieferzeit: '2–4 Werktage', akzent: '#aa3300', abholung: { aktiv: true, adresse: 'Werkstatt, Hauptstr. 1, 67722 Winnweiler', hinweis: 'Di–Fr 10–16 Uhr' }, ...over, }) const texte = (over = {}) => ({ agb: 'AGB Zeile 1\nAGB Zeile 2', widerrufsbelehrung: 'Belehrung Zeile 1\nZeile 2', widerrufsformular: 'An Töpferei Muster\nHiermit widerrufe ich', impressumUrl: 'https://toepferei.example/impressum', datenschutzUrl: 'https://toepferei.example/datenschutz', ...over, }) const ctxMit = (e = einstellungen(), t = texte()) => ({ doc: { $id: 'f_shop01', customerId: 'c1', projectId: 'p1', featureKey: 'shop', siteKey: SITE_KEY }, shop: { id: 'f_shop01', customerId: 'c1', projectId: 'p1', siteKey: SITE_KEY, einstellungen: e, rechtstexte: { entwurf: texte(), bestaetigt: { texte: t, hash: 'h1', at: JETZT.toISOString(), von: 'c1' } }, }, }) const bestellung = (over = {}, snap = {}) => ({ $id: 'o_bestellung01', bestellnummer: '26-K7QM2X', kaeuferEmail: 'max@example.com', bezahltAt: JETZT.toISOString(), erstelltAt: new Date(JETZT.getTime() - 600000).toISOString(), summeCent: 4140, bestellung: JSON.stringify({ positionen: [ { name: 'Tasse', varianteText: 'blau', menge: 2, summeCent: 2400, widerruf: 'gesetzlich' }, { name: 'Teller', varianteText: '', menge: 1, summeCent: 1250, summeText: '12,50 €', widerruf: 'individuell' }, ], summen: { zwischensummeCent: 3650, versandCent: 490, gesamtCent: 4140, texte: { zwischensumme: '36,50 €', versand: '4,90 €', gesamt: '41,40 €', steuerHinweis: 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.' } }, lieferart: 'versand', land: 'DE', kaeufer: { name: 'Max Mustermann', email: 'max@example.com' }, lieferadresse: { name: 'Max Mustermann', line1: 'Musterweg 3', postal_code: '12345', city: 'Musterstadt', country: 'DE' }, zahlungsart: 'card', ...snap, }), ...over, }) let sink let log beforeEach(async () => { for (const id of aw.docs('termineOutbox').keys()) await deleteDocument(config.collections.termineOutbox, id) for (const id of aw.docs('shopRechtstexteArchiv').keys()) await deleteDocument(config.collections.shopRechtstexteArchiv, id) for (const id of aw.docs('customers').keys()) await deleteDocument(config.collections.customers, id) resetOutboxForTests() 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 eintraege = () => [...aw.docs('termineOutbox').values()] const warnLog = () => log.warn.mock.calls.map((aufruf) => aufruf.arguments.map(String).join(' ')) // ---------------------------------------------------------------- Werte test('kaeuferWerte: Kopf, Fuß, Positionen, Summen, Lieferadresse und Rechtstexte aus Schnappschuss und Shop', () => { const w = kaeuferWerte(ctxMit(), bestellung(), JETZT) // euro() setzt ein geschütztes Leerzeichen vor das €-Zeichen for (const [schluessel, wert] of Object.entries(w)) w[schluessel] = wert.replaceAll('\u00a0', ' ') assert.equal(w.farbe, '#aa3300') assert.equal(w.betrieb_name, 'Töpferei Muster') assert.equal(w.anbieter_zeile, 'Erika Muster, Hauptstr. 1, 67722 Winnweiler, info@toepferei.example') assert.equal(w.widerruf_url, `https://project.webklar.com/shop/${SITE_KEY}/widerruf`) assert.equal(w.impressum_url, 'https://toepferei.example/impressum') assert.equal(w.datenschutz_url, 'https://toepferei.example/datenschutz') assert.equal(w.bestellnummer, '26-K7QM2X') assert.equal(w.datum, '05.10.2026') assert.equal(w.positionen, '2 × Tasse (blau) – 24,00 €\n1 × Teller – 12,50 €') assert.deepEqual([w.zwischensumme, w.versand, w.gesamt], ['36,50 €', '4,90 €', '41,40 €']) assert.equal(w.steuer_hinweis, 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.') assert.equal(w.lieferart_text, 'Versand nach Deutschland') assert.equal(w.lieferadresse, 'Max Mustermann\nMusterweg 3\n12345 Musterstadt') assert.equal(w.abholort, 'Werkstatt, Hauptstr. 1, 67722 Winnweiler') assert.equal(w.lieferzeit, '2–4 Werktage') assert.equal(w.zahlungsart, 'Kreditkarte, Apple Pay, Google Pay') assert.equal(w.ausschluss_text, 'Teller: Vom Widerruf ausgeschlossen: nach Kundenwunsch angefertigt') assert.equal(w.agb, 'AGB Zeile 1\nAGB Zeile 2') assert.equal(w.widerrufsbelehrung, 'Belehrung Zeile 1\nZeile 2') assert.equal(w.widerrufsformular, 'An Töpferei Muster\nHiermit widerrufe ich') assert.equal(w.gewaehrleistung, gewaehrleistungsHinweis()) }) test('kaeuferWerte: Links fallen ohne Impressum-/Datenschutz-Adresse auf den Shop zurück, ungültige Farbe auf Schwarz, ohne Bestellung "–"', () => { const w = kaeuferWerte(ctxMit(einstellungen({ akzent: 'red;x' }), texte({ impressumUrl: '', datenschutzUrl: '' })), null, JETZT) assert.equal(w.farbe, '#111111') assert.equal(w.impressum_url, `https://project.webklar.com/shop/${SITE_KEY}`) assert.equal(w.datenschutz_url, `https://project.webklar.com/shop/${SITE_KEY}`) assert.equal(w.bestellnummer, '–') assert.equal(w.positionen, '–') assert.equal(w.lieferadresse, '–') assert.equal(w.datum, '05.10.2026', 'ohne Bestellung gilt das übergebene Datum') }) test('kaeuferWerte I-1: Bestelldatum ist der Tag der Bestellung, Zahlungsdatum der des Zahlungseingangs (SEPA Tage später)', () => { const w = kaeuferWerte(ctxMit(), bestellung({ erstelltAt: '2026-10-05T10:00:00.000Z', bezahltAt: '2026-10-08T08:30:00.000Z' }), JETZT) assert.equal(w.datum, '05.10.2026') assert.equal(w.zahlungsdatum, '08.10.2026') const offen = kaeuferWerte(ctxMit(), bestellung({ bezahltAt: undefined }), JETZT) assert.equal(offen.zahlungsdatum, '05.10.2026', 'ohne Zahlung gilt das Bestelldatum') }) test('kaeuferWerte M-5: Telefonnummer des Anbieters steht in der Anbieterzeile, wenn vorhanden', () => { const mit = kaeuferWerte(ctxMit(einstellungen({ anbieter: { ...einstellungen().anbieter, telefon: '0 63 02 / 12 34' } })), bestellung(), JETZT) assert.equal(mit.anbieter_zeile, 'Erika Muster, Hauptstr. 1, 67722 Winnweiler, info@toepferei.example, Tel. 0 63 02 / 12 34') }) // ---------------------------------------------------------------- Käufermails test('G09: wird mit Shopname als Absender und Antwort an den Händler eingereiht, kein BCC, Rechtstexte stehen in der Mail', async () => { const erg = await anKaeufer('G09', ctxMit(), bestellung(), {}, JETZT) assert.equal(erg.ok, true) await warteAufVersand() const [doc] = eintraege() assert.equal(doc.art, 'G09') assert.equal(doc.featureId, 'f_shop01') assert.equal(doc.bookingId, 'o_bestellung01') assert.equal(doc.fromName, 'Töpferei Muster') assert.equal(doc.replyTo, 'info@toepferei.example') assert.equal(doc.status, 'gesendet') assert.equal(doc.an, '', 'nach dem Versand ist die Adresse gelöscht') assert.equal(sink.sent.length, 1) const mail = sink.sent[0] assert.deepEqual(mail.to.map((t) => t.address), ['max@example.com']) assert.ok(!mail.bcc, 'kein BCC') assert.equal(mail.from.name, 'Töpferei Muster') assert.equal(mail.from.address, 'noreply@webklar.com') assert.equal(mail.replyTo[0].address, 'info@toepferei.example') assert.equal(mail.subject, 'Ihre Bestellung 26-K7QM2X bei Töpferei Muster') for (const teil of ['2 × Tasse (blau) – 24,00 €', '41,40 €', 'Max Mustermann', 'Musterweg 3', 'Vom Widerruf ausgeschlossen', 'AGB Zeile 2', 'Belehrung Zeile 1', 'Hiermit widerrufe ich', 'GESETZLICHE GEWÄHRLEISTUNG', 'Vertrag widerrufen']) { assert.ok(mail.text.includes(teil), teil) } assert.match(mail.html, new RegExp(`href="https://project\\.webklar\\.com/shop/${SITE_KEY}/widerruf"`)) assert.doesNotMatch(mail.text, /Abholort/) assert.deepEqual(warnLog(), [], 'keine Platzhalter-Warnung der Vorlage') }) test('G15: Eingang der Bestellung geht an die übergebene Adresse (noch keine Käuferadresse an der Bestellung), ohne Rechtstexte und ohne Vertragsschluss', async () => { const offen = bestellung({ kaeuferEmail: undefined, bezahltAt: undefined, status: 'offen' }) const erg = await anKaeufer('G15', ctxMit(), offen, { an: 'sepa@example.com' }, JETZT) assert.equal(erg.ok, true) await warteAufVersand() const [doc] = eintraege() assert.equal(doc.art, 'G15') assert.equal(doc.bookingId, 'o_bestellung01') assert.equal(sink.sent.length, 1) const mail = sink.sent[0] assert.equal(mail.to[0].address, 'sepa@example.com') assert.equal(mail.subject, 'Wir haben Ihre Bestellung 26-K7QM2X erhalten') assert.match(mail.text, /noch in Bearbeitung/) assert.match(mail.text, /2 × Tasse \(blau\) – 24,00/) assert.doesNotMatch(mail.text, /Kaufvertrag ist damit geschlossen|AGB Zeile 1|Belehrung Zeile 1/) }) test('G09 Abholung: Abholort statt Lieferadresse', async () => { const erg = await anKaeufer('G09', ctxMit(), bestellung({}, { lieferart: 'abholung', lieferadresse: null, land: null, positionen: [{ name: 'Tasse', menge: 1, summeCent: 1200, widerruf: 'gesetzlich' }] }), {}, JETZT) assert.equal(erg.ok, true) await warteAufVersand() const text = sink.sent[0].text assert.match(text, /Lieferart: Abholung/) assert.match(text, /Abholort: Werkstatt, Hauptstr\. 1, 67722 Winnweiler/) assert.doesNotMatch(text, /Lieferadresse|Hinweis zum Widerrufsrecht/) assert.deepEqual(warnLog(), []) }) /** Zeilen mit Satzzeichen und Sonderzeichen wie in echten Rechtstexten, genau n Zeichen lang. */ const textMitLaenge = (n, zeile) => { let t = '' while (t.length < n) t += `${zeile}\n` return t.slice(0, n) } test('G09 I-7: Texte in Höchstlänge passen mit Volltext in die Warteschlange (html 100000, text 50000), nie ein Link statt der AGB', async () => { const zeile = 'Ein Satz der Geschäftsbedingungen mit Komma, Klammern (wie hier) & einem „Zitat“ – und Zahlen 1.234,56 €.' const t = texte({ agb: textMitLaenge(MAX_AGB, zeile), widerrufsbelehrung: textMitLaenge(MAX_WIDERRUFSBELEHRUNG, zeile), widerrufsformular: textMitLaenge(MAX_WIDERRUFSFORMULAR, zeile), }) const geprueft = normalisiereRechtstexte({ ...t }) assert.equal(geprueft.fehler, undefined, 'Höchstlänge wird vom Speichern akzeptiert') const viele = Array.from({ length: 40 }, (_, i) => ({ name: `Artikel Nummer ${i + 1} mit langem Namen`, varianteText: 'Farbe: blau, Größe: M', menge: 3, summeCent: 12345, widerruf: i % 2 ? 'gesetzlich' : 'verderblich' })) const erg = await anKaeufer('G09', ctxMit(einstellungen(), t), bestellung({}, { positionen: viele }), {}, JETZT) assert.equal(erg.ok, true) const doc = eintraege()[0] assert.ok(doc.html.length <= 100000 && doc.text.length <= 50000, `${doc.html.length}/${doc.text.length}`) assert.equal(doc.text.split('Ein Satz der Geschäftsbedingungen').length - 1 >= 300, true, 'alle Texte stehen vollständig in der Mail') assert.ok(doc.text.includes(t.agb.slice(-200).trim().split('\n').pop().slice(0, 30)), 'Ende der AGB') assert.doesNotMatch(doc.text, /ansicht=agb|vollständigen AGB finden Sie/) }) test('G09 I-7: zu lang für die Warteschlange ⇒ { ok: false } ohne Wurf, ohne Eintrag und ohne Link-Ersatz', async () => { const t = texte({ agb: 'a&b\n'.repeat(4000), widerrufsbelehrung: 'Belehrung Satz.\n'.repeat(500) }) const erg = await anKaeufer('G09', ctxMit(einstellungen(), t), bestellung(), {}, JETZT) assert.deepEqual(erg, { ok: false, fehler: 'Die E-Mail an den Käufer konnte nicht eingereiht werden.' }) assert.equal(eintraege().length, 0) assert.equal(sink.sent.length, 0) assert.match(warnLog().join('\n'), /zu lang für die Warteschlange/) }) test('G09 I-6: zeigt die archivierten Rechtstexte zum Hash der Bestellung, nicht die aktuell bestätigten', async () => { const HASH = 'a'.repeat(64) await archiviereRechtstexte({ shopId: 'f_shop01', customerId: 'c1', hash: HASH, at: JETZT.toISOString(), von: 'Erika', texte: { agb: 'ALTE AGB', widerrufsbelehrung: 'ALTE BELEHRUNG', widerrufsformular: 'ALTES FORMULAR', impressumUrl: 'https://alt.example/impressum', datenschutzUrl: 'https://alt.example/datenschutz', versandUndZahlung: 'x', gewaehrleistung: 'ALTER GEWÄHRLEISTUNGSTEXT' }, }) const erg = await anKaeufer('G09', ctxMit(), bestellung({}, { rechtstexteHash: HASH }), {}, JETZT) assert.equal(erg.ok, true) const doc = eintraege()[0] for (const teil of ['ALTE AGB', 'ALTE BELEHRUNG', 'ALTES FORMULAR', 'ALTER GEWÄHRLEISTUNGSTEXT']) assert.ok(doc.text.includes(teil), teil) assert.doesNotMatch(doc.text, /AGB Zeile 1|Belehrung Zeile 1/) assert.match(doc.html, /href="https:\/\/alt\.example\/impressum"/) assert.deepEqual(warnLog(), []) }) test('G09 I-6: fehlt die Fassung im Archiv, gilt mit Warnung die aktuell bestätigte; ohne Hash an der Bestellung ohne Warnung', async () => { const erg = await anKaeufer('G09', ctxMit(), bestellung({}, { rechtstexteHash: 'b'.repeat(64) }), {}, JETZT) assert.equal(erg.ok, true) assert.match(eintraege()[0].text, /AGB Zeile 1/) assert.match(warnLog().join('\n'), /fehlen im Archiv/) assert.doesNotMatch(warnLog().join('\n'), /max@example\.com/) }) test('Archiv I-6: Dokument-ID r + 35 Hex, ein zweites Anlegen derselben Fassung ist kein Fehler, fremder Shop findet sie nicht', async () => { const HASH = 'c'.repeat(64) assert.match(archivId('f_shop01', HASH), /^r[0-9a-f]{35}$/) assert.notEqual(archivId('f_shop01', HASH), archivId('f_shop02', HASH)) const p = { shopId: 'f_shop01', customerId: 'c1', hash: HASH, at: JETZT.toISOString(), von: 'Erika', texte: { agb: 'A' } } assert.deepEqual(await archiviereRechtstexte(p), { angelegt: true }) assert.deepEqual(await archiviereRechtstexte(p), { angelegt: false }) assert.deepEqual(await holeArchivierteRechtstexte('f_shop01', HASH), { agb: 'A' }) assert.equal(await holeArchivierteRechtstexte('f_shop02', HASH), null) assert.equal(await holeArchivierteRechtstexte('f_shop01', 'd'.repeat(64)), null) const doc = [...aw.docs('shopRechtstexteArchiv').values()][0] assert.deepEqual([doc.shopId, doc.customerId, doc.hash, doc.bestaetigtVon], ['f_shop01', 'c1', HASH, 'Erika']) }) test('G10: Versanddienst und Sendungsnummer (Block sendung); ohne beide kein Block', async () => { assert.equal((await anKaeufer('G10', ctxMit(), bestellung(), { versanddienst: 'DHL', sendungsnummer: '00340434' }, JETZT)).ok, true) assert.equal((await anKaeufer('G10', ctxMit(), bestellung({ $id: 'o_zwei' }), {}, JETZT)).ok, true) await warteAufVersand() const [mit, ohne] = sink.sent assert.equal(mit.subject, 'Ihre Bestellung 26-K7QM2X wurde versendet') assert.match(mit.text, /Versanddienst: DHL/) assert.match(mit.text, /Sendungsnummer: 00340434/) assert.doesNotMatch(ohne.text, /Sendungsnummer/) }) test('G11: Abholort und Hinweis des Händlers, ersatzweise ein Standardhinweis', async () => { await anKaeufer('G11', ctxMit(), bestellung(), {}, JETZT) const ohneHinweis = einstellungen({ abholung: { aktiv: true, adresse: 'Werkstatt', hinweis: '' } }) await anKaeufer('G11', ctxMit(ohneHinweis), bestellung({ $id: 'o_zwei' }), {}, JETZT) await warteAufVersand() const texte11 = sink.sent.map((m) => m.text) assert.match(texte11[0], /Abholort: Werkstatt, Hauptstr\. 1/) assert.match(texte11[0], /Hinweis: Di–Fr 10–16 Uhr/) assert.match(texte11[1], /Hinweis: Bitte bringen Sie Ihre Bestellnummer mit\./) assert.equal(sink.sent[0].subject, 'Ihre Bestellung 26-K7QM2X ist abholbereit') }) test('G12: ohne passende Bestellung, an die Adresse der Erklärung, mit Datum und Uhrzeit', async () => { const erg = await anKaeufer('G12', ctxMit(), null, { an: 'kaeufer@example.com', bookingId: 'w_widerruf01', name: 'Kai Käufer', bestellnummer: '26-ZZZZZZ', eingang: JETZT, nachricht: 'Ich widerrufe.' }, JETZT) assert.equal(erg.ok, true) assert.equal(erg.eintrag.bookingId, 'w_widerruf01') await warteAufVersand() const mail = sink.sent[0] assert.deepEqual(mail.to.map((t) => t.address), ['kaeufer@example.com']) assert.equal(mail.subject, 'Eingangsbestätigung Ihres Widerrufs bei Töpferei Muster') assert.match(mail.text, /Guten Tag Kai Käufer,/) assert.match(mail.text, /Eingegangen am: 05\.10\.2026, 14:03 Uhr/) assert.match(mail.text, /Bestellnummer: 26-ZZZZZZ/) assert.match(mail.text, /Ihre Erklärung: Ich widerrufe\./) assert.deepEqual(warnLog(), []) }) test('G12 und K26 ohne Bestellnummer: „–“ statt leerer Stelle', async () => { const erg = await anKaeufer('G12', ctxMit(), null, { an: 'kaeufer@example.com', bookingId: 'w_widerruf02', name: 'Kai Käufer', bestellnummer: '', eingang: JETZT, nachricht: 'Bestellt am 01.10., Tasse blau' }, JETZT) assert.equal(erg.ok, true) const k = haendlerK26(ctxMit(), { name: 'Kai', email: 'kai@example.com', bestellnummer: '', nachricht: 'Bestellt am 01.10., Tasse blau', eingang: JETZT }) assert.equal(k.values.bestellnummer, '–') assert.equal((await anHaendler('K26', ctxMit(), k.values, k.blocks)).ok, true) await warteAufVersand() assert.match(sink.sent[0].text, /Bestellnummer: –/) assert.match(sink.sent[1].text, /Bestellnummer: –/) }) test('G13: erstatteter Betrag in Euro, Grund nur wenn angegeben; ohne Betrag { ok: false }', async () => { assert.equal((await anKaeufer('G13', ctxMit(), bestellung(), { betragCent: 1250, grund: 'Artikel beschädigt' }, JETZT)).ok, true) assert.equal((await anKaeufer('G13', ctxMit(), bestellung({ $id: 'o_zwei' }), { betragCent: 4140 }, JETZT)).ok, true) assert.equal((await anKaeufer('G13', ctxMit(), bestellung({ $id: 'o_drei' }), {}, JETZT)).ok, false) assert.equal((await anKaeufer('G13', ctxMit(), bestellung({ $id: 'o_vier' }), { betragCent: 12.5 }, JETZT)).ok, false) await warteAufVersand() assert.equal(sink.sent.length, 2) assert.match(sink.sent[0].text, /Erstatteter Betrag: 12,50 €/) assert.match(sink.sent[0].text, /Grund: Artikel beschädigt/) assert.match(sink.sent[1].text, /Erstatteter Betrag: 41,40 €/) assert.doesNotMatch(sink.sent[1].text, /Grund:/) assert.match(sink.sent[1].text, /Die Gutschrift erfolgt über die ursprüngliche Zahlungsart\./) }) test('G14: Zahlung fehlgeschlagen, nichts berechnet', async () => { assert.equal((await anKaeufer('G14', ctxMit(), bestellung(), {}, JETZT)).ok, true) await warteAufVersand() assert.equal(sink.sent[0].subject, 'Ihre Bestellung 26-K7QM2X wurde nicht abgeschlossen') assert.match(sink.sent[0].text, /es wurde nichts berechnet/) }) test('anKaeufer wirft nie: unbekannte Art, fehlende Adresse und Speicherfehler ergeben { ok: false }', async () => { assert.equal((await anKaeufer('G01', ctxMit(), bestellung(), {}, JETZT)).ok, false) assert.equal((await anKaeufer('G09', ctxMit(), bestellung({ kaeuferEmail: '' }), {}, JETZT)).ok, false) assert.equal((await anKaeufer('G09', ctxMit(), bestellung(), {}, new Date('kaputt'))).ok, false) aw.failNext({ collection: 'termineOutbox', method: 'POST', status: 500, message: 'Nachbau kaputt' }) const erg = await anKaeufer('G09', ctxMit(), bestellung(), {}, JETZT) assert.equal(erg.ok, false) assert.equal(eintraege().length, 0) assert.equal(sink.sent.length, 0) }) test('Käufermails laufen in der Klasse normal (keine zusätzlichen notEqual in der Abfrage)', async () => { const { mailKlasse } = await import('../server/services/termine/outbox.js') assert.deepEqual(['G09', 'G10', 'G11', 'G12', 'G13', 'G14'].map(mailKlasse), ['normal', 'normal', 'normal', 'normal', 'normal', 'normal']) }) // ---------------------------------------------------------------- Händlermails test('K25: geht an das Kundenkonto, ohne BCC ans Team, mit Käufer, Positionen und Portal-Link', async () => { const { values, blocks } = haendlerK25(ctxMit(), bestellung()) assert.deepEqual(blocks, { ueberverkauft: false, sepa: false }) const erg = await anHaendler('K25', ctxMit(), values, blocks) assert.deepEqual(erg, { ok: true }) assert.equal(sink.sent.length, 1) const mail = sink.sent[0] assert.deepEqual(mail.to.map((t) => t.address), ['erika@toepferei.example']) assert.ok(!mail.bcc, 'kein BCC ans Team') assert.equal(mail.subject, 'Neue Bestellung 26-K7QM2X in Deinem Webshop') assert.match(mail.text, /Hallo Erika Muster/) assert.match(mail.text, /Töpferei Muster/) assert.match(mail.text, /Käufer: Max Mustermann/) assert.match(mail.text, /Gesamt: 41,40 €/) assert.match(mail.html.replaceAll('\u00a0', ' '), /2 × Tasse \(blau\) – 24,00 €
1 × Teller – 12,50 €/) assert.match(mail.html, /href="https:\/\/project\.webklar\.com\/dashboard\.html#webshop\?projekt=p1"/) assert.doesNotMatch(mail.text, /Achtung|Lastschrift/) assert.deepEqual(warnLog(), []) }) test('K25: Blöcke ueberverkauft und sepa', async () => { const { values, blocks } = haendlerK25(ctxMit(), bestellung({}, { zahlungsart: 'sepa_debit' }), { ueberverkauft: true }) assert.deepEqual(blocks, { ueberverkauft: true, sepa: true }) assert.equal((await anHaendler('K25', ctxMit(), values, blocks)).ok, true) assert.match(sink.sent[0].text, /Achtung: Bestand reichte nicht/) assert.match(sink.sent[0].text, /Zahlung per Lastschrift/) }) test('K26: Widerruf mit Eingang in Berliner Zeit und Erklärung, ohne BCC; ohne Erklärung kein Block', async () => { const widerruf = { name: 'Kai ', email: 'kai@example.com', bestellnummer: '26-ZZZZZZ', nachricht: 'Ich widerrufe.', eingangAt: JETZT.toISOString() } const { values, blocks } = haendlerK26(ctxMit(), widerruf) assert.equal(values.eingang, '05.10.2026, 14:03 Uhr') assert.deepEqual(blocks, { nachricht: true }) assert.equal((await anHaendler('K26', ctxMit(), values, blocks)).ok, true) const ohne = haendlerK26(ctxMit(), { ...widerruf, nachricht: ' ' }) assert.deepEqual(ohne.blocks, { nachricht: false }) assert.equal((await anHaendler('K26', ctxMit(), ohne.values, ohne.blocks)).ok, true) const [mit, kurz] = sink.sent assert.ok(!mit.bcc) assert.equal(mit.subject, 'Widerruf in Deinem Webshop eingegangen') assert.match(mit.text, /Eingegangen am: 05\.10\.2026, 14:03 Uhr/) assert.match(mit.text, /Käufer: Kai /) assert.match(mit.text, /E-Mail: kai@example\.com/) assert.match(mit.text, /Erklärung: Ich widerrufe\./) assert.match(mit.html, /Kai <K>/) assert.doesNotMatch(kurz.text, /Erklärung:/) }) test('anHaendler wirft nie: Kunde fehlt oder ohne E-Mail, Versandfehler ⇒ { ok: false } und Warnung ohne Adresse', async () => { const { values, blocks } = haendlerK25(ctxMit(), bestellung()) const fremd = ctxMit() fremd.shop.customerId = 'c_gibtsnicht' assert.equal((await anHaendler('K25', fremd, values, blocks)).ok, false) aw.seed('customers', { $id: 'c_ohne', name: 'Ohne', email: '' }) const ohneMail = ctxMit() ohneMail.shop.customerId = 'c_ohne' assert.equal((await anHaendler('K25', ohneMail, values, blocks)).ok, false) setMailTransportForTests({ async sendMail() { throw new Error('SMTP erika@toepferei.example kaputt') }, }) assert.deepEqual(await anHaendler('K25', ctxMit(), values, blocks), { ok: false, fehler: 'Die E-Mail an den Händler konnte nicht gesendet werden.' }) assert.equal(sink.sent.length, 0) assert.doesNotMatch(warnLog().join('\n'), /erika@toepferei\.example/, 'keine Adresse im Log') assert.ok(warnLog().some((zeile) => /K25 nicht gesendet/.test(zeile))) })