Auf der echten Appwrite 1.8.1 lehnt notEqual mit Liste die Abfrage ab (HTTP 400
general_query_invalid, "NotEqual queries require exactly one value"). Die Klasse normal der
Mail-Warteschlange schickte immer eine Liste, jeder Lauf warf dort, und die nachrangige Klasse
(G05, K20, K21) kam nie dran.
- appwriteAdmin: Query.notEqual nimmt genau einen Wert (eine Liste wirft einen TypeError, der
keinerVon nennt); neu keinerVon(attribute, werte) = je Wert ein notEqual (Appwrite schneidet
sie); dokumenteUrl() baut die Listen-URL ohne Netz, mit derselben Serialisierung wie die Anfrage.
- outbox: klassenFilter liefert fuer jede Klasse eine Liste von Abfragen (normal = sechs notEqual
auf art), die Ausschlussliste ist keinerVon('featureId', ...). AUSSCHLUSS_MAX 90 -> 30 (der nginx
antwortet in dieser Abfrageform ab 46 Funktions-IDs mit 414); der Kommentar "100 Werte je Abfrage"
entfaellt.
- Nachbau (appwriteMock): notEqual mit Liste oder ohne Wert => 400 wie Appwrite; ein Dokument mit
Feld null oder ohne Feld passt nicht zu notEqual (SQL-Semantik).
- Tests: Query-Helfer, keinerVon, dokumenteUrl, Nachbau (400, null), ein Lauf bedient alle drei
Klassen, Ausschlussliste hoechstens 30 und URL unter 6000 Zeichen im schlimmsten Fall.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1281 lines
67 KiB
JavaScript
1281 lines
67 KiB
JavaScript
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, dokumenteUrl } = 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, AUSSCHLUSS_MAX, 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')
|
||
// Appwrite 1.8.1: notEqual nimmt genau einen Wert, mehrere notEqual werden geschnitten. Die normale Klasse sind darum sechs
|
||
// notEqual auf art, die Ausschlussliste je Funktion eine notEqual auf featureId.
|
||
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 }) => {
|
||
const ids = queries.filter((q) => q.method === 'notEqual' && q.attribute === 'featureId').map((q) => q.values[0])
|
||
return ids.length ? ids : null
|
||
}
|
||
const begrenzteLeseabfragen = (abfragen) => {
|
||
for (const abfrage of abfragen) {
|
||
assert.ok(abfrage.queries.filter((q) => q.method === 'notEqual').every((q) => q.values.length === 1), `notEqual mit genau einem Wert (Appwrite 1.8.1): ${JSON.stringify(abfrage.queries)}`)
|
||
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)
|
||
}
|
||
})
|
||
|
||
// G5 (2026-10-05): auf der echten Appwrite 1.8.1 lehnt notEqual mit Liste die Abfrage ab (HTTP 400). Die normale Klasse warf dann bei jedem Lauf,
|
||
// die nachrangige (G05, K20, K21) kam nie dran. Der Nachbau lehnt es jetzt genauso ab.
|
||
test('Live-Appwrite: ein Lauf mit fälligen Einträgen aller drei Klassen bedient Vorrang, normal UND nachrangig (G05, K20, K21)', async () => {
|
||
const arten = ['G02', 'G08', 'G03', 'K17', 'G05', 'K20', 'K21']
|
||
const docs = []
|
||
for (const [i, art] of arten.entries()) {
|
||
docs.push(await legeAn({ art, bookingId: `b_${art}`, an: `${art.toLowerCase()}@example.com`, naechsterVersuch: plus(-(10 - i) * MIN).toISOString() }))
|
||
}
|
||
aw.resetListCalls()
|
||
const ergebnis = await verarbeite(JETZT)
|
||
assert.deepEqual(ergebnis, { faellig: 7, gesendet: 7 })
|
||
for (const doc of docs) assert.equal((await lies(doc.$id)).status, 'gesendet', doc.art)
|
||
assert.deepEqual(empfaenger(), ['g02@example.com', 'g08@example.com', 'g03@example.com', 'k17@example.com', 'g05@example.com', 'k20@example.com', 'k21@example.com'])
|
||
assert.deepEqual(abfrageKlassen(), ['vorrang', 'normal', 'nachrangig'])
|
||
begrenzteLeseabfragen(listenAbfragen())
|
||
// die normale Klasse: sechs einzelne notEqual auf art, zusammen alle Arten der anderen Klassen
|
||
const normal = listenAbfragen().find((a) => abfrageKlasse(a) === 'normal')
|
||
const ausgeschlossen = normal.queries.filter((q) => q.method === 'notEqual' && q.attribute === 'art')
|
||
assert.ok(ausgeschlossen.every((q) => q.values.length === 1))
|
||
assert.deepEqual(ausgeschlossen.map((q) => q.values[0]).sort(), [...MAIL_VORRANG, ...MAIL_NACHRANGIG].sort())
|
||
// vorrang und nachrangig: equal mit Liste ist bei Appwrite erlaubt
|
||
const nachrangig = listenAbfragen().find((a) => abfrageKlasse(a) === 'nachrangig')
|
||
assert.deepEqual(nachrangig.queries.find((q) => q.attribute === 'art'), { method: 'equal', attribute: 'art', values: [...MAIL_NACHRANGIG] })
|
||
})
|
||
|
||
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 })
|
||
})
|
||
|
||
// Appwrite 1.8.1 (M4): ab etwa 45 featureId-notEqual in dieser Abfrageform antwortet der nginx mit 414 (URL zu lang); 30 lassen Luft
|
||
test('Ausschlussliste: höchstens AUSSCHLUSS_MAX (30) Funktionen je Abfrage, auch wenn mehr blockiert sind; die URL bleibt unter 6000 Zeichen', async () => {
|
||
assert.equal(AUSSCHLUSS_MAX, 30)
|
||
config.termine.mailsProFunktion = 1
|
||
// 36 Zeichen lange Funktions-IDs: so lang sind die Dokument-IDs in Produktion
|
||
const feature = (i) => `f-${String(i).padStart(34, '0')}`
|
||
assert.equal(feature(7).length, 36)
|
||
for (let i = 0; i < 100; i++) {
|
||
for (const teil of ['a', 'b']) {
|
||
await legeAn({ featureId: feature(i), art: 'G02', bookingId: `b_${i}${teil}`, naechsterVersuch: plus(-3600000 + (2 * i + (teil === 'a' ? 0 : 1)) * 1000).toISOString() })
|
||
}
|
||
}
|
||
aw.resetListCalls()
|
||
// Runde 1: Seite 1 (f0 bis f49) sendet 50, blockiert 50. Runde 2 (die ersten 30 ausgeschlossen): f30b bis f49b sind wieder dabei, dahinter
|
||
// f50 bis f89 (40 gesendet). Runde 3: 60 + 20 Eintraege, die letzten 10 gehen raus. Die Seite ist dann nicht mehr voll.
|
||
assert.deepEqual(await verarbeite(JETZT), { faellig: 280, gesendet: 100 })
|
||
const abfragen = listenAbfragen()
|
||
begrenzteLeseabfragen(abfragen)
|
||
const vorrang = abfragen.filter((a) => abfrageKlasse(a) === 'vorrang')
|
||
assert.deepEqual(vorrang.map((a) => ausschluss(a)?.length ?? 0), [0, 30, 30], '100 Funktionen blockiert, aber nur 30 in der Liste')
|
||
assert.deepEqual(ausschluss(vorrang[1]), Array.from({ length: 30 }, (_, i) => feature(i)), 'die zuerst blockierten')
|
||
for (const abfrage of abfragen) assert.ok((ausschluss(abfrage)?.length ?? 0) <= AUSSCHLUSS_MAX)
|
||
assert.ok(abfragen.filter((a) => abfrageKlasse(a) !== 'vorrang').every((a) => ausschluss(a).length === AUSSCHLUSS_MAX), 'blockierte Funktionen gelten für alle Klassen')
|
||
assert.equal(alle().filter((d) => d.status === 'gesendet').length, 100)
|
||
|
||
// Schlimmster Fall: die normale Klasse (6 notEqual auf art) mit voller Ausschlussliste. Dieselbe Serialisierung wie die Anfrage an Appwrite.
|
||
const normal = abfragen.find((a) => abfrageKlasse(a) === 'normal')
|
||
assert.equal(normal.queries.filter((q) => q.method === 'notEqual' && q.attribute === 'art').length, 6)
|
||
assert.equal(normal.queries.filter((q) => q.method === 'notEqual' && q.attribute === 'featureId').length, 30)
|
||
const laengen = abfragen.map((a) => dokumenteUrl(col(), a.queries).length)
|
||
assert.ok(Math.max(...laengen) < 6000, `längste Abfrage-URL ${Math.max(...laengen)} Zeichen`)
|
||
assert.equal(laengen[abfragen.indexOf(normal)], Math.max(...laengen), 'die normale Klasse ist der längste Fall')
|
||
})
|
||
|
||
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')
|
||
})
|