Terminplaner: Mail-Warteschlange mit Stundenbudget
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
70b904260a
commit
af4029af81
2 files changed
+913
No files matched your search
@@ -0,0 +1,586 @@
|
||||
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'])
|
||||
})
|
||||
Reference in new issue
Block a user