Terminplaner: Mailkontingent je Funktion, Vorrang-Reserve und kleinere Haltungsgrenze

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 09:54:11 +00:00
1 parent 978722023f
commit c35711caad
7 files changed
+330 -42

No files matched your search

+5 -5
View File
@@ -611,19 +611,19 @@ test('die IP kommt vom Proxy: ein selbst gesetzter erster Eintrag in X-Forwarded
assert.equal((await buchen({ kontakt_url: 'x' }, { ip: `192.0.2.99, ${ip}` })).status, 429, 'gezaehlt wird der letzte Eintrag (trust proxy 1)')
})
test('je siteKey 60 gehaltene Termine pro Stunde: danach 429, ohne dass eine Buchung entsteht; Honeypot-Versuche zaehlen nicht mit', async () => {
test('je siteKey 25 gehaltene Termine pro Stunde: danach 429, ohne dass eine Buchung entsteht; Honeypot-Versuche zaehlen nicht mit', async () => {
await speichere()
for (let i = 0; i < 60; i += 1) assert.equal(darfHaltung(SITE_KEY, JETZT), true)
for (let i = 0; i < 25; i += 1) assert.equal(darfHaltung(SITE_KEY, JETZT), true)
const res = await buchen()
assert.equal(res.status, 429)
assert.deepEqual(Object.keys(res.body.fehler), ['_'])
assert.equal(buchungen().length, 0)
resetTermineLimitsForTests()
for (let i = 0; i < 59; i += 1) assert.equal(darfHaltung(SITE_KEY, JETZT), true)
for (let i = 0; i < 24; i += 1) assert.equal(darfHaltung(SITE_KEY, JETZT), true)
for (let i = 0; i < 3; i += 1) assert.equal((await buchen({ kontakt_url: 'x' }, { ip: `198.51.100.${i + 1}` })).status, 201)
assert.equal((await buchen({ zeit: '10:00' })).status, 201, 'die 60. Haltung geht noch')
assert.equal((await buchen({ zeit: '11:00', email: 'anna@example.com' })).status, 429, 'die 61. nicht')
assert.equal((await buchen({ zeit: '10:00' })).status, 201, 'die 25. Haltung geht noch')
assert.equal((await buchen({ zeit: '11:00', email: 'anna@example.com' })).status, 429, 'die 26. nicht')
assert.equal(buchungen().length, 1)
})
+3 -3
View File
@@ -136,7 +136,7 @@ test('darfAnfrage: 120 je IP und Minute (IPv6 je /64); darfAnfrageSite: 600 je s
assert.equal(darfAnfrageSite('abcdefghijklmnop', plus(MINUTE)), true)
})
test('darfBuchungsversuch: 10 je IP und 10 Minuten (IPv6 je /64); darfHaltung: 60 je siteKey und Stunde', () => {
test('darfBuchungsversuch: 10 je IP und 10 Minuten (IPv6 je /64); darfHaltung: 25 je siteKey und Stunde', () => {
assert.equal(Array.from({ length: 12 }, () => darfBuchungsversuch('203.0.113.7', T0)).filter(Boolean).length, 10)
assert.equal(darfBuchungsversuch('203.0.113.7', plus(10 * MINUTE - 1)), false)
assert.equal(darfBuchungsversuch('203.0.113.7', plus(10 * MINUTE)), true)
@@ -148,7 +148,7 @@ test('darfBuchungsversuch: 10 je IP und 10 Minuten (IPv6 je /64); darfHaltung: 6
assert.equal(darfBuchungsversuch('2001:db8:9:9:ffff::1', T0), false, 'elf Versuche aus einer /64')
resetTermineLimitsForTests()
assert.equal(Array.from({ length: 65 }, () => darfHaltung('abcdefghijklmnop', T0)).filter(Boolean).length, 60)
assert.equal(Array.from({ length: 65 }, () => darfHaltung('abcdefghijklmnop', T0)).filter(Boolean).length, 25)
assert.equal(darfHaltung('bcdefghijklmnopq', T0), true)
assert.equal(darfHaltung('abcdefghijklmnop', plus(STUNDE)), true)
})
@@ -177,7 +177,7 @@ test('Speicher: 10 000 und mehr verschiedene IPs verdraengen keinen laufenden Za
// G01 einer Adresse ausgeschoepft, eine Haltungsgrenze angebrochen
for (let i = 0; i < 5; i += 1) assert.equal(darfG01('f1', 'opfer@example.com', T0), true)
assert.equal(darfG01('f1', 'opfer@example.com', T0), false)
for (let i = 0; i < 60; i += 1) assert.equal(darfHaltung('abcdefghijklmnop', T0), true)
for (let i = 0; i < 25; i += 1) assert.equal(darfHaltung('abcdefghijklmnop', T0), true)
assert.equal(darfG08('f1', 'opfer@example.com', T0), true)
// ueber 10 000 verschiedene Quelladressen: alle drei IP-Arten
+209 -1
View File
@@ -24,6 +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
after(() => setMailTransportForTests(null))
const MIN = 60000
@@ -66,6 +67,8 @@ beforeEach(async () => {
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
tr = fakeTransport()
setMailTransportForTests(tr)
// Fehler-/Warnlogs der Warteschlange abfangen (Testausgabe bleibt sauber, Logs sind pruefbar)
@@ -75,6 +78,8 @@ afterEach(async () => {
await warteAufVersand()
mock.restoreAll()
config.termine.mailsProStunde = 200
config.termine.mailsProFunktion = 40
config.termine.mailsReserve = 0.25
config.smtp.from = ABSENDER_VORHER
})
@@ -291,6 +296,209 @@ test('Fehlversuche verbrauchen kein Stundenbudget (auch nicht Anmelde- oder Verb
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 (älteste zuerst)', async () => {
config.termine.mailsProFunktion = 2
for (let i = 0; i < 4; i++) await legeAn({ featureId: 'f_A', art: 'G02', bookingId: `b_a${i}`, createdAt: plus(i * 1000).toISOString() })
for (let i = 0; i < 2; i++) await legeAn({ featureId: 'f_B', art: 'G02', bookingId: `b_b${i}`, createdAt: plus(10000 + 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: 4, gesendet: 0 }, 'das siebte G05 wartet, obwohl noch ein Platz frei ist')
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')
})
// ---------------------------------------------------------------------------------------------------------------------
// Fehlerklassen
// ---------------------------------------------------------------------------------------------------------------------
@@ -467,7 +675,7 @@ test('Stundenbudget gilt auch bei gleichzeitigen Versuchen für verschiedene Ein
config.termine.mailsProStunde = 3
tr.verzoegerungMs = 20
const ids = []
for (let i = 0; i < 6; i++) ids.push((await legeAn({ bookingId: `b_${i}` })).$id)
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)
+2
View File
@@ -47,6 +47,8 @@ test('Konfiguration: Vorgaben ohne Umgebungsvariablen', async () => {
holdMinutes: 30,
zelleMin: 5,
mailsProStunde: 200,
mailsProFunktion: 40,
mailsReserve: 0.25,
loeschDeckel: 2000,
loeschDeckelGesamt: 20000,
zellenDeckel: 5000,