Files
Webklar-Kundenbereich/test/termineOutbox.test.js
T
ClaudeandClaude Sonnet 5.5 43b3caddde Terminplaner: veraltete G01 verworfen, Index bookingId
Eine G01, die erst nach holdMinutes + 1 Minute faellig wird, ist mit verfallenem Link
nutzlos: sie wird nicht mehr gesendet, sondern verworfen (kein Transportaufruf, kein
Budgetplatz, Felder geleert, kein Zustellfehler, raeumeAuf loescht sie nach 48 Stunden).
Neuer Index idx_booking auf termineOutbox fuer zustellFehler und verwerfeWartende;
verworfeneG01() zaehlt seit Prozessstart.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 10:21:25 +00:00

1233 lines
64 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, beendeFestgefahrene, verwerfeWartende, pausiertBis, resetOutboxForTests } = O
const { MAIL_VORRANG, MAIL_NACHRANGIG, mailKlasse, pruefePlatz, mailBudget, verworfeneG01 } = 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
config.termine.mailsProFunktion = 40
config.termine.mailsReserve = 0.25
config.termine.mailListe = 100
config.termine.holdMinutes = 30
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.termine.mailsProFunktion = 40
config.termine.mailsReserve = 0.25
config.termine.mailListe = 100
config.termine.holdMinutes = 30
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, fälligste zuerst (naechsterVersuch, nicht createdAt); 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), ['faellig@example.com', 'aelter@example.com'], 'fälligste zuerst: naechsterVersuch -5 min vor -1 min, obwohl der andere Eintrag älter ist (createdAt)')
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 mit eigenem limit mehr als die 25 der Standardseite (30 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
// G02 statt G01: eine G01 verfaellt nach dem Halte-Fenster (31 Minuten) und wuerde nach einer Stunde verworfen (siehe „veraltetes G01“)
for (const nummer of [1, 2, 3]) await einreihen(eintrag({ bookingId: `b_${nummer}`, art: 'G02' }), 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: 0, gesendet: 0 }, 'Frühausstieg: das Budget ist voll, der wartende Eintrag wird nicht einmal gelesen')
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')
})
// ---------------------------------------------------------------------------------------------------------------------
// Mail-Fairness: Kontingent je Funktion, Vorrang-Reserve
// ---------------------------------------------------------------------------------------------------------------------
/** Eintrag einer bestimmten Funktion und Art (Vorlage bleibt G01: die Warteschlange prueft Art und Vorlage nicht gegeneinander). */
const von = (featureId, art, nummer, extra = {}) => eintrag({ featureId, art, bookingId: `b_${featureId}_${art}_${nummer}`, ...extra })
test('mailKlasse: G01/G02/G08 vorrang, G05/K20/K21 nachrangig, alle anderen normal', () => {
assert.deepEqual(MAIL_VORRANG, ['G01', 'G02', 'G08'])
assert.deepEqual(MAIL_NACHRANGIG, ['G05', 'K20', 'K21'])
const erwartet = { G01: 'vorrang', G02: 'vorrang', G03: 'normal', G04: 'normal', G05: 'nachrangig', G06: 'normal', G07: 'normal', G08: 'vorrang', K20: 'nachrangig', K21: 'nachrangig' }
assert.deepEqual(Object.fromEntries(Object.keys(erwartet).map((art) => [art, mailKlasse(art)])), erwartet)
assert.equal(mailKlasse('unbekannt'), 'normal')
})
test('Kontingent je Funktion: die Funktion A bekommt 3 von 5 Mails, B ist unberührt, nach einer Stunde geht der Rest raus', async () => {
config.termine.mailsProFunktion = 3
for (let i = 1; i <= 5; i++) await einreihen(von('f_A', 'G02', i), JETZT)
await warteAufVersand()
assert.equal(tr.senke.sent.length, 3)
const wartendA = alle().filter((d) => d.status === 'wartet')
assert.equal(wartendA.length, 2)
for (const d of wartendA) {
assert.equal(d.versuche, 0, 'ein gebremster Eintrag ist kein Versuch')
assert.equal(d.fehler, '')
assert.ok(d.an && d.html && d.text, 'Inhalt bleibt erhalten')
assert.equal(ms(d.naechsterVersuch), JETZT.getTime())
}
assert.equal(tr.aufrufe, 3, 'kein Transportaufruf für die gebremsten Einträge')
for (let i = 1; i <= 2; i++) await einreihen(von('f_B', 'G02', i), plus(MIN))
await warteAufVersand()
assert.equal(tr.senke.sent.length, 5, 'Funktion B ist vom Kontingent von A nicht betroffen')
assert.deepEqual(alle().filter((d) => d.status === 'gesendet' && d.featureId === 'f_B').length, 2)
assert.deepEqual(await verarbeite(plus(59 * MIN)), { faellig: 2, gesendet: 0 }, 'A steht bis 60 Minuten nach dem ersten Versand')
assert.deepEqual(await verarbeite(plus(61 * MIN)), { faellig: 2, gesendet: 2 })
assert.equal(tr.senke.sent.length, 7)
assert.equal(alle().filter((d) => d.status === 'wartet').length, 0)
})
test('verarbeite überspringt Einträge einer ausgeschöpften Funktion und bedient die Funktionen dahinter (fälligste zuerst)', async () => {
config.termine.mailsProFunktion = 2
for (let i = 0; i < 4; i++) await legeAn({ featureId: 'f_A', art: 'G02', bookingId: `b_a${i}`, naechsterVersuch: plus(-60000 + i * 1000).toISOString() })
for (let i = 0; i < 2; i++) await legeAn({ featureId: 'f_B', art: 'G02', bookingId: `b_b${i}`, naechsterVersuch: plus(-50000 + i * 1000).toISOString() })
assert.deepEqual(await verarbeite(plus(MIN)), { faellig: 6, gesendet: 4 })
const gesendet = alle().filter((d) => d.status === 'gesendet')
assert.deepEqual(gesendet.map((d) => d.bookingId).sort(), ['b_a0', 'b_a1', 'b_b0', 'b_b1'])
const wartend = alle().filter((d) => d.status === 'wartet')
assert.deepEqual(wartend.map((d) => d.bookingId).sort(), ['b_a2', 'b_a3'])
assert.ok(wartend.every((d) => d.versuche === 0))
})
test('Vorrang-Reserve: nachrangige Mails (G05, K20, K21) nehmen höchstens 6 von 8 Plätzen, die letzten gehören dem Vorrang', async () => {
config.termine.mailsProStunde = 8
for (let i = 1; i <= 10; i++) await einreihen(von('f_A', 'G05', i), JETZT)
await warteAufVersand()
assert.equal(tr.senke.sent.length, 6)
assert.equal(alle().filter((d) => d.status === 'wartet').length, 4)
await einreihen(von('f_A', 'G01', 1), plus(MIN))
await warteAufVersand()
assert.equal(tr.senke.sent.length, 7, 'Platz 7 geht an den Vorrang')
assert.deepEqual(await verarbeite(plus(2 * MIN)), { faellig: 0, gesendet: 0 }, 'das siebte G05 wartet, obwohl noch ein Platz frei ist (Reserve erreicht: die nachrangigen werden nicht einmal gelesen)')
assert.equal(tr.senke.sent.length, 7)
assert.ok(alle().filter((d) => d.status === 'wartet').every((d) => d.versuche === 0 && d.html))
await einreihen(von('f_A', 'G08', 1), plus(3 * MIN))
await warteAufVersand()
assert.equal(tr.senke.sent.length, 8, 'Platz 8 geht an den Vorrang')
await einreihen(von('f_A', 'G03', 1), plus(4 * MIN))
await warteAufVersand()
assert.equal(tr.senke.sent.length, 8, 'das Gesamtbudget gilt weiter')
assert.deepEqual(await verarbeite(plus(61 * MIN)), { faellig: 5, gesendet: 5 }, 'nach einer Stunde fließt alles wieder (4 G05 und die G03)')
})
test('Vorrang-Reserve: K20 und K21 zählen wie G05; G03/G04/G06/G07 (normal) werden von der Reserve nicht gebremst', async () => {
config.termine.mailsProStunde = 8
await einreihen(von('f_A', 'G05', 1), JETZT)
await einreihen(von('f_A', 'K20', 1), JETZT)
await einreihen(von('f_A', 'K21', 1), JETZT)
await einreihen(von('f_A', 'G05', 2), JETZT)
await einreihen(von('f_A', 'K20', 2), JETZT)
await einreihen(von('f_A', 'K21', 2), JETZT)
await einreihen(von('f_A', 'K21', 3), JETZT)
await warteAufVersand()
assert.equal(tr.senke.sent.length, 6, 'höchstens 6 nachrangige')
for (const art of ['G03', 'G04']) await einreihen(von('f_A', art, 1), JETZT)
await warteAufVersand()
assert.equal(tr.senke.sent.length, 8, 'die Reserve bremst normale Mails nicht')
})
test('pruefePlatz: Reihenfolge Pause, Gesamtbudget, Reserve, Kontingent (nur lesen, belegt nichts)', async () => {
config.termine.mailsProStunde = 4 // nachrangig höchstens 3
config.termine.mailsProFunktion = 2
assert.equal(pruefePlatz(JETZT.getTime(), 'f_A', 'G01'), null)
assert.equal(pruefePlatz(JETZT.getTime(), 'f_A', 'G01'), null, 'Prüfen belegt keinen Platz')
await einreihen(von('f_A', 'G05', 1), JETZT)
await einreihen(von('f_A', 'G05', 2), JETZT)
await warteAufVersand()
assert.equal(pruefePlatz(JETZT.getTime(), 'f_A', 'G01'), 'kontingent', 'Funktion A hat 2 von 2')
assert.equal(pruefePlatz(JETZT.getTime(), 'f_A', 'G05'), 'kontingent', '2 nachrangige von höchstens 3: die Reserve greift noch nicht, das Kontingent schon')
assert.equal(pruefePlatz(JETZT.getTime(), 'f_B', 'G05'), null, '2 nachrangige von höchstens 3')
await einreihen(von('f_B', 'G05', 1), JETZT)
await warteAufVersand()
assert.equal(pruefePlatz(JETZT.getTime(), 'f_C', 'K21'), 'reserve', '3 nachrangige von höchstens 3')
assert.equal(pruefePlatz(JETZT.getTime(), 'f_C', 'G02'), null, 'Vorrang darf noch')
await einreihen(von('f_C', 'G02', 1), JETZT)
await warteAufVersand()
assert.equal(pruefePlatz(JETZT.getTime(), 'f_D', 'G01'), 'budget', '4 von 4')
assert.equal(pruefePlatz(JETZT.getTime(), 'f_D', 'G05'), 'budget', 'Gesamtbudget vor Reserve')
tr.fehler.push(EAUTH)
await einreihen(von('f_D', 'G01', 1), plus(61 * MIN))
await warteAufVersand()
assert.equal(pruefePlatz(plus(62 * MIN).getTime(), 'f_E', 'G01'), 'pause', 'Pause vor allem anderen')
assert.equal(pruefePlatz(plus(77 * MIN).getTime(), 'f_E', 'G01'), null, 'Pause vorbei (15 Minuten)')
})
test('gleitendes Fenster je Funktion: 59 Minuten nach dem Versand noch belegt, nach 60 wieder frei', async () => {
config.termine.mailsProFunktion = 1
await einreihen(von('f_A', 'G02', 1), JETZT)
await warteAufVersand()
await einreihen(von('f_A', 'G02', 2), plus(10 * MIN))
await warteAufVersand()
assert.equal(tr.senke.sent.length, 1)
assert.deepEqual(await verarbeite(plus(59 * MIN)), { faellig: 1, gesendet: 0 })
assert.equal(pruefePlatz(plus(59 * MIN).getTime(), 'f_A', 'G02'), 'kontingent')
assert.equal(pruefePlatz(plus(60 * MIN).getTime(), 'f_A', 'G02'), null)
assert.deepEqual(await verarbeite(plus(60 * MIN)), { faellig: 1, gesendet: 1 })
})
test('Fehlversuche (4xx, Verbindung) und Anmeldepause belegen weder einen Gesamt- noch einen Funktionsplatz', async () => {
config.termine.mailsProFunktion = 1
config.termine.mailsProStunde = 1
tr.fehler.push(smtp(451), EAUTH)
const a = await einreihen(von('f_A', 'G02', 1), JETZT)
await warteAufVersand()
assert.equal((await lies(a.$id)).versuche, 1)
assert.equal(mailBudget(JETZT).stunde.benutzt, 0, '4xx belegt nichts')
await verarbeite(plus(MIN)) // Anmeldefehler: Pause, kein Fehlversuch
assert.equal(mailBudget(plus(MIN)).stunde.benutzt, 0, 'die Anmeldepause belegt nichts')
assert.deepEqual(mailBudget(plus(MIN)).spitzen, [])
assert.equal((await verarbeite(plus(16 * MIN))).gesendet, 1, 'der einzige Platz der Funktion und der Stunde war frei')
assert.equal((await lies(a.$id)).status, 'gesendet')
})
test('gleichzeitige Versuche einer Funktion halten das Kontingent: 6 Einträge, Kontingent 3 ⇒ genau 3 Transportaufrufe', async () => {
config.termine.mailsProFunktion = 3
tr.verzoegerungMs = 20
const ids = []
for (let i = 0; i < 6; i++) ids.push((await legeAn({ featureId: 'f_A', art: 'G02', bookingId: `b_${i}` })).$id)
const antworten = await Promise.all(ids.map((id) => sendeEintrag(id, JETZT)))
assert.equal(tr.aufrufe, 3)
assert.equal(antworten.filter((a) => a === 'gesendet').length, 3)
assert.equal(antworten.filter((a) => a === 'wartet').length, 3)
assert.equal(alle().filter((d) => d.status === 'gesendet').length, 3)
})
test('mailBudget: Zahlen und Funktions-IDs, Spitzen sortiert und auf 3 begrenzt, nie eine Adresse', async () => {
for (let i = 1; i <= 2; i++) await einreihen(von('f_A', 'G02', i), JETZT)
await einreihen(von('f_A', 'G05', 1), JETZT)
for (let i = 1; i <= 2; i++) await einreihen(von('f_B', 'G01', i), JETZT)
await warteAufVersand()
assert.equal(tr.senke.sent.length, 5)
assert.deepEqual(mailBudget(plus(MIN)), {
stunde: { benutzt: 5, max: 200 },
nachrangig: { benutzt: 1, max: 150 },
jeFunktionMax: 40,
spitzen: [{ featureId: 'f_A', benutzt: 3 }, { featureId: 'f_B', benutzt: 2 }],
})
assert.ok(!JSON.stringify(mailBudget(JETZT)).includes('@'), 'keine Adresse')
for (const [id, anzahl] of [['f_C', 4], ['f_D', 1]]) {
for (let i = 1; i <= anzahl; i++) await einreihen(von(id, 'G03', i), plus(MIN))
}
await warteAufVersand()
const budget = mailBudget(plus(2 * MIN))
assert.equal(budget.stunde.benutzt, 10)
assert.deepEqual(budget.spitzen, [{ featureId: 'f_C', benutzt: 4 }, { featureId: 'f_A', benutzt: 3 }, { featureId: 'f_B', benutzt: 2 }], 'höchstens 3, größte zuerst')
config.termine.mailsProStunde = 8
config.termine.mailsProFunktion = 5
config.termine.mailsReserve = 0.5
const neu = mailBudget(plus(2 * MIN))
assert.deepEqual([neu.stunde.max, neu.nachrangig.max, neu.jeFunktionMax], [8, 4, 5], 'liest die Werte bei jedem Aufruf')
assert.equal(mailBudget(plus(60 * MIN)).stunde.benutzt, 5, 'A und B (Versand um 08:00) sind nach 60 Minuten raus, C und D (08:01) noch nicht')
assert.deepEqual(mailBudget(plus(61 * MIN)), { stunde: { benutzt: 0, max: 8 }, nachrangig: { benutzt: 0, max: 4 }, jeFunktionMax: 5, spitzen: [] })
assert.throws(() => mailBudget('jetzt'), TypeError)
})
test('Reserve: mit sehr kleinem Stundenbudget darf mindestens eine nachrangige Mail raus', async () => {
config.termine.mailsProStunde = 1
assert.equal(pruefePlatz(JETZT.getTime(), 'f_A', 'G05'), null)
assert.equal(mailBudget(JETZT).nachrangig.max, 1)
config.termine.mailsProStunde = 3
config.termine.mailsReserve = 0.9
assert.equal(mailBudget(JETZT).nachrangig.max, 1, 'floor(3 × 0.1) = 0 ⇒ mindestens 1')
})
// ---------------------------------------------------------------------------------------------------------------------
// Minutenjob: begrenzte, nach Klasse geordnete Liste (verarbeite)
// ---------------------------------------------------------------------------------------------------------------------
const listenAbfragen = () => aw.listCalls('termineOutbox')
const abfrageKlasse = ({ queries }) => {
const art = queries.find((q) => q.attribute === 'art')
if (art.method === 'notEqual') return 'normal'
return art.values.includes('G01') ? 'vorrang' : 'nachrangig'
}
const abfrageKlassen = () => listenAbfragen().map(abfrageKlasse)
const ausschluss = ({ queries }) => queries.find((q) => q.method === 'notEqual' && q.attribute === 'featureId')?.values ?? null
const begrenzteLeseabfragen = (abfragen) => {
for (const abfrage of abfragen) {
const limit = abfrage.queries.find((q) => q.method === 'limit')
assert.ok(limit && limit.values[0] <= 100, `limit höchstens 100: ${JSON.stringify(abfrage.queries)}`)
assert.ok(!abfrage.queries.some((q) => q.method === 'cursorAfter' || (q.method === 'orderAsc' && q.attribute === '$id')), 'kein $id-Seitenlauf')
assert.deepEqual(abfrage.queries.find((q) => q.method === 'select')?.values, ['$id', 'featureId', 'art', 'naechsterVersuch'], 'nur die vier Felder')
assert.ok(abfrage.queries.some((q) => q.method === 'orderAsc' && q.attribute === 'naechsterVersuch'), 'fälligste zuerst')
assert.ok(!JSON.stringify(abfrage.queries).includes('html'), 'kein Mailtext in der Abfrage')
assert.ok(abfrage.queries.some((q) => q.method === 'equal' && q.attribute === 'status' && q.values[0] === 'wartet'))
assert.ok(abfrage.queries.some((q) => q.method === 'lessThanEqual' && q.attribute === 'naechsterVersuch'))
}
}
/** Alle Einträge löschen und den Prozesszustand zurücksetzen (mehrere Szenarien in einem Test). */
async function leereWarteschlange() {
for (const id of aw.docs('termineOutbox').keys()) await deleteDocument(col(), id)
resetOutboxForTests()
tr = fakeTransport()
setMailTransportForTests(tr)
}
const empfaenger = () => tr.senke.sent.map((m) => m.to[0].address)
test('Frühausstieg: bei leerem Gesamtbudget liest verarbeite nichts (keine Listenabfrage an termineOutbox)', async () => {
config.termine.mailsProStunde = 2
for (const nummer of [1, 2]) await einreihen(eintrag({ bookingId: `b_${nummer}` }), JETZT)
await warteAufVersand()
await legeAn({ art: 'G02', bookingId: 'b_wartet', naechsterVersuch: plus(-MIN).toISOString() })
aw.resetListCalls()
assert.deepEqual(await verarbeite(plus(MIN)), { faellig: 0, gesendet: 0 })
assert.deepEqual(await verarbeite(plus(59 * MIN)), { faellig: 0, gesendet: 0 })
assert.equal(listenAbfragen().length, 0, 'Budget voll: nicht eine Abfrage')
assert.equal(tr.senke.sent.length, 2)
assert.deepEqual(await verarbeite(plus(61 * MIN)), { faellig: 1, gesendet: 1 }, 'nach einer Stunde wird gelesen und gesendet')
assert.ok(listenAbfragen().length > 0)
})
test('Frühausstieg: während der Anmeldepause liest verarbeite nichts, danach läuft es weiter', async () => {
tr.fehler.push(EAUTH)
await einreihen(eintrag(), JETZT) // Anmeldefehler: 15 Minuten Pause, der Eintrag bleibt wartet
await warteAufVersand()
assert.equal(pausiertBis().getTime(), plus(15 * MIN).getTime())
await legeAn({ art: 'G02', bookingId: 'b_wartet' })
aw.resetListCalls()
assert.deepEqual(await verarbeite(plus(MIN)), { faellig: 0, gesendet: 0 })
assert.equal(listenAbfragen().length, 0, 'Pause: nicht eine Abfrage')
assert.deepEqual(await verarbeite(plus(16 * MIN)), { faellig: 2, gesendet: 2 })
})
test('begrenzte Liste: 250 fällige G02 aus 3 Funktionen ⇒ jede Abfrage limit ≤ 100, ohne $id-Seitenlauf, mit select; gesendet wird bis zum Kontingent', async () => {
const funktionen = ['f_A', 'f_B', 'f_C']
for (let i = 0; i < 250; i++) {
await legeAn({ featureId: funktionen[i % 3], art: 'G02', bookingId: `b_${i}`, naechsterVersuch: plus(-3600000 + i * 1000).toISOString() })
}
aw.resetListCalls()
const ergebnis = await verarbeite(JETZT)
assert.deepEqual(ergebnis, { faellig: 200, gesendet: 120 }, 'zwei volle Seiten gelesen; je Funktion höchstens 40 (Kontingent)')
begrenzteLeseabfragen(listenAbfragen())
assert.deepEqual(abfrageKlassen(), ['vorrang', 'vorrang', 'vorrang', 'normal', 'nachrangig'])
for (const f of funktionen) assert.equal(alle().filter((d) => d.featureId === f && d.status === 'gesendet').length, 40)
assert.equal(alle().filter((d) => d.status === 'wartet').length, 130)
assert.ok(alle().filter((d) => d.status === 'wartet').every((d) => d.versuche === 0 && d.html), 'gebremste Einträge bleiben unverändert')
assert.equal(tr.aufrufe, 120)
})
test('Reihenfolge: Vorrang vor normal vor nachrangig, innerhalb einer Klasse die fälligste zuerst (Budget 1 bis 4)', async () => {
const lege = async () => {
await legeAn({ art: 'G05', an: 'g05@example.com', bookingId: 'b_g05', naechsterVersuch: plus(-10 * MIN).toISOString(), createdAt: plus(-10 * MIN).toISOString() })
await legeAn({ art: 'G03', an: 'g03a@example.com', bookingId: 'b_g03a', naechsterVersuch: plus(-3 * MIN).toISOString(), createdAt: plus(-60 * MIN).toISOString() })
await legeAn({ art: 'G03', an: 'g03b@example.com', bookingId: 'b_g03b', naechsterVersuch: plus(-7 * MIN).toISOString(), createdAt: plus(-MIN).toISOString() })
await legeAn({ art: 'G01', an: 'g01@example.com', bookingId: 'b_g01', naechsterVersuch: plus(-MIN).toISOString(), createdAt: plus(-30 * MIN).toISOString() })
}
// gelesen wird je Klasse eine Liste: vorrang 1 Eintrag, normal 2, nachrangig 1; danach ist das Budget voll oder alles gesendet
const szenarien = [
[1, ['g01@example.com'], 1],
[2, ['g01@example.com', 'g03b@example.com'], 3],
[3, ['g01@example.com', 'g03b@example.com', 'g03a@example.com'], 3],
[4, ['g01@example.com', 'g03b@example.com', 'g03a@example.com', 'g05@example.com'], 4],
]
for (const [budget, erwartet, gelesen] of szenarien) {
await leereWarteschlange()
config.termine.mailsProStunde = budget
await lege()
const ergebnis = await verarbeite(JETZT)
assert.deepEqual(empfaenger(), erwartet, `Budget ${budget}`)
assert.equal(ergebnis.gesendet, budget)
assert.equal(ergebnis.faellig, gelesen, `Budget ${budget}: gelesen wird nur, bis das Budget voll ist`)
assert.equal(alle().filter((d) => d.status === 'wartet').length, 4 - budget)
}
})
test('kein Verhungern: 150 fällige G02 der Funktion A (Kontingent 40) vor einem G02 der Funktion B ⇒ ein Lauf sendet 40 von A und die Mail von B', async () => {
for (let i = 0; i < 150; i++) await legeAn({ featureId: 'f_A', art: 'G02', bookingId: `b_a${i}`, naechsterVersuch: plus(-3600000 + i * 1000).toISOString() })
const b = await legeAn({ featureId: 'f_B', art: 'G02', bookingId: 'b_b', an: 'b@example.com', naechsterVersuch: plus(-1000).toISOString() })
aw.resetListCalls()
assert.deepEqual(await verarbeite(JETZT), { faellig: 101, gesendet: 41 })
assert.equal((await lies(b.$id)).status, 'gesendet')
assert.equal(alle().filter((d) => d.featureId === 'f_A' && d.status === 'gesendet').length, 40)
const abfragen = listenAbfragen()
begrenzteLeseabfragen(abfragen)
const vorrang = abfragen.filter((a) => abfrageKlasse(a) === 'vorrang')
assert.equal(vorrang.length, 2, 'erste Seite voll von A, zweite mit Ausschlussliste')
assert.equal(ausschluss(vorrang[0]), null, 'die erste Abfrage schließt nichts aus')
assert.deepEqual(ausschluss(vorrang[1]), ['f_A'])
for (const klasse of ['vorrang', 'normal', 'nachrangig']) assert.ok(abfragen.filter((a) => abfrageKlasse(a) === klasse).length <= 5)
})
test('Runden: höchstens 5 Abfragen je Klasse; mailListe wird bei jedem Aufruf gelesen; der Rest folgt beim nächsten Lauf', async () => {
config.termine.mailListe = 2
for (let i = 0; i < 12; i++) await legeAn({ featureId: `f_${i}`, art: 'G02', bookingId: `b_${i}`, naechsterVersuch: plus(-3600000 + i * 1000).toISOString() })
aw.resetListCalls()
assert.deepEqual(await verarbeite(JETZT), { faellig: 10, gesendet: 10 })
assert.deepEqual(abfrageKlassen(), ['vorrang', 'vorrang', 'vorrang', 'vorrang', 'vorrang', 'normal', 'nachrangig'])
assert.ok(listenAbfragen().every((a) => a.queries.find((q) => q.method === 'limit').values[0] === 2))
assert.equal(alle().filter((d) => d.status === 'wartet').length, 2)
assert.deepEqual(await verarbeite(JETZT), { faellig: 2, gesendet: 2 })
})
test('Ausschlussliste: höchstens 90 Funktionen je Abfrage, auch wenn mehr blockiert sind', async () => {
config.termine.mailsProFunktion = 1
for (let i = 0; i < 100; i++) {
for (const teil of ['a', 'b']) {
await legeAn({ featureId: `f_${i}`, art: 'G02', bookingId: `b_${i}${teil}`, naechsterVersuch: plus(-3600000 + (2 * i + (teil === 'a' ? 0 : 1)) * 1000).toISOString() })
}
}
aw.resetListCalls()
assert.deepEqual(await verarbeite(JETZT), { faellig: 210, gesendet: 100 })
const abfragen = listenAbfragen()
const vorrang = abfragen.filter((a) => abfrageKlasse(a) === 'vorrang')
assert.deepEqual(vorrang.map((a) => ausschluss(a)?.length ?? 0), [0, 50, 90], 'Runde 3: 100 blockierte Funktionen, 90 davon in der Liste')
for (const abfrage of abfragen) assert.ok((ausschluss(abfrage)?.length ?? 0) <= 90)
assert.ok(abfragen.filter((a) => abfrageKlasse(a) !== 'vorrang').every((a) => ausschluss(a).length === 90), 'blockierte Funktionen gelten für alle Klassen')
assert.equal(alle().filter((d) => d.status === 'gesendet').length, 100)
})
test('Reserve erreicht: keine Abfrage der nachrangigen Klasse mehr; Vorrang und normal kommen trotzdem dran', async () => {
config.termine.mailsProStunde = 8 // nachrangig höchstens 6
for (let i = 1; i <= 6; i++) await einreihen(von('f_A', 'G05', i), JETZT)
await warteAufVersand()
assert.equal(tr.senke.sent.length, 6)
const rest = await legeAn({ featureId: 'f_A', art: 'G05', bookingId: 'b_rest', naechsterVersuch: plus(-MIN).toISOString() })
await legeAn({ featureId: 'f_A', art: 'G03', bookingId: 'b_normal', naechsterVersuch: plus(-MIN).toISOString() })
aw.resetListCalls()
assert.deepEqual(await verarbeite(plus(MIN)), { faellig: 1, gesendet: 1 })
assert.deepEqual(abfrageKlassen(), ['vorrang', 'normal'], 'die nachrangige Klasse wird gar nicht erst gelesen')
assert.equal((await lies(rest.$id)).status, 'wartet')
assert.equal((await lies(rest.$id)).versuche, 0)
})
test('Reserve mitten im Lauf: nach der Runde, die sie erreicht, folgt keine weitere nachrangige Abfrage', async () => {
config.termine.mailsProStunde = 8 // nachrangig höchstens 6
config.termine.mailListe = 3
for (let i = 0; i < 10; i++) await legeAn({ art: i % 2 ? 'K20' : 'G05', bookingId: `b_${i}`, naechsterVersuch: plus(-3600000 + i * 1000).toISOString() })
aw.resetListCalls()
assert.deepEqual(await verarbeite(JETZT), { faellig: 6, gesendet: 6 })
assert.deepEqual(abfrageKlassen(), ['vorrang', 'normal', 'nachrangig', 'nachrangig'], 'zwei Runden à 3; vor der dritten ist die Reserve erreicht')
assert.equal(alle().filter((d) => d.status === 'wartet').length, 4)
})
// ---------------------------------------------------------------------------------------------------------------------
// 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)
// G02 statt G01: die Wiederholungen reichen ueber 31 Minuten, eine G01 wuerde dann verworfen (siehe „veraltetes G01“)
const doc = await einreihen(eintrag({ art: 'G02' }), 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}`, art: 'G02' })).$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('beendeFestgefahrene: wartet länger als 48 Stunden (createdAt) ⇒ fehler mit Löschtermin in 48 Stunden; Jüngere und andere Status bleiben', async () => {
const alt = await legeAn({ bookingId: 'b_alt', createdAt: plus(-48 * STUNDE - 1).toISOString() })
const altVertagt = await legeAn({ bookingId: 'b_alt2', createdAt: plus(-5 * TAG).toISOString(), naechsterVersuch: plus(20 * MIN).toISOString() })
const grenze = await legeAn({ bookingId: 'b_grenze', createdAt: plus(-48 * STUNDE).toISOString() })
const jung = await legeAn({ bookingId: 'b_jung', createdAt: plus(-2 * STUNDE).toISOString() })
const fertig = await legeAn({ bookingId: 'b_fertig', status: 'gesendet', createdAt: plus(-5 * TAG).toISOString(), naechsterVersuch: plus(3 * TAG).toISOString() })
assert.equal(await beendeFestgefahrene(JETZT), 2)
for (const eintragDoc of [alt, altVertagt]) {
const e = await lies(eintragDoc.$id)
assert.equal(e.status, 'fehler')
assert.equal(ms(e.naechsterVersuch), JETZT.getTime() + 48 * STUNDE, 'Löschtermin = jetzt + 48 Stunden')
assert.ok(e.fehler.length > 0 && e.fehler.length <= 300)
assert.equal(e.versuche, 0)
}
for (const eintragDoc of [grenze, jung]) assert.equal((await lies(eintragDoc.$id)).status, 'wartet')
assert.equal((await lies(fertig.$id)).status, 'gesendet')
assert.deepEqual([...(await zustellFehler(['b_alt', 'b_alt2', 'b_jung']))].sort(), ['b_alt', 'b_alt2'], 'das Portal zeigt den Zustellfehler')
assert.equal(await beendeFestgefahrene(JETZT), 0, 'zweiter Lauf ändert nichts')
assert.equal(await raeumeAuf(JETZT), 0, 'nicht sofort gelöscht')
assert.equal(await raeumeAuf(plus(48 * STUNDE - 1)), 0)
assert.equal(await raeumeAuf(plus(48 * STUNDE)), 2, 'erst 48 Stunden später')
await assert.rejects(() => beendeFestgefahrene(), TypeError)
})
test('beendeFestgefahrene: ein Eintrag, der inzwischen gesendet wurde, bleibt gesendet', async () => {
const doc = await legeAn({ bookingId: 'b_race', createdAt: plus(-3 * TAG).toISOString() })
const echt = globalThis.fetch
let einmal = true
// zwischen Auflisten und Sperre sendet ein anderer Lauf den Eintrag
globalThis.fetch = async (url, optionen) => {
const antwort = await echt(url, optionen)
if (einmal && (optionen?.method ?? 'GET') === 'GET' && String(url).includes('/documents?')) {
einmal = false
await sendeEintrag(doc.$id, JETZT)
}
return antwort
}
try {
assert.equal(await beendeFestgefahrene(JETZT), 0)
} finally {
globalThis.fetch = echt
}
assert.equal(einmal, false, 'der Versand lief zwischen Auflisten und Sperre')
assert.equal((await lies(doc.$id)).status, 'gesendet')
})
test('verwerfeWartende: löscht nur die wartenden Einträge dieser Buchung (auch über mehrere Seiten); gesendete, fehlgeschlagene und fremde bleiben', async () => {
for (let i = 0; i < 30; i++) await legeAn({ bookingId: 'b_weg', art: 'G05' })
const gesendet = await legeAn({ bookingId: 'b_weg', status: 'gesendet', an: '', html: '', text: '' })
const fehler = await legeAn({ bookingId: 'b_weg', status: 'fehler' })
const fremd = await legeAn({ bookingId: 'b_fremd' })
assert.equal(await verwerfeWartende('b_weg'), 30)
assert.deepEqual(alle().map((d) => d.$id).sort(), [gesendet.$id, fehler.$id, fremd.$id].sort())
assert.equal(await verwerfeWartende('b_weg'), 0, 'zweiter Aufruf findet nichts')
assert.equal(await verwerfeWartende('b_gibtsnicht'), 0)
for (const ungueltig of [undefined, null, '', 5]) await assert.rejects(() => verwerfeWartende(ungueltig), TypeError)
})
test('verwerfeWartende: ein Eintrag, der inzwischen gesendet wurde, bleibt (Sperre, neu lesen); ein Eintrag, der inzwischen weg ist, stört nicht', async () => {
const doc = await legeAn({ bookingId: 'b_race' })
const weg = await legeAn({ bookingId: 'b_race' })
const echt = globalThis.fetch
let einmal = true
// zwischen Auflisten und Sperre sendet ein anderer Lauf den ersten Eintrag und ein anderer Lauf loescht den zweiten
globalThis.fetch = async (url, optionen) => {
const antwort = await echt(url, optionen)
if (einmal && (optionen?.method ?? 'GET') === 'GET' && String(url).includes('/documents?')) {
einmal = false
await sendeEintrag(doc.$id, JETZT)
await deleteDocument(col(), weg.$id)
}
return antwort
}
try {
assert.equal(await verwerfeWartende('b_race'), 0)
} finally {
globalThis.fetch = echt
}
assert.equal(einmal, false, 'der Versand lief zwischen Auflisten und Sperre')
assert.equal((await lies(doc.$id)).status, 'gesendet')
assert.equal(await lies(weg.$id), undefined)
})
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'])
})
// ---------------------------------------------------------------------------------------------------------------------
// Veraltete Bestätigungsmail (G01): verworfen statt gesendet
// ---------------------------------------------------------------------------------------------------------------------
const GRUND_VERWORFEN = 'Verworfen: Bestätigungslink abgelaufen'
/** G01 mit Adresse, Text und Kalenderanhang, createdAt = jetzt − alterMs; der Standard ist fällig (naechsterVersuch = jetzt). */
const legeG01An = (alterMs, extra = {}) =>
legeAn({
art: 'G01',
bookingId: 'b_g01',
subject: 'Bitte bestätigen Sie Ihren Termin',
ics: 'BEGIN:VCALENDAR\r\nMETHOD:REQUEST\r\nEND:VCALENDAR\r\n',
icsMethod: 'REQUEST',
createdAt: plus(-alterMs).toISOString(),
...extra,
})
test('veraltetes G01 (älter als holdMinutes + 1 = 31 Minuten): verworfen ohne Transportaufruf, Felder geleert, kein Budgetplatz, kein Zustellfehler, nach 48 Stunden weg', async () => {
const vorher = verworfeneG01()
const doc = await legeG01An(31 * MIN + 1000)
assert.equal(await sendeEintrag(doc.$id, JETZT), 'verworfen')
assert.equal(tr.aufrufe, 0, 'kein Versuch am Mailserver')
assert.equal(tr.senke.sent.length, 0)
const d = await lies(doc.$id)
assert.equal(d.status, 'verworfen')
assert.deepEqual([d.an, d.html, d.text, d.ics], ['', '', '', ''])
assert.equal(d.fehler, GRUND_VERWORFEN)
assert.equal(ms(d.naechsterVersuch), JETZT.getTime() + 48 * STUNDE, 'Löschtermin = jetzt + 48 Stunden')
assert.equal(d.versuche, 0, 'kein Versuch gezählt')
assert.equal(d.gesendetAt || '', '')
assert.equal(d.subject, 'Bitte bestätigen Sie Ihren Termin', 'Betreff und Bezug bleiben für das Aufräumen')
assert.equal(d.bookingId, 'b_g01')
assert.equal(mailBudget(JETZT).stunde.benutzt, 0, 'kein Platz im Stundenbudget belegt')
assert.equal(pruefePlatz(JETZT.getTime(), 'f_feature01', 'G01'), null)
assert.equal(verworfeneG01(), vorher + 1)
// kein endgültig gescheiterter Versand: der Betrieb sieht keinen Zustellfehler
await legeAn({ bookingId: 'b_echt', status: 'fehler', naechsterVersuch: plus(30 * TAG).toISOString() })
assert.deepEqual([...(await zustellFehler(['b_g01']))], [])
assert.deepEqual([...(await zustellFehler(['b_g01', 'b_echt']))], ['b_echt'], 'fehler zählt weiter, verworfen nicht')
// ein zweiter Aufruf fasst den erledigten Eintrag nicht mehr an
assert.equal(await sendeEintrag(doc.$id, JETZT), 'uebersprungen')
assert.equal(verworfeneG01(), vorher + 1)
assert.equal(await raeumeAuf(plus(48 * STUNDE - 1)), 0)
assert.equal(await raeumeAuf(plus(48 * STUNDE)), 1, 'verworfen verschwindet 48 Stunden später (der fehler-Eintrag ist selbst noch nicht fällig)')
assert.equal(await lies(doc.$id), undefined)
assert.ok(alle().some((e) => e.bookingId === 'b_echt'), 'der fehler-Eintrag trägt seinen eigenen Löschtermin')
})
test('G01-Grenze: genau 31 Minuten wird gesendet, 31 Minuten und 1 Millisekunde wird verworfen', async () => {
const genau = await legeG01An(31 * MIN, { bookingId: 'b_genau' })
const knapp = await legeG01An(31 * MIN + 1, { bookingId: 'b_knapp' })
assert.equal(await sendeEintrag(genau.$id, JETZT), 'gesendet')
assert.equal(await sendeEintrag(knapp.$id, JETZT), 'verworfen')
assert.equal(tr.aufrufe, 1)
assert.equal((await lies(genau.$id)).status, 'gesendet')
assert.equal((await lies(knapp.$id)).status, 'verworfen')
})
test('G01 ohne Bestätigungslink (bookingId "-", zu_viele) und ein gleich altes G02 werden gesendet, auch nach Stunden', async () => {
const zuViele = await legeG01An(3 * STUNDE, { bookingId: '-' })
const g02 = await legeAn({ art: 'G02', bookingId: 'b_g02', createdAt: plus(-3 * STUNDE).toISOString() })
const g01Link = await legeG01An(3 * STUNDE, { bookingId: 'b_mit_link' })
assert.equal(await sendeEintrag(zuViele.$id, JETZT), 'gesendet')
assert.equal(await sendeEintrag(g02.$id, JETZT), 'gesendet')
assert.equal(await sendeEintrag(g01Link.$id, JETZT), 'verworfen', 'Gegenprobe: dasselbe Alter mit Link ist verfallen')
assert.equal(tr.aufrufe, 2)
assert.equal(tr.senke.sent.length, 2)
assert.equal(mailBudget(JETZT).stunde.benutzt, 2, 'nur die gesendeten Mails belegen Plätze')
})
test('G01: holdMinutes wird bei jedem Aufruf gelesen (Grenze = holdMinutes + 1 Minute)', async () => {
const zwoelf = await legeG01An(12 * MIN, { bookingId: 'b_12' })
const vierzig = await legeG01An(40 * MIN, { bookingId: 'b_40' })
config.termine.holdMinutes = 10
assert.equal(await sendeEintrag(zwoelf.$id, JETZT), 'verworfen', '12 Minuten > 10 + 1')
config.termine.holdMinutes = 60
assert.equal(await sendeEintrag(vierzig.$id, JETZT), 'gesendet', '40 Minuten <= 60 + 1')
config.termine.holdMinutes = 30
const elf = await legeG01An(11 * MIN, { bookingId: 'b_11' })
config.termine.holdMinutes = 10
assert.equal(await sendeEintrag(elf.$id, JETZT), 'gesendet', '11 Minuten sind noch nicht älter als 10 + 1')
})
test('G01: ein vertagter (noch nicht fälliger) und veralteter Eintrag wird ebenfalls verworfen, ein frisches G01 bleibt unberührt', async () => {
const vertagt = await legeG01An(40 * MIN, { bookingId: 'b_vertagt', naechsterVersuch: plus(20 * MIN).toISOString(), versuche: 3 })
const frisch = await legeG01An(1 * MIN, { bookingId: 'b_frisch', naechsterVersuch: plus(2 * MIN).toISOString() })
assert.equal(await sendeEintrag(vertagt.$id, JETZT), 'verworfen')
assert.equal(await sendeEintrag(frisch.$id, JETZT), 'wartet', 'noch nicht fällig')
assert.equal(tr.aufrufe, 0)
assert.equal((await lies(vertagt.$id)).versuche, 3, 'Versuche bleiben stehen')
assert.equal((await lies(frisch.$id)).status, 'wartet')
})
test('G01, das nach Wiederholungen nach dem Link-Ablauf fällig wird, wird verworfen statt gesendet (4xx-Folge)', async () => {
tr.immer = smtp(451)
const doc = await einreihen(eintrag({ bookingId: 'b_wdh' }), JETZT)
await warteAufVersand()
assert.equal((await lies(doc.$id)).status, 'wartet')
tr.immer = null
const aufrufeVorher = tr.aufrufe
assert.equal(await sendeEintrag(doc.$id, plus(32 * MIN)), 'verworfen')
assert.equal(tr.aufrufe, aufrufeVorher)
assert.equal((await lies(doc.$id)).status, 'verworfen')
})
test('verarbeite: veraltete G01 werden verworfen, bewegen die Liste weiter, und das frische G01 dahinter geht raus', async () => {
config.termine.mailListe = 2
for (let i = 0; i < 3; i++) await legeG01An(2 * STUNDE, { bookingId: `b_alt${i}`, naechsterVersuch: plus(-(10 - i) * MIN).toISOString() })
const frisch = await legeG01An(1 * MIN, { bookingId: 'b_neu' })
const vorher = verworfeneG01()
const lauf = await verarbeite(JETZT)
assert.equal(lauf.gesendet, 1)
assert.equal(tr.aufrufe, 1)
assert.equal((await lies(frisch.$id)).status, 'gesendet')
assert.equal(alle().filter((d) => d.status === 'verworfen').length, 3)
assert.equal(verworfeneG01(), vorher + 3)
})
test('unverbucht: ein gesendetes, nicht verbuchtes G01 wird nach Ablauf des Links nie verworfen und nie doppelt gesendet', async () => {
const doc = await legeG01An(0, { bookingId: 'b_unverbucht' })
const zurueck = patchSchlaegtFehl(2)
try {
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')
const vorher = verworfeneG01()
// 40 Minuten später ist die Mail veraltet, aber sie ging schon raus: nachtragen, nicht verwerfen, nicht erneut senden
assert.equal(await sendeEintrag(doc.$id, plus(40 * MIN)), 'gesendet')
assert.equal(tr.senke.sent.length, 1, 'nicht erneut gesendet')
assert.equal(tr.aufrufe, 1)
const d = await lies(doc.$id)
assert.equal(d.status, 'gesendet')
assert.equal(ms(d.gesendetAt), JETZT.getTime(), 'Zeitpunkt des echten Versands')
assert.equal(d.fehler || '', '')
assert.equal(verworfeneG01(), vorher, 'nichts verworfen')
assert.deepEqual([...(await zustellFehler(['b_unverbucht']))], [])
})
test('unverbucht über verarbeite: der Minutenjob trägt den Versand nach, auch wenn das G01 inzwischen veraltet ist', async () => {
const doc = await legeG01An(0, { bookingId: 'b_unverbucht2' })
const zurueck = patchSchlaegtFehl(2)
try {
assert.equal(await sendeEintrag(doc.$id, JETZT), 'gesendet')
} finally {
zurueck()
}
await verarbeite(plus(45 * MIN))
assert.equal(tr.senke.sent.length, 1)
assert.equal((await lies(doc.$id)).status, 'gesendet')
assert.equal(alle().filter((d) => d.status === 'verworfen').length, 0)
})
test('verworfeneG01: Zähler seit Prozessstart, zählt nur verworfene G01 und wird von resetOutboxForTests auf 0 gesetzt', async () => {
assert.equal(typeof verworfeneG01(), 'number')
const doc = await legeG01An(2 * STUNDE)
await sendeEintrag(doc.$id, JETZT)
const gesendet = await legeAn({ art: 'G05', bookingId: 'b_g05', createdAt: plus(-2 * STUNDE).toISOString() })
await sendeEintrag(gesendet.$id, JETZT)
assert.equal(verworfeneG01(), 1, 'nur das veraltete G01, nicht die gesendete G05')
resetOutboxForTests()
assert.equal(verworfeneG01(), 0)
})
test('verwerfeWartende lässt verworfene Einträge in Ruhe (sie halten keine Gastdaten mehr, raeumeAuf löscht sie)', async () => {
const doc = await legeG01An(2 * STUNDE, { bookingId: 'b_gast' })
await sendeEintrag(doc.$id, JETZT)
assert.equal(await verwerfeWartende('b_gast'), 0)
assert.equal((await lies(doc.$id)).status, 'verworfen')
})