Terminplaner: Mailkontingent je Funktion, Vorrang-Reserve und kleinere Haltungsgrenze
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
978722023f
commit
c35711caad
7 files changed
+330
-42
No files matched your search
@@ -386,6 +386,11 @@ export const config = {
|
|||||||
holdMinutes: 30,
|
holdMinutes: 30,
|
||||||
zelleMin: 5,
|
zelleMin: 5,
|
||||||
mailsProStunde: 200,
|
mailsProStunde: 200,
|
||||||
|
// Mail-Fairness (outbox.js): hoechstens so viele Mails je Funktion und Stunde (gleitendes Fenster, unter mailsProStunde)
|
||||||
|
mailsProFunktion: 40,
|
||||||
|
// Anteil der Stunde, der Vorrang- und Normal-Mails vorbehalten bleibt: nachrangige Mails (G05, K20, K21) nehmen hoechstens
|
||||||
|
// max(1, floor(mailsProStunde x (1 - mailsReserve))) Plaetze
|
||||||
|
mailsReserve: 0.25,
|
||||||
// Hoechstens so viele Buchungen loescht ein Schritt der Aufbewahrungs-Loeschung je Lauf (Tagesjob): je Status, bei erledigt und
|
// Hoechstens so viele Buchungen loescht ein Schritt der Aufbewahrungs-Loeschung je Lauf (Tagesjob): je Status, bei erledigt und
|
||||||
// nichterschienen zusammen je Funktion; der Rest folgt am naechsten Tag
|
// nichterschienen zusammen je Funktion; der Rest folgt am naechsten Tag
|
||||||
loeschDeckel: 2000,
|
loeschDeckel: 2000,
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ const ANFRAGEN_JE_SITE = 600
|
|||||||
/** Buchungsversuche (jedes POST auf /buchungen, auch Honeypot): je IP und 10 Minuten; gehaltene Termine: je siteKey und Stunde. */
|
/** Buchungsversuche (jedes POST auf /buchungen, auch Honeypot): je IP und 10 Minuten; gehaltene Termine: je siteKey und Stunde. */
|
||||||
const BUCHUNGEN_JE_IP = 10
|
const BUCHUNGEN_JE_IP = 10
|
||||||
const BUCHUNGEN_FENSTER_MS = 10 * MINUTE_MS
|
const BUCHUNGEN_FENSTER_MS = 10 * MINUTE_MS
|
||||||
const HALTUNGEN_JE_SITE = 60
|
const HALTUNGEN_JE_SITE = 25
|
||||||
/** Widerrufserklaerungen: je IP und Stunde. */
|
/** Widerrufserklaerungen: je IP und Stunde. */
|
||||||
const WIDERRUFE_JE_IP = 10
|
const WIDERRUFE_JE_IP = 10
|
||||||
/** G01 (Bestaetigungsmail, auch "zu viele"): je Adresse und Funktion und Tag. */
|
/** G01 (Bestaetigungsmail, auch "zu viele"): je Adresse und Funktion und Tag. */
|
||||||
@@ -182,7 +182,10 @@ export function darfBuchungsversuch(ip, now) {
|
|||||||
return zaehle(`ip:buchung:${ipSchluessel(ip)}`, BUCHUNGEN_JE_IP, BUCHUNGEN_FENSTER_MS, now)
|
return zaehle(`ip:buchung:${ipSchluessel(ip)}`, BUCHUNGEN_JE_IP, BUCHUNGEN_FENSTER_MS, now)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Darf diese Funktion (siteKey) jetzt einen weiteren Termin halten? Hoechstens 60 je Stunde. */
|
/**
|
||||||
|
* Darf diese Funktion (siteKey) jetzt einen weiteren Termin halten? Hoechstens 25 je Stunde: so loest eine Funktion hoechstens 25 G01
|
||||||
|
* je Stunde aus, unter dem Mailkontingent je Funktion (config.termine.mailsProFunktion, outbox.js).
|
||||||
|
*/
|
||||||
export function darfHaltung(siteKey, now) {
|
export function darfHaltung(siteKey, now) {
|
||||||
return zaehle(`haltung-site:${siteKey}`, HALTUNGEN_JE_SITE, STUNDE_MS, now)
|
return zaehle(`haltung-site:${siteKey}`, HALTUNGEN_JE_SITE, STUNDE_MS, now)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,9 +24,16 @@ import { pruefeNow, pruefeText, zeitMs } from './util.js'
|
|||||||
* den vorhandenen Index idx_status_next (status, naechsterVersuch); ein eigenes Fehlerdatum gibt es im Schema nicht.
|
* den vorhandenen Index idx_status_next (status, naechsterVersuch); ein eigenes Fehlerdatum gibt es im Schema nicht.
|
||||||
*
|
*
|
||||||
* Stundenbudget (config.termine.mailsProStunde) und Anmeldepause sind Zustand dieses Prozesses: gleitendes Fenster der
|
* Stundenbudget (config.termine.mailsProStunde) und Anmeldepause sind Zustand dieses Prozesses: gleitendes Fenster der
|
||||||
* Sendezeitpunkte bzw. ein Zeitpunkt. Ein Platz wird vor dem Senden belegt (gleichzeitige Versuche koennen das Budget
|
* belegten Plaetze { t, f, n } (Zeit, Funktion, nachrangig) bzw. ein Zeitpunkt. Ein Platz wird vor dem Senden belegt
|
||||||
* nicht ueberziehen) und bei einem Fehlschlag wieder freigegeben. `now` ist immer ein Parameter (feste Uhr in Tests);
|
* (gleichzeitige Versuche koennen das Budget nicht ueberziehen) und bei einem Fehlschlag wieder freigegeben. `now` ist
|
||||||
* ohne Angabe gilt bei einreihen und sendeEintrag die Wanduhr.
|
* immer ein Parameter (feste Uhr in Tests); ohne Angabe gilt bei einreihen und sendeEintrag die Wanduhr.
|
||||||
|
*
|
||||||
|
* Fairness (pruefePlatz, in dieser Reihenfolge; der Eintrag bleibt dann unveraendert `wartet`, kein Versuch):
|
||||||
|
* pause Anmeldepause laeuft
|
||||||
|
* budget Plaetze im Fenster >= mailsProStunde
|
||||||
|
* reserve nachrangige Mails (G05, K20, K21) im Fenster >= max(1, floor(mailsProStunde x (1 - mailsReserve))): so bleibt
|
||||||
|
* ein Teil der Stunde den Vorrang-Mails (G01, G02, G08) und den normalen Mails vorbehalten
|
||||||
|
* kontingent Mails dieser Funktion im Fenster >= mailsProFunktion: eine Funktion kann die Stunde nicht allein fuellen
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const col = () => config.collections.termineOutbox
|
const col = () => config.collections.termineOutbox
|
||||||
@@ -47,35 +54,91 @@ const ID_BLOCK = 50
|
|||||||
|
|
||||||
const iso = (wertMs) => new Date(wertMs).toISOString()
|
const iso = (wertMs) => new Date(wertMs).toISOString()
|
||||||
|
|
||||||
|
/** Mails, die vor allen anderen rausgehen duerfen (Bestaetigung, Termin, Eingangsbestaetigung des Widerrufs). */
|
||||||
|
export const MAIL_VORRANG = Object.freeze(['G01', 'G02', 'G08'])
|
||||||
|
/** Mails, die zuerst warten (Erinnerung, Hinweise an den Betrieb): sie nehmen nie die ganze Stunde. */
|
||||||
|
export const MAIL_NACHRANGIG = Object.freeze(['G05', 'K20', 'K21'])
|
||||||
|
|
||||||
|
/** 'vorrang' | 'normal' | 'nachrangig' nach der Art (Vorlagen-ID) der Mail. */
|
||||||
|
export function mailKlasse(art) {
|
||||||
|
if (MAIL_VORRANG.includes(art)) return 'vorrang'
|
||||||
|
if (MAIL_NACHRANGIG.includes(art)) return 'nachrangig'
|
||||||
|
return 'normal'
|
||||||
|
}
|
||||||
|
|
||||||
// Zustand des Prozesses
|
// Zustand des Prozesses
|
||||||
let pauseBisMs = 0
|
let pauseBisMs = 0
|
||||||
/** Zeitpunkte (ms) der Sendeversuche im letzten Stundenfenster (belegte Plaetze). */
|
/** Belegte Plaetze im letzten Stundenfenster: { t: Sendezeit (ms), f: featureId, n: nachrangig }. */
|
||||||
let sendeZeiten = []
|
let plaetze = []
|
||||||
/** Laufende sendeEintrag-Aufrufe (fuer warteAufVersand). */
|
/** Laufende sendeEintrag-Aufrufe (fuer warteAufVersand). */
|
||||||
const laufend = new Set()
|
const laufend = new Set()
|
||||||
/** Eintraege, die schon rausgingen, deren Ergebnis aber nicht gespeichert werden konnte: id => Versandzeitpunkt (ms). */
|
/** Eintraege, die schon rausgingen, deren Ergebnis aber nicht gespeichert werden konnte: id => Versandzeitpunkt (ms). */
|
||||||
const unverbucht = new Map()
|
const unverbucht = new Map()
|
||||||
|
|
||||||
/** Anzahl freier Plaetze im Stundenbudget zum Zeitpunkt nowMs; raeumt abgelaufene Plaetze aus dem Fenster. */
|
/** Die Plaetze, die zum Zeitpunkt nowMs noch im Stundenfenster stehen (nur lesen; ein Platz laeuft 60 Minuten nach dem Versand ab). */
|
||||||
function freieBudgetPlaetze(nowMs) {
|
const imFenster = (nowMs) => plaetze.filter((platz) => platz.t > nowMs - STUNDE_MS)
|
||||||
const grenze = nowMs - STUNDE_MS
|
|
||||||
sendeZeiten = sendeZeiten.filter((zeit) => zeit > grenze)
|
/** Hoechstens so viele nachrangige Mails (G05, K20, K21) im Stundenfenster; die Toleranz faengt Fliesskommafehler wie 100 x 0.58 ab. */
|
||||||
return Math.max(0, config.termine.mailsProStunde - sendeZeiten.length)
|
const nachrangigMax = () => Math.max(1, Math.floor(config.termine.mailsProStunde * (1 - config.termine.mailsReserve) + 1e-9))
|
||||||
|
|
||||||
|
/** 'pause' | 'budget' | null: die Bremsen, die fuer jede Mail gelten (nur lesen). */
|
||||||
|
function gesamtBremse(nowMs) {
|
||||||
|
if (pauseBisMs > nowMs) return 'pause'
|
||||||
|
if (imFenster(nowMs).length >= config.termine.mailsProStunde) return 'budget'
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pause oder ausgeschoepftes Budget (nur lesen). */
|
/**
|
||||||
const gebremst = (nowMs) => pauseBisMs > nowMs || freieBudgetPlaetze(nowMs) === 0
|
* Darf eine Mail dieser Funktion und Art zum Zeitpunkt nowMs raus? null = ja, sonst der erste Grund (siehe Kopfkommentar):
|
||||||
|
* 'pause' | 'budget' | 'reserve' | 'kontingent'. Nur lesen: belegt keinen Platz.
|
||||||
/** Belegt einen Platz im Budget (synchron, ohne Zwischenschritt); false bei Pause oder leerem Budget. */
|
* @param {number} nowMs
|
||||||
function belegePlatz(nowMs) {
|
* @param {string} featureId
|
||||||
if (gebremst(nowMs)) return false
|
* @param {string} art Vorlagen-ID der Mail ('G01' ... 'K21')
|
||||||
sendeZeiten.push(nowMs)
|
* @returns {null|'pause'|'budget'|'reserve'|'kontingent'}
|
||||||
return true
|
*/
|
||||||
|
export function pruefePlatz(nowMs, featureId, art) {
|
||||||
|
const bremse = gesamtBremse(nowMs)
|
||||||
|
if (bremse) return bremse
|
||||||
|
const fenster = imFenster(nowMs)
|
||||||
|
if (mailKlasse(art) === 'nachrangig' && fenster.filter((platz) => platz.n).length >= nachrangigMax()) return 'reserve'
|
||||||
|
if (fenster.filter((platz) => platz.f === featureId).length >= config.termine.mailsProFunktion) return 'kontingent'
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function gibPlatzFrei(nowMs) {
|
/** Belegt einen Platz (synchron, ohne Zwischenschritt); der Platz oder null, wenn pruefePlatz einen Grund nennt. */
|
||||||
const stelle = sendeZeiten.indexOf(nowMs)
|
function belegePlatz(nowMs, featureId, art) {
|
||||||
if (stelle >= 0) sendeZeiten.splice(stelle, 1)
|
if (pruefePlatz(nowMs, featureId, art) !== null) return null
|
||||||
|
const platz = { t: nowMs, f: featureId, n: mailKlasse(art) === 'nachrangig' }
|
||||||
|
plaetze = [...imFenster(nowMs), platz]
|
||||||
|
return platz
|
||||||
|
}
|
||||||
|
|
||||||
|
function gibPlatzFrei(platz) {
|
||||||
|
plaetze = plaetze.filter((belegt) => belegt !== platz)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stand des Mailbudgets zum Zeitpunkt now (fuer die Betriebspruefung und die Statusroute): nur Zahlen und Funktions-IDs,
|
||||||
|
* nie eine Adresse. `spitzen` sind die bis zu drei Funktionen mit den meisten Mails im Fenster, die groesste zuerst.
|
||||||
|
* @param {Date} now
|
||||||
|
* @returns {{ stunde: { benutzt: number, max: number }, nachrangig: { benutzt: number, max: number }, jeFunktionMax: number,
|
||||||
|
* spitzen: Array<{ featureId: string, benutzt: number }> }}
|
||||||
|
*/
|
||||||
|
export function mailBudget(now) {
|
||||||
|
const nowMs = pruefeNow(now, 'mailBudget')
|
||||||
|
const fenster = imFenster(nowMs)
|
||||||
|
const jeFunktion = new Map()
|
||||||
|
for (const platz of fenster) jeFunktion.set(platz.f, (jeFunktion.get(platz.f) ?? 0) + 1)
|
||||||
|
const spitzen = [...jeFunktion]
|
||||||
|
.map(([featureId, benutzt]) => ({ featureId, benutzt }))
|
||||||
|
.sort((a, b) => b.benutzt - a.benutzt || (a.featureId < b.featureId ? -1 : 1))
|
||||||
|
.slice(0, 3)
|
||||||
|
return {
|
||||||
|
stunde: { benutzt: fenster.length, max: config.termine.mailsProStunde },
|
||||||
|
nachrangig: { benutzt: fenster.filter((platz) => platz.n).length, max: nachrangigMax() },
|
||||||
|
jeFunktionMax: config.termine.mailsProFunktion,
|
||||||
|
spitzen,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 'dauerhaft' | 'anmeldung' | 'vorlaeufig' (siehe Kopfkommentar). */
|
/** 'dauerhaft' | 'anmeldung' | 'vorlaeufig' (siehe Kopfkommentar). */
|
||||||
@@ -145,12 +208,13 @@ export async function einreihen(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Einziger Sendepfad. Macht hoechstens einen Sendeversuch fuer den Eintrag, wenn er `wartet`, faellig ist, keine Pause
|
* Einziger Sendepfad. Macht hoechstens einen Sendeversuch fuer den Eintrag, wenn er `wartet`, faellig ist, keine Pause
|
||||||
* laeuft und das Stundenbudget reicht.
|
* laeuft und Stundenbudget, Vorrang-Reserve und Kontingent der Funktion reichen (pruefePlatz).
|
||||||
* @param {string} id
|
* @param {string} id
|
||||||
* @param {Date} [now]
|
* @param {Date} [now]
|
||||||
* @returns {Promise<'gesendet'|'wartet'|'fehler'|'uebersprungen'>} gesendet = dieser Aufruf hat die Mail gesendet;
|
* @returns {Promise<'gesendet'|'wartet'|'fehler'|'uebersprungen'>} gesendet = dieser Aufruf hat die Mail gesendet;
|
||||||
* wartet = Eintrag bleibt in der Warteschlange (Pause, Budget, noch nicht faellig, vorlaeufiger Fehler); fehler = dieser
|
* wartet = Eintrag bleibt in der Warteschlange (Pause, Budget, Reserve, Kontingent, noch nicht faellig, vorlaeufiger
|
||||||
* Aufruf hat ihn endgueltig scheitern lassen; uebersprungen = Eintrag fehlt oder ist schon gesendet/fehlgeschlagen
|
* Fehler); fehler = dieser Aufruf hat ihn endgueltig scheitern lassen; uebersprungen = Eintrag fehlt oder ist schon
|
||||||
|
* gesendet/fehlgeschlagen
|
||||||
*/
|
*/
|
||||||
export function sendeEintrag(id, now = new Date()) {
|
export function sendeEintrag(id, now = new Date()) {
|
||||||
const lauf = sende(id, now).finally(() => laufend.delete(lauf))
|
const lauf = sende(id, now).finally(() => laufend.delete(lauf))
|
||||||
@@ -161,7 +225,7 @@ export function sendeEintrag(id, now = new Date()) {
|
|||||||
async function sende(id, now) {
|
async function sende(id, now) {
|
||||||
const nowMs = pruefeNow(now, 'sendeEintrag')
|
const nowMs = pruefeNow(now, 'sendeEintrag')
|
||||||
pruefeText(id, 'id')
|
pruefeText(id, 'id')
|
||||||
if (gebremst(nowMs)) return 'wartet'
|
if (gesamtBremse(nowMs)) return 'wartet'
|
||||||
|
|
||||||
return withLocks([`termine-mail:${id}`], async () => {
|
return withLocks([`termine-mail:${id}`], async () => {
|
||||||
let doc
|
let doc
|
||||||
@@ -180,8 +244,10 @@ async function sende(id, now) {
|
|||||||
return 'gesendet'
|
return 'gesendet'
|
||||||
}
|
}
|
||||||
if (new Date(doc.naechsterVersuch).getTime() > nowMs) return 'wartet'
|
if (new Date(doc.naechsterVersuch).getTime() > nowMs) return 'wartet'
|
||||||
// Der Platz wird ohne Zwischenschritt vor dem Senden belegt: gleichzeitige Versuche koennen das Budget nicht ueberziehen
|
// Der Platz wird ohne Zwischenschritt vor dem Senden belegt: gleichzeitige Versuche koennen Budget und Kontingent nicht
|
||||||
if (!belegePlatz(nowMs)) return 'wartet'
|
// ueberziehen. Ein gebremster Eintrag bleibt unveraendert `wartet` (kein Versuch).
|
||||||
|
const platz = belegePlatz(nowMs, doc.featureId, doc.art)
|
||||||
|
if (!platz) return 'wartet'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendGuestMail({
|
await sendGuestMail({
|
||||||
@@ -195,7 +261,7 @@ async function sende(id, now) {
|
|||||||
icsMethod: doc.icsMethod || 'REQUEST',
|
icsMethod: doc.icsMethod || 'REQUEST',
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
gibPlatzFrei(nowMs)
|
gibPlatzFrei(platz)
|
||||||
return speichereFehlschlag(doc, err, nowMs)
|
return speichereFehlschlag(doc, err, nowMs)
|
||||||
}
|
}
|
||||||
await speichereGesendet(doc, nowMs)
|
await speichereGesendet(doc, nowMs)
|
||||||
@@ -252,7 +318,8 @@ async function speichereFehlschlag(doc, err, nowMs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minutenjob: sendet alle faelligen wartenden Eintraege, aelteste zuerst, bis Pause oder Stundenbudget bremsen.
|
* Minutenjob: sendet alle faelligen wartenden Eintraege, aelteste zuerst, bis Pause oder Stundenbudget bremsen; Eintraege, die
|
||||||
|
* Vorrang-Reserve oder Kontingent ihrer Funktion bremsen, bleiben `wartet` und die uebrigen kommen trotzdem dran.
|
||||||
* Fehler bei einzelnen Eintraegen (z. B. Appwrite) stehen im Log und stoppen den Lauf nicht.
|
* Fehler bei einzelnen Eintraegen (z. B. Appwrite) stehen im Log und stoppen den Lauf nicht.
|
||||||
* @param {Date} now
|
* @param {Date} now
|
||||||
* @returns {Promise<{ faellig: number, gesendet: number }>}
|
* @returns {Promise<{ faellig: number, gesendet: number }>}
|
||||||
@@ -264,7 +331,10 @@ export async function verarbeite(now) {
|
|||||||
faellig.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt) || (a.$id < b.$id ? -1 : 1))
|
faellig.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt) || (a.$id < b.$id ? -1 : 1))
|
||||||
let gesendet = 0
|
let gesendet = 0
|
||||||
for (const eintrag of faellig) {
|
for (const eintrag of faellig) {
|
||||||
if (gebremst(nowMs)) break
|
// Pause und leeres Gesamtbudget beenden den Lauf; Reserve und Kontingent betreffen nur diesen Eintrag (ohne Lesen und Sperre)
|
||||||
|
const grund = pruefePlatz(nowMs, eintrag.featureId, eintrag.art)
|
||||||
|
if (grund === 'pause' || grund === 'budget') break
|
||||||
|
if (grund) continue
|
||||||
try {
|
try {
|
||||||
if ((await sendeEintrag(eintrag.$id, now)) === 'gesendet') gesendet += 1
|
if ((await sendeEintrag(eintrag.$id, now)) === 'gesendet') gesendet += 1
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -395,6 +465,6 @@ export function pausiertBis() {
|
|||||||
/** Nur fuer Tests: Pause, Budgetfenster und nicht gespeicherte Versände zuruecksetzen. */
|
/** Nur fuer Tests: Pause, Budgetfenster und nicht gespeicherte Versände zuruecksetzen. */
|
||||||
export function resetOutboxForTests() {
|
export function resetOutboxForTests() {
|
||||||
pauseBisMs = 0
|
pauseBisMs = 0
|
||||||
sendeZeiten = []
|
plaetze = []
|
||||||
unverbucht.clear()
|
unverbucht.clear()
|
||||||
}
|
}
|
||||||
@@ -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)')
|
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()
|
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()
|
const res = await buchen()
|
||||||
assert.equal(res.status, 429)
|
assert.equal(res.status, 429)
|
||||||
assert.deepEqual(Object.keys(res.body.fehler), ['_'])
|
assert.deepEqual(Object.keys(res.body.fehler), ['_'])
|
||||||
assert.equal(buchungen().length, 0)
|
assert.equal(buchungen().length, 0)
|
||||||
|
|
||||||
resetTermineLimitsForTests()
|
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)
|
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: '10:00' })).status, 201, 'die 25. Haltung geht noch')
|
||||||
assert.equal((await buchen({ zeit: '11:00', email: 'anna@example.com' })).status, 429, 'die 61. nicht')
|
assert.equal((await buchen({ zeit: '11:00', email: 'anna@example.com' })).status, 429, 'die 26. nicht')
|
||||||
assert.equal(buchungen().length, 1)
|
assert.equal(buchungen().length, 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
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(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 - 1)), false)
|
||||||
assert.equal(darfBuchungsversuch('203.0.113.7', plus(10 * MINUTE)), true)
|
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')
|
assert.equal(darfBuchungsversuch('2001:db8:9:9:ffff::1', T0), false, 'elf Versuche aus einer /64')
|
||||||
|
|
||||||
resetTermineLimitsForTests()
|
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('bcdefghijklmnopq', T0), true)
|
||||||
assert.equal(darfHaltung('abcdefghijklmnop', plus(STUNDE)), 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
|
// G01 einer Adresse ausgeschoepft, eine Haltungsgrenze angebrochen
|
||||||
for (let i = 0; i < 5; i += 1) assert.equal(darfG01('f1', 'opfer@example.com', T0), true)
|
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)
|
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)
|
assert.equal(darfG08('f1', 'opfer@example.com', T0), true)
|
||||||
|
|
||||||
// ueber 10 000 verschiedene Quelladressen: alle drei IP-Arten
|
// ueber 10 000 verschiedene Quelladressen: alle drei IP-Arten
|
||||||
|
|||||||
+209
-1
@@ -24,6 +24,7 @@ const { loadTemplate } = await import('../server/services/mailTemplates.js')
|
|||||||
const { icsTermin } = await import('../server/services/termine/ics.js')
|
const { icsTermin } = await import('../server/services/termine/ics.js')
|
||||||
const O = await import('../server/services/termine/outbox.js')
|
const O = await import('../server/services/termine/outbox.js')
|
||||||
const { einreihen, sendeEintrag, verarbeite, warteAufVersand, zustellFehler, raeumeAuf, beendeFestgefahrene, verwerfeWartende, pausiertBis, resetOutboxForTests } = O
|
const { einreihen, sendeEintrag, verarbeite, warteAufVersand, zustellFehler, raeumeAuf, beendeFestgefahrene, verwerfeWartende, pausiertBis, resetOutboxForTests } = O
|
||||||
|
const { MAIL_VORRANG, MAIL_NACHRANGIG, mailKlasse, pruefePlatz, mailBudget } = O
|
||||||
after(() => setMailTransportForTests(null))
|
after(() => setMailTransportForTests(null))
|
||||||
|
|
||||||
const MIN = 60000
|
const MIN = 60000
|
||||||
@@ -66,6 +67,8 @@ beforeEach(async () => {
|
|||||||
for (const id of aw.docs('termineOutbox').keys()) await deleteDocument(col(), id)
|
for (const id of aw.docs('termineOutbox').keys()) await deleteDocument(col(), id)
|
||||||
resetOutboxForTests()
|
resetOutboxForTests()
|
||||||
config.termine.mailsProStunde = 200
|
config.termine.mailsProStunde = 200
|
||||||
|
config.termine.mailsProFunktion = 40
|
||||||
|
config.termine.mailsReserve = 0.25
|
||||||
tr = fakeTransport()
|
tr = fakeTransport()
|
||||||
setMailTransportForTests(tr)
|
setMailTransportForTests(tr)
|
||||||
// Fehler-/Warnlogs der Warteschlange abfangen (Testausgabe bleibt sauber, Logs sind pruefbar)
|
// Fehler-/Warnlogs der Warteschlange abfangen (Testausgabe bleibt sauber, Logs sind pruefbar)
|
||||||
@@ -75,6 +78,8 @@ afterEach(async () => {
|
|||||||
await warteAufVersand()
|
await warteAufVersand()
|
||||||
mock.restoreAll()
|
mock.restoreAll()
|
||||||
config.termine.mailsProStunde = 200
|
config.termine.mailsProStunde = 200
|
||||||
|
config.termine.mailsProFunktion = 40
|
||||||
|
config.termine.mailsReserve = 0.25
|
||||||
config.smtp.from = ABSENDER_VORHER
|
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')
|
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
|
// Fehlerklassen
|
||||||
// ---------------------------------------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------------------------------------
|
||||||
@@ -467,7 +675,7 @@ test('Stundenbudget gilt auch bei gleichzeitigen Versuchen für verschiedene Ein
|
|||||||
config.termine.mailsProStunde = 3
|
config.termine.mailsProStunde = 3
|
||||||
tr.verzoegerungMs = 20
|
tr.verzoegerungMs = 20
|
||||||
const ids = []
|
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)))
|
await Promise.all(ids.map((id) => sendeEintrag(id, JETZT)))
|
||||||
assert.equal(tr.aufrufe, 3)
|
assert.equal(tr.aufrufe, 3)
|
||||||
assert.equal(alle().filter((d) => d.status === 'gesendet').length, 3)
|
assert.equal(alle().filter((d) => d.status === 'gesendet').length, 3)
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ test('Konfiguration: Vorgaben ohne Umgebungsvariablen', async () => {
|
|||||||
holdMinutes: 30,
|
holdMinutes: 30,
|
||||||
zelleMin: 5,
|
zelleMin: 5,
|
||||||
mailsProStunde: 200,
|
mailsProStunde: 200,
|
||||||
|
mailsProFunktion: 40,
|
||||||
|
mailsReserve: 0.25,
|
||||||
loeschDeckel: 2000,
|
loeschDeckel: 2000,
|
||||||
loeschDeckelGesamt: 20000,
|
loeschDeckelGesamt: 20000,
|
||||||
zellenDeckel: 5000,
|
zellenDeckel: 5000,
|
||||||
|
|||||||
Reference in new issue
Block a user