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>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 10:21:25 +00:00
1 parent 36b94c3035
commit 43b3caddde
4 files changed
+268 -18

No files matched your search

+184 -3
View File
@@ -24,7 +24,7 @@ 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 } = O
const { MAIL_VORRANG, MAIL_NACHRANGIG, mailKlasse, pruefePlatz, mailBudget, verworfeneG01 } = O
after(() => setMailTransportForTests(null))
const MIN = 60000
@@ -70,6 +70,7 @@ beforeEach(async () => {
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)
@@ -82,6 +83,7 @@ afterEach(async () => {
config.termine.mailsProFunktion = 40
config.termine.mailsReserve = 0.25
config.termine.mailListe = 100
config.termine.holdMinutes = 30
config.smtp.from = ABSENDER_VORHER
})
@@ -253,7 +255,8 @@ test('verarbeite liest mit eigenem limit mehr als die 25 der Standardseite (30 f
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)
// 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()
@@ -677,7 +680,8 @@ test('Reserve mitten im Lauf: nach der Runde, die sie erreicht, folgt keine weit
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)
// 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)
@@ -1049,3 +1053,180 @@ test('zustellFehler: auch eine gescheiterte Mail an den Betrieb (K20) zählt; er
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')
})