Files
Webklar-Kundenbereich/test/termineOutbox.test.js
T
2026-10-03 08:17:43 +00:00

587 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
// Mail-Warteschlange (termineOutbox): Nachbau von Appwrite mit Schema und Datumsfeldern, Standard-Limit 25 wie in
// Produktion. Der Mailtransport ist ein Fake, der Sendeversuche zaehlt und gezielt Fehler wirft (nodemailer-Fehler
// tragen `code` und SMTP-`responseCode`). Alle Zeiten sind feste Parameter.
const aw = await startAppwriteMock({
schema: { termineOutbox: schemaKeys('termineOutbox') },
datetimes: TERMINE_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 { createDocument, deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { loadTemplate } = await import('../server/services/mailTemplates.js')
const { icsTermin } = await import('../server/services/termine/ics.js')
const O = await import('../server/services/termine/outbox.js')
const { einreihen, sendeEintrag, verarbeite, warteAufVersand, zustellFehler, raeumeAuf, pausiertBis, resetOutboxForTests } = O
after(() => setMailTransportForTests(null))
const MIN = 60000
const STUNDE = 60 * MIN
const TAG = 24 * STUNDE
const JETZT = new Date('2026-10-05T08:00:00.000Z')
const plus = (ms, von = JETZT) => new Date(von.getTime() + ms)
const ms = (wert) => new Date(wert).getTime()
const col = () => config.collections.termineOutbox
/** Transport: zaehlt Aufrufe, wirft die Fehler aus `fehler` der Reihe nach (leer = Erfolg), sonst landet die Mail in der Senke. */
function fakeTransport() {
const senke = mailSink()
const transport = {
senke,
aufrufe: 0,
fehler: [],
immer: null,
verzoegerungMs: 0,
async sendMail(mail) {
transport.aufrufe += 1
if (transport.verzoegerungMs) await new Promise((resolve) => setTimeout(resolve, transport.verzoegerungMs))
const fehler = transport.fehler.shift() ?? transport.immer
if (fehler) throw fehler
return senke.sendMail(mail)
},
}
return transport
}
const smtp = (responseCode, code = 'EENVELOPE') => Object.assign(new Error(`${responseCode} Testfehler`), { responseCode, code })
const EAUTH = Object.assign(new Error('Invalid login: 535 Authentication failed'), { code: 'EAUTH', responseCode: 535 })
const NUR_535 = Object.assign(new Error('535 5.7.8 Authentication failed'), { responseCode: 535 })
const ETIMEDOUT = Object.assign(new Error('Connection timeout'), { code: 'ETIMEDOUT' })
const ABSENDER_VORHER = config.smtp.from
let tr
let log
beforeEach(async () => {
config.smtp.from = 'noreply@webklar.com'
for (const id of aw.docs('termineOutbox').keys()) await deleteDocument(col(), id)
resetOutboxForTests()
config.termine.mailsProStunde = 200
tr = fakeTransport()
setMailTransportForTests(tr)
// Fehler-/Warnlogs der Warteschlange abfangen (Testausgabe bleibt sauber, Logs sind pruefbar)
log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}) }
})
afterEach(async () => {
await warteAufVersand()
mock.restoreAll()
config.termine.mailsProStunde = 200
config.smtp.from = ABSENDER_VORHER
})
const werte = (id, extra = {}) => ({ ...Object.fromEntries(loadTemplate(id).placeholders.map((p) => [p, `Wert-${p}`])), ...extra })
const eintrag = (extra = {}) => ({
featureId: 'f_feature01',
bookingId: 'b_buchung01',
art: 'G01',
an: 'anna@example.com',
fromName: 'Salon Muster',
replyTo: 'info@salon-muster.de',
template: 'G01',
values: werte('G01', { betreff: 'Bitte bestätigen Sie Ihren Termin bei Salon Muster' }),
blocks: { sie: true, bestaetigen: true },
...extra,
})
const kalender = (methode) =>
icsTermin({
uid: 'b_buchung01@termine.webklar.com',
sequenz: 0,
methode,
start: '2026-10-12T10:00:00.000+02:00',
ende: new Date('2026-10-12T08:45:00.000Z'),
titel: 'Haarschnitt – Salon Muster',
ort: 'Hauptstraße 1, 67655 Kaiserslautern',
organisator: { name: 'Salon Muster', email: 'info@salon-muster.de' },
teilnehmer: { name: 'Anna Müller', email: 'anna@example.com' },
jetzt: JETZT,
})
/** Eintrag direkt in der Datenbank (wartet), ohne Sendeversuch. */
const legeAn = (extra = {}) =>
createDocument(col(), {
featureId: 'f_feature01',
bookingId: 'b_direkt',
art: 'G05',
an: 'anna@example.com',
fromName: 'Salon Muster',
replyTo: 'info@salon-muster.de',
subject: 'Erinnerung: Ihr Termin',
html: '<p>Hallo</p>',
text: 'Hallo',
ics: '',
icsMethod: '',
status: 'wartet',
versuche: 0,
naechsterVersuch: JETZT.toISOString(),
fehler: '',
createdAt: JETZT.toISOString(),
...extra,
})
const lies = async (id) => aw.docs('termineOutbox').get(id)
const alle = () => [...aw.docs('termineOutbox').values()]
// ---------------------------------------------------------------------------------------------------------------------
// Einreihen und Senden
// ---------------------------------------------------------------------------------------------------------------------
test('einreihen: rendert sofort, speichert wartet und sendet danach im Hintergrund (From = Betrieb, replyTo, nie BCC)', async () => {
const doc = await einreihen(eintrag(), JETZT)
assert.equal(doc.status, 'wartet')
assert.equal(doc.versuche, 0)
assert.equal(tr.aufrufe, 0, 'einreihen wartet nicht auf den Versand')
assert.deepEqual(
{ featureId: doc.featureId, bookingId: doc.bookingId, art: doc.art, an: doc.an, fromName: doc.fromName, replyTo: doc.replyTo },
{ featureId: 'f_feature01', bookingId: 'b_buchung01', art: 'G01', an: 'anna@example.com', fromName: 'Salon Muster', replyTo: 'info@salon-muster.de' },
)
assert.equal(doc.subject, 'Bitte bestätigen Sie Ihren Termin bei Salon Muster')
assert.match(doc.html, /Wert-leistung/, 'Vorlage ist gerendert gespeichert')
assert.match(doc.text, /Wert-leistung/)
assert.equal(ms(doc.createdAt), JETZT.getTime())
assert.equal(ms(doc.naechsterVersuch), JETZT.getTime())
await warteAufVersand()
assert.equal(tr.senke.sent.length, 1)
const [mail] = tr.senke.sent
assert.deepEqual(mail.from, { address: 'noreply@webklar.com', name: 'Salon Muster' })
assert.deepEqual(mail.to, [{ address: 'anna@example.com', name: '' }])
assert.deepEqual(mail.replyTo, [{ address: 'info@salon-muster.de', name: '' }])
assert.equal(mail.subject, 'Bitte bestätigen Sie Ihren Termin bei Salon Muster')
assert.equal(mail.html, doc.html)
assert.equal(mail.text, doc.text)
assert.equal('bcc' in mail, false)
assert.equal('icalEvent' in mail, false)
})
test('Erfolg: Status gesendet, an/html/text/ics geleert, Betreff und Absender bleiben; Löschtermin 7 Tage nach dem Versand', async () => {
const doc = await einreihen(eintrag({ template: 'G02', art: 'G02', values: werte('G02', { betreff: 'Ihr Termin' }), blocks: { sie: true }, ics: kalender('REQUEST') }), JETZT)
await warteAufVersand()
const danach = await lies(doc.$id)
assert.equal(danach.status, 'gesendet')
assert.equal(danach.versuche, 1)
assert.equal(ms(danach.gesendetAt), JETZT.getTime())
assert.equal(ms(danach.naechsterVersuch), JETZT.getTime() + 7 * TAG)
for (const feld of ['an', 'html', 'text', 'ics']) assert.equal(danach[feld] || '', '', `${feld} ist geleert`)
assert.equal(danach.subject, 'Ihr Termin')
assert.equal(danach.fromName, 'Salon Muster')
assert.equal(danach.bookingId, 'b_buchung01')
assert.equal(danach.art, 'G02')
})
test('Kalenderdatei geht mit ihrer Methode als icalEvent raus (REQUEST, CANCEL, PUBLISH)', async () => {
for (const methode of ['REQUEST', 'CANCEL', 'PUBLISH']) {
const ics = kalender(methode)
await einreihen(eintrag({ bookingId: `b_${methode}`, ics, icsMethod: methode }), JETZT)
}
await warteAufVersand()
assert.equal(tr.senke.sent.length, 3)
assert.deepEqual(tr.senke.sent.map((m) => m.icalEvent.method).sort(), ['CANCEL', 'PUBLISH', 'REQUEST'])
for (const mail of tr.senke.sent) assert.match(mail.icalEvent.content, new RegExp(`METHOD:${mail.icalEvent.method}`))
})
test('einreihen weist falsche Eingaben ab und speichert nichts (fehlende Felder, ics-Methode passt nicht oder ist unbekannt)', async () => {
for (const feld of ['featureId', 'bookingId', 'art', 'an', 'fromName', 'template']) {
await assert.rejects(() => einreihen(eintrag({ [feld]: '' }), JETZT), TypeError, feld)
}
await assert.rejects(() => einreihen(eintrag({ ics: kalender('REQUEST'), icsMethod: 'CANCEL' }), JETZT), TypeError, 'METHOD in ics = REQUEST, icsMethod = CANCEL')
await assert.rejects(() => einreihen(eintrag({ ics: kalender('REQUEST'), icsMethod: 'REPLY' }), JETZT), TypeError, 'unbekannte Methode')
await assert.rejects(() => einreihen(eintrag(), 'gestern'), TypeError, 'now muss ein Date sein')
assert.equal(alle().length, 0)
await warteAufVersand()
assert.equal(tr.aufrufe, 0)
})
test('Vorlagenprobleme (fehlender Wert) landen im Log, die Mail wird trotzdem gespeichert und gesendet', async () => {
const unvollstaendig = werte('G01', { betreff: 'Betreff' })
delete unvollstaendig.leistung
await einreihen(eintrag({ values: unvollstaendig }), JETZT)
await warteAufVersand()
assert.ok(log.warn.mock.calls.some((call) => /G01.*leistung/.test(call.arguments.join(' '))), 'Warnung zu {{leistung}}')
assert.equal(tr.senke.sent.length, 1)
})
test('sendeEintrag meldet das Ergebnis: gesendet, uebersprungen (unbekannt oder schon erledigt)', async () => {
const doc = await legeAn()
assert.equal(await sendeEintrag(doc.$id, JETZT), 'gesendet')
assert.equal(await sendeEintrag(doc.$id, JETZT), 'uebersprungen', 'zweiter Aufruf: nichts mehr zu tun')
assert.equal(await sendeEintrag('gibt-es-nicht', JETZT), 'uebersprungen')
assert.equal(tr.aufrufe, 1)
})
test('verarbeite sendet nur fällige wartet-Einträge, älteste zuerst; gesendet und fehler bleiben unberührt', async () => {
const faellig = await legeAn({ bookingId: 'b_faellig', an: 'faellig@example.com', naechsterVersuch: plus(-5 * MIN).toISOString(), createdAt: plus(-9 * MIN).toISOString() })
const aelter = await legeAn({ bookingId: 'b_aelter', an: 'aelter@example.com', naechsterVersuch: plus(-1 * MIN).toISOString(), createdAt: plus(-30 * MIN).toISOString() })
const spaeter = await legeAn({ bookingId: 'b_spaeter', naechsterVersuch: plus(MIN).toISOString() })
const fertig = await legeAn({ bookingId: 'b_fertig', status: 'gesendet', an: '', html: '', text: '' })
const kaputt = await legeAn({ bookingId: 'b_kaputt', status: 'fehler' })
const ergebnis = await verarbeite(JETZT)
assert.deepEqual(ergebnis, { faellig: 2, gesendet: 2 })
assert.equal(tr.aufrufe, 2)
assert.deepEqual(tr.senke.sent.map((m) => m.to[0].address), ['aelter@example.com', 'faellig@example.com'], 'älteste zuerst')
assert.equal((await lies(faellig.$id)).status, 'gesendet')
assert.equal((await lies(aelter.$id)).status, 'gesendet')
assert.equal((await lies(spaeter.$id)).status, 'wartet')
assert.equal((await lies(fertig.$id)).status, 'gesendet')
assert.equal((await lies(kaputt.$id)).status, 'fehler')
await assert.rejects(() => verarbeite('jetzt'), TypeError)
})
test('verarbeite liest alle Seiten (mehr als 25 fällige Einträge)', async () => {
for (let i = 0; i < 30; i++) await legeAn({ bookingId: `b_${i}` })
const ergebnis = await verarbeite(JETZT)
assert.deepEqual(ergebnis, { faellig: 30, gesendet: 30 })
assert.equal(tr.senke.sent.length, 30)
})
// ---------------------------------------------------------------------------------------------------------------------
// Stundenbudget
// ---------------------------------------------------------------------------------------------------------------------
test('Stundenbudget 2: der dritte Eintrag wartet (unverändert, ohne Versuch) und geht erst nach einer Stunde raus', async () => {
config.termine.mailsProStunde = 2
for (const nummer of [1, 2, 3]) await einreihen(eintrag({ bookingId: `b_${nummer}` }), JETZT)
await warteAufVersand()
assert.equal(tr.senke.sent.length, 2)
const zustaende = alle().map((d) => d.status).sort()
assert.deepEqual(zustaende, ['gesendet', 'gesendet', 'wartet'])
const wartend = alle().find((d) => d.status === 'wartet')
assert.equal(wartend.versuche, 0)
assert.ok(wartend.an && wartend.html, 'Inhalt bleibt erhalten')
assert.deepEqual(await verarbeite(plus(59 * MIN)), { faellig: 1, gesendet: 0 })
assert.equal(tr.senke.sent.length, 2, 'innerhalb der Stunde bleibt es bei zwei Mails')
assert.deepEqual(await verarbeite(plus(61 * MIN)), { faellig: 1, gesendet: 1 })
assert.equal(tr.senke.sent.length, 3)
assert.equal(tr.aufrufe, 3, 'nur gesendete Mails zaehlen, kein Versuch fuer den wartenden Eintrag')
})
test('Stundenbudget ist ein gleitendes Fenster und zählt nur Mails, die wirklich rausgingen', async () => {
config.termine.mailsProStunde = 2
await einreihen(eintrag({ bookingId: 'b_a' }), JETZT)
await einreihen(eintrag({ bookingId: 'b_b' }), plus(30 * MIN))
await warteAufVersand()
await einreihen(eintrag({ bookingId: 'b_c' }), plus(61 * MIN)) // a ist raus aus dem Fenster, b nicht: ein Platz frei
await einreihen(eintrag({ bookingId: 'b_d' }), plus(61 * MIN))
await warteAufVersand()
assert.equal(tr.senke.sent.length, 3)
assert.deepEqual(alle().filter((d) => d.status === 'wartet').map((d) => d.bookingId), ['b_d'])
await verarbeite(plus(89 * MIN))
assert.equal(tr.senke.sent.length, 3, 'b ist noch im Fenster (bis 90 Minuten)')
await verarbeite(plus(90 * MIN))
assert.equal(tr.senke.sent.length, 4)
})
test('Fehlversuche verbrauchen kein Stundenbudget (auch nicht Anmelde- oder Verbindungsfehler)', async () => {
config.termine.mailsProStunde = 1
tr.fehler.push(smtp(451), ETIMEDOUT)
const doc = await einreihen(eintrag(), JETZT)
await warteAufVersand()
await verarbeite(plus(MIN)) // zweiter Fehlversuch
assert.equal(tr.aufrufe, 2)
assert.equal((await lies(doc.$id)).status, 'wartet')
await verarbeite(plus(6 * MIN)) // gelingt: der einzige Platz war frei
assert.equal(tr.senke.sent.length, 1)
assert.equal((await lies(doc.$id)).status, 'gesendet')
})
// ---------------------------------------------------------------------------------------------------------------------
// Fehlerklassen
// ---------------------------------------------------------------------------------------------------------------------
test('4xx: erneut nach 1, 5 und 30 Minuten (ab dem jeweils letzten Versuch), nach dem vierten Fehlschlag fehler', async () => {
tr.immer = smtp(451)
const doc = await einreihen(eintrag(), JETZT)
await warteAufVersand()
assert.equal(tr.aufrufe, 1)
let d = await lies(doc.$id)
assert.equal(d.status, 'wartet')
assert.equal(d.versuche, 1)
assert.equal(ms(d.naechsterVersuch), JETZT.getTime() + 1 * MIN)
assert.match(d.fehler, /451/)
await verarbeite(plus(59 * 1000))
assert.equal(tr.aufrufe, 1, 'vor Ablauf der Minute kein neuer Versuch')
await verarbeite(plus(1 * MIN))
assert.equal(tr.aufrufe, 2)
d = await lies(doc.$id)
assert.equal(d.versuche, 2)
assert.equal(ms(d.naechsterVersuch), plus(6 * MIN).getTime(), '5 Minuten nach dem zweiten Versuch')
await verarbeite(plus(5 * MIN))
assert.equal(tr.aufrufe, 2)
await verarbeite(plus(6 * MIN))
assert.equal(tr.aufrufe, 3)
d = await lies(doc.$id)
assert.equal(d.status, 'wartet')
assert.equal(ms(d.naechsterVersuch), plus(36 * MIN).getTime(), '30 Minuten nach dem dritten Versuch')
await verarbeite(plus(35 * MIN))
assert.equal(tr.aufrufe, 3)
await verarbeite(plus(36 * MIN))
assert.equal(tr.aufrufe, 4)
d = await lies(doc.$id)
assert.equal(d.status, 'fehler')
assert.equal(d.versuche, 4)
assert.match(d.fehler, /451/)
assert.equal(ms(d.naechsterVersuch), plus(36 * MIN).getTime() + 48 * STUNDE, 'Löschtermin 48 Stunden nach dem endgültigen Fehler')
assert.ok(d.an && d.html, 'bei fehler bleibt der Inhalt bis zum Löschen (Portal zeigt den Hinweis)')
await verarbeite(plus(3 * TAG))
assert.equal(tr.aufrufe, 4, 'danach kein weiterer Versuch')
assert.equal(log.error.mock.calls.length >= 1, true, 'endgültiger Fehler steht im Log')
})
test('Verbindungsfehler ohne SMTP-Antwort werden wie 4xx wiederholt; der zweite Versuch kann gelingen', async () => {
tr.fehler.push(ETIMEDOUT)
const doc = await einreihen(eintrag(), JETZT)
await warteAufVersand()
let d = await lies(doc.$id)
assert.equal(d.status, 'wartet')
assert.equal(d.versuche, 1)
assert.match(d.fehler, /ETIMEDOUT/)
await verarbeite(plus(MIN))
d = await lies(doc.$id)
assert.equal(d.status, 'gesendet')
assert.equal(d.versuche, 2)
assert.equal(ms(d.gesendetAt), plus(MIN).getTime())
assert.equal(d.an || '', '')
assert.equal(tr.senke.sent.length, 1)
})
test('nicht konfigurierter SMTP-Server ist ein vorübergehender Fehler (wartet, kein Absturz im Hintergrund)', async () => {
setMailTransportForTests(null)
const doc = await einreihen(eintrag(), JETZT)
await warteAufVersand()
const d = await lies(doc.$id)
assert.equal(d.status, 'wartet')
assert.equal(d.versuche, 1)
assert.match(d.fehler, /SMTP/)
})
test('550 und andere 5xx (außer 535): sofort fehler, kein zweiter Versuch', async () => {
for (const [nummer, fehler] of [[550, smtp(550)], [554, smtp(554, 'EMESSAGE')], [500, smtp(500)]]) {
tr.immer = fehler
const doc = await einreihen(eintrag({ bookingId: `b_${nummer}` }), JETZT)
await warteAufVersand()
const d = await lies(doc.$id)
assert.equal(d.status, 'fehler', String(nummer))
assert.equal(d.versuche, 1)
assert.match(d.fehler, new RegExp(String(nummer)))
assert.equal(ms(d.naechsterVersuch), JETZT.getTime() + 48 * STUNDE)
}
const vorher = tr.aufrufe
await verarbeite(plus(2 * TAG))
assert.equal(tr.aufrufe, vorher)
})
test('ungültige Eingabe (TypeError aus sendGuestMail) ist endgültig: fehler ohne Versuch am Transport', async () => {
const doc = await einreihen(eintrag({ an: 'keine-adresse' }), JETZT)
await warteAufVersand()
const d = await lies(doc.$id)
assert.equal(d.status, 'fehler')
assert.equal(d.versuche, 1)
assert.equal(tr.aufrufe, 0)
await verarbeite(plus(TAG))
assert.equal(tr.aufrufe, 0)
})
for (const [name, fehler] of [['EAUTH', EAUTH], ['535 ohne Code', NUR_535]]) {
test(`Anmeldefehler (${name}): 15 Minuten Pause für alle, Einträge bleiben wartet, in der Pause keine Sendeversuche, danach weiter`, async () => {
tr.fehler.push(fehler)
const a = await einreihen(eintrag({ bookingId: 'b_a' }), JETZT)
await warteAufVersand()
assert.equal(tr.aufrufe, 1)
const da = await lies(a.$id)
assert.equal(da.status, 'wartet')
assert.equal(da.versuche, 0, 'Anmeldefehler ist kein Fehlversuch dieser Mail')
assert.equal(ms(da.naechsterVersuch), JETZT.getTime())
assert.equal(pausiertBis().getTime(), plus(15 * MIN).getTime())
assert.ok(log.error.mock.calls.some((call) => /pausiert/i.test(call.arguments.join(' '))), 'Pause steht im Log')
// neue Einträge und Jobläufe in der Pause: kein einziger Sendeversuch
const b = await einreihen(eintrag({ bookingId: 'b_b' }), plus(1 * MIN))
await warteAufVersand()
await verarbeite(plus(5 * MIN))
await verarbeite(plus(14 * MIN + 59 * 1000))
assert.equal(await sendeEintrag(a.$id, plus(10 * MIN)), 'wartet')
assert.equal(tr.aufrufe, 1, 'während der Pause kein Sendeversuch')
assert.equal((await lies(b.$id)).status, 'wartet')
// nach der Pause geht alles raus
assert.deepEqual(await verarbeite(plus(15 * MIN)), { faellig: 2, gesendet: 2 })
assert.equal(tr.aufrufe, 3)
assert.equal(tr.senke.sent.length, 2)
assert.equal((await lies(a.$id)).status, 'gesendet')
assert.equal((await lies(b.$id)).status, 'gesendet')
})
}
test('pausiertBis: null ohne Pause; weitere Anmeldefehler nach der Pause verlängern sie erneut', async () => {
assert.equal(pausiertBis(), null)
tr.fehler.push(EAUTH, EAUTH)
await einreihen(eintrag(), JETZT)
await warteAufVersand()
assert.equal(pausiertBis().getTime(), plus(15 * MIN).getTime())
await verarbeite(plus(15 * MIN))
assert.equal(pausiertBis().getTime(), plus(30 * MIN).getTime())
resetOutboxForTests()
assert.equal(pausiertBis(), null)
})
// ---------------------------------------------------------------------------------------------------------------------
// Parallelität
// ---------------------------------------------------------------------------------------------------------------------
test('zwei verarbeite (und ein sendeEintrag) gleichzeitig ⇒ genau eine Mail', async () => {
tr.verzoegerungMs = 30
const doc = await legeAn()
await Promise.all([verarbeite(JETZT), verarbeite(JETZT), sendeEintrag(doc.$id, JETZT)])
assert.equal(tr.aufrufe, 1)
assert.equal(tr.senke.sent.length, 1)
const d = await lies(doc.$id)
assert.equal(d.status, 'gesendet')
assert.equal(d.versuche, 1)
})
test('zwei verarbeite gleichzeitig bei einem Fehlschlag ⇒ nur ein Versuch (der zweite sieht den neuen Termin)', async () => {
tr.verzoegerungMs = 30
tr.immer = smtp(451)
const doc = await legeAn()
await Promise.all([verarbeite(JETZT), verarbeite(JETZT)])
assert.equal(tr.aufrufe, 1)
const d = await lies(doc.$id)
assert.equal(d.versuche, 1)
assert.equal(ms(d.naechsterVersuch), plus(MIN).getTime())
})
test('Stundenbudget gilt auch bei gleichzeitigen Versuchen für verschiedene Einträge', async () => {
config.termine.mailsProStunde = 3
tr.verzoegerungMs = 20
const ids = []
for (let i = 0; i < 6; i++) ids.push((await legeAn({ bookingId: `b_${i}` })).$id)
await Promise.all(ids.map((id) => sendeEintrag(id, JETZT)))
assert.equal(tr.aufrufe, 3)
assert.equal(alle().filter((d) => d.status === 'gesendet').length, 3)
})
// ---------------------------------------------------------------------------------------------------------------------
// Speicherfehler nach dem Versand
// ---------------------------------------------------------------------------------------------------------------------
/** Die nächsten `n` PATCH-Anfragen scheitern mit 500 (der Nachbau kann nur einen eingeplanten Fehler gleichzeitig). */
function patchSchlaegtFehl(n) {
const echt = globalThis.fetch
let rest = n
globalThis.fetch = async (url, options) => {
if (options?.method === 'PATCH' && rest > 0) {
rest -= 1
return new Response(JSON.stringify({ message: 'Nachbau: Speichern gescheitert', code: 500 }), { status: 500 })
}
return echt(url, options)
}
return () => (globalThis.fetch = echt)
}
test('Speichern nach erfolgreichem Versand scheitert einmal: zweiter Speicherversuch, nur eine Mail', async () => {
const zurueck = patchSchlaegtFehl(1)
try {
const doc = await einreihen(eintrag(), JETZT)
await warteAufVersand()
assert.equal(tr.senke.sent.length, 1)
assert.equal((await lies(doc.$id)).status, 'gesendet')
} finally {
zurueck()
}
})
test('Speichern scheitert dauerhaft: keine zweite Mail, der Status wird beim nächsten Lauf nachgetragen', async () => {
const zurueck = patchSchlaegtFehl(2)
let doc
try {
doc = await legeAn()
assert.equal(await sendeEintrag(doc.$id, JETZT), 'gesendet')
} finally {
zurueck()
}
assert.equal(tr.senke.sent.length, 1)
assert.equal((await lies(doc.$id)).status, 'wartet', 'Datenbank kennt den Versand noch nicht')
assert.ok(log.error.mock.calls.length >= 1)
await verarbeite(plus(MIN))
assert.equal(tr.senke.sent.length, 1, 'nicht erneut gesendet')
const d = await lies(doc.$id)
assert.equal(d.status, 'gesendet')
assert.equal(d.an || '', '')
assert.equal(ms(d.gesendetAt), JETZT.getTime(), 'Zeitpunkt des echten Versands')
})
// ---------------------------------------------------------------------------------------------------------------------
// Aufräumen und Zustellhinweis
// ---------------------------------------------------------------------------------------------------------------------
test('raeumeAuf: gesendet 7 Tage nach dem Versand, fehler 48 Stunden nach dem Fehler; wartet nie', async () => {
const ok = await einreihen(eintrag({ bookingId: 'b_ok' }), JETZT)
await warteAufVersand()
tr.immer = smtp(550)
const kaputt = await einreihen(eintrag({ bookingId: 'b_kaputt' }), JETZT)
await warteAufVersand()
const wartend = await legeAn({ bookingId: 'b_wartet', createdAt: plus(-30 * TAG).toISOString() })
assert.equal(await raeumeAuf(plus(48 * STUNDE - 1)), 0)
assert.equal(await raeumeAuf(plus(48 * STUNDE)), 1)
assert.equal(await lies(kaputt.$id), undefined)
assert.ok(await lies(ok.$id))
assert.equal(await raeumeAuf(plus(7 * TAG - 1)), 0)
assert.equal(await raeumeAuf(plus(7 * TAG)), 1)
assert.equal(await lies(ok.$id), undefined)
assert.equal(await raeumeAuf(plus(60 * TAG)), 0)
assert.ok(await lies(wartend.$id), 'wartende Einträge löscht das Aufräumen nie')
await assert.rejects(() => raeumeAuf(), TypeError)
})
test('raeumeAuf löscht mehr als eine Seite (über 25 Einträge)', async () => {
for (let i = 0; i < 40; i++) await legeAn({ bookingId: `b_${i}`, status: 'gesendet', an: '', html: '', text: '', naechsterVersuch: JETZT.toISOString() })
assert.equal(await raeumeAuf(JETZT), 40)
assert.equal(alle().length, 0)
})
test('zustellFehler: Menge der Buchungen mit endgültig gescheiterter Mail (nur fehler, auch über mehrere Blöcke)', async () => {
await legeAn({ bookingId: 'b_fehler', status: 'fehler' })
await legeAn({ bookingId: 'b_fehler', status: 'fehler' })
await legeAn({ bookingId: 'b_spaet', status: 'fehler' })
await legeAn({ bookingId: 'b_gesendet', status: 'gesendet' })
await legeAn({ bookingId: 'b_wartet', status: 'wartet' })
await legeAn({ bookingId: 'b_fremd', status: 'fehler' })
const treffer = await zustellFehler(['b_fehler', 'b_gesendet', 'b_wartet', 'b_unbekannt'])
assert.ok(treffer instanceof Set)
assert.deepEqual([...treffer], ['b_fehler'])
const viele = Array.from({ length: 130 }, (_, i) => `b_x${i}`)
viele.splice(120, 0, 'b_spaet')
assert.deepEqual([...(await zustellFehler(viele))], ['b_spaet'])
assert.deepEqual([...(await zustellFehler([]))], [])
assert.deepEqual([...(await zustellFehler(undefined))], [])
})
test('zustellFehler: auch eine gescheiterte Mail an den Betrieb (K20) zählt; erfolgreiche Mails derselben Buchung ändern daran nichts', async () => {
tr.immer = smtp(550)
await einreihen(eintrag({ bookingId: 'b_1', template: 'K20', art: 'K20', values: werte('K20', { betreff: 'Neue Buchung' }), blocks: {} }), JETZT)
await warteAufVersand()
tr.immer = null
await einreihen(eintrag({ bookingId: 'b_1' }), JETZT)
await warteAufVersand()
assert.deepEqual([...(await zustellFehler(['b_1']))], ['b_1'])
})