Files
Webklar-Kundenbereich/test/termineBetrieb.test.js
T
ClaudeandClaude Sonnet 5.5 4601c5ef3b Terminplaner: Tagesjob-Meldung, Betriebe-Zaehlung und Bericht
meldeTagesjob meldet am Ende des Tagesjobs ueber den Melder aus A10a2 die Vorfaelle
tagesjob (Fehler oder Abbruch), deckel (Loeschdeckel an 3 Tagesjobs in Folge) und
betriebe (ab 20 aktiven Betrieben, D8) und merkt letzterTagesjob (nur Zaehlungen).
taeglicherJob liefert geloescht und aktiveBetriebe, tagesbericht haengt
"n aktive Betriebe" an.

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

661 lines
33 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'
// Betriebspruefung des Terminplaners (Budget, Warteschlange, Anmeldepause) und Tagesjob-Meldung (Fehler, Loeschdeckel, Zahl der
// aktiven Betriebe) mit Abkuehlzeit fuer die Team-Mail (T20):
// Nachbau von Appwrite mit Schema, Mails landen in der Senke (ueber einen Fake, der Sendeversuche zaehlt und gezielt
// scheitert), alle Zeiten sind feste Parameter.
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
termineConfig: schemaKeys('termineConfig'),
termineBookings: schemaKeys('termineBookings'),
termineClaims: schemaKeys('termineClaims'),
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 { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { loadTemplate } = await import('../server/services/mailTemplates.js')
const { einreihen, warteAufVersand, warteschlangeAlter, pausiertBis, resetOutboxForTests } = await import('../server/services/termine/outbox.js')
const { pruefeBetrieb, meldeVorfall, meldeTagesjob, letzterTagesjob, zaehleAktiveBetriebe, resetBetriebForTests } = await import('../server/services/termine/betrieb.js')
const { minutenJob, resetJobsForTests } = await import('../server/services/termine/jobs.js')
after(() => setMailTransportForTests(null))
const MIN = 60000
const STUNDE = 60 * MIN
const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin
const plus = (ms, von = JETZT) => new Date(von.getTime() + ms)
const iso = (wert) => new Date(wert).toISOString()
const col = () => config.collections.termineOutbox
const TEAM = ['kenso@example.test', 'andrej@example.test']
const EAUTH = Object.assign(new Error('Invalid login: 535 Authentication failed'), { code: 'EAUTH', responseCode: 535 })
const ist20 = (mail) => /^\[Terminplaner\]/.test(mail.subject)
/** Transport: zaehlt Sendeversuche (gesamt und fuer die Team-Mail T20), wirft die Fehler aus `fehler` der Reihe nach, sonst entscheidet `wirf(mail)`. */
function fakeTransport() {
const senke = mailSink()
const transport = {
senke,
aufrufe: 0,
teamVersuche: 0,
fehler: [],
wirf: null,
async sendMail(mail) {
transport.aufrufe += 1
if (ist20(mail)) transport.teamVersuche += 1
const fehler = transport.fehler.shift() ?? transport.wirf?.(mail)
if (fehler) throw fehler
return senke.sendMail(mail)
},
}
return transport
}
const VORHER = {
modus: config.termine.mode,
empfaenger: config.chatNotify.emails,
absender: config.smtp.from,
}
let tr
let log
beforeEach(async () => {
for (const collection of ['termineOutbox', 'termineBookings', 'termineClaims', 'portalFeatures', 'termineConfig']) {
for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id)
}
resetOutboxForTests()
resetBetriebForTests()
resetJobsForTests()
aw.resetListCalls()
config.termine.mode = 'pilot'
config.termine.mailsProStunde = 200
config.termine.mailsProFunktion = 40
config.termine.mailsReserve = 0.25
config.chatNotify.emails = [...TEAM]
config.smtp.from = 'noreply@webklar.com'
tr = fakeTransport()
setMailTransportForTests(tr)
log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}) }
})
afterEach(async () => {
await warteAufVersand()
const zeilen = [...log.error.mock.calls, ...log.warn.mock.calls].map((aufruf) => aufruf.arguments.map(String).join(' '))
mock.restoreAll()
config.termine.mode = VORHER.modus
config.termine.mailsProStunde = 200
config.termine.mailsProFunktion = 40
config.chatNotify.emails = VORHER.empfaenger
config.smtp.from = VORHER.absender
// Logs enthalten nie Adressen oder Namen der Gaeste
assert.doesNotMatch(zeilen.join('\n'), /anna@example\.com|Anna/, 'keine Gastadresse und kein Gastname im Log')
})
// ---------------------------------------------------------------- Hilfen
const werte = (id, extra = {}) => ({ ...Object.fromEntries(loadTemplate(id).placeholders.map((p) => [p, `Wert-${p}`])), ...extra })
let nummer = 0
/** Mail an einen Gast (Vorrang-Art G02, Vorlage G01): belegt beim Senden einen Platz im Stundenbudget. */
const gastMail = (featureId = 'f_A') => ({
featureId,
bookingId: `b_${featureId}_${++nummer}`,
art: 'G02',
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 },
})
/** `anzahl` Gastmails zum Zeitpunkt `now` senden (je Funktion hoechstens 20 gleichzeitig; das Kontingent je Funktion ist hier nicht das Thema). */
async function belegeBudget(anzahl, now = JETZT) {
config.termine.mailsProFunktion = 1000
for (let i = 0; i < anzahl; i += 20) {
await Promise.all(Array.from({ length: Math.min(20, anzahl - i) }, (_, k) => einreihen(gastMail(`f_${(i + k) % 7}`), now)))
}
await warteAufVersand()
config.termine.mailsProFunktion = 40
}
/** Eintrag direkt in der Datenbank (ohne Sendeversuch). */
function legeEintrag(id, over = {}) {
aw.seed('termineOutbox', {
$id: id,
featureId: 'f_A',
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: iso(JETZT),
fehler: '',
createdAt: iso(JETZT),
...over,
})
}
/** Anmeldepause ausloesen: ein Anmeldefehler beim Senden zum Zeitpunkt `now` (Pause bis now + 15 Minuten); der Eintrag bleibt danach nicht stehen. */
async function loeseAnmeldepauseAus(now) {
tr.fehler.push(EAUTH)
const doc = await einreihen(gastMail('f_P'), now)
await warteAufVersand()
await deleteDocument(col(), doc.$id)
}
const teamMails = () => tr.senke.sent.filter(ist20)
const logZeilen = () => [...log.warn.mock.calls, ...log.error.mock.calls].map((aufruf) => aufruf.arguments.map(String).join(' '))
const betriebLog = () => logZeilen().filter((zeile) => zeile.startsWith('[termine-betrieb]'))
const betriebLogMit = (muster) => betriebLog().filter((zeile) => muster.test(zeile))
/** Alle drei Vorfaelle zum Zeitpunkt JETZT: Budget 7/10, ein 11 Minuten ueberfaelliger Eintrag, Anmeldepause bis JETZT + 4 Minuten. */
async function richteAlleVorfaelleEin() {
config.termine.mailsProStunde = 10
await belegeBudget(7, plus(-12 * MIN))
tr.fehler.push(EAUTH)
await einreihen(gastMail('f_B'), plus(-11 * MIN))
await warteAufVersand()
assert.equal(pausiertBis().getTime(), plus(4 * MIN).getTime())
}
/** Alle Lese-Anfragen an die Collection scheitern mit 500, solange fn laeuft. */
async function mitLesefehler(collection, fn) {
const echt = globalThis.fetch
globalThis.fetch = async (url, optionen) => {
if (new RegExp(`/collections/${collection}/documents`).test(String(url)) && (optionen?.method ?? 'GET') === 'GET') {
return new Response(JSON.stringify({ message: 'Nachbau kaputt', code: 500, type: 'general_server_error' }), { status: 500, headers: { 'content-type': 'application/json' } })
}
return echt(url, optionen)
}
try {
return await fn()
} finally {
globalThis.fetch = echt
}
}
/** Anzahl der Anfragen, die fn ausloest. */
async function zaehleAnfragen(fn) {
const echt = globalThis.fetch
let n = 0
globalThis.fetch = (url, optionen) => {
n += 1
return echt(url, optionen)
}
try {
await fn()
} finally {
globalThis.fetch = echt
}
return n
}
// ---------------------------------------------------------------- warteschlangeAlter
test('warteschlangeAlter: Anzahl der Wartenden und Minuten des ältesten fälligen Eintrags; leere Warteschlange ⇒ 0 / 0', async () => {
assert.deepEqual(await warteschlangeAlter(JETZT), { wartet: 0, aeltesteMin: 0 })
legeEintrag('o_alt', { naechsterVersuch: iso(plus(-30 * MIN - 59000)) })
legeEintrag('o_neu', { naechsterVersuch: iso(plus(-12 * MIN)) })
legeEintrag('o_spaeter', { naechsterVersuch: iso(plus(5 * MIN)) })
assert.deepEqual(await warteschlangeAlter(JETZT), { wartet: 3, aeltesteMin: 30 }, 'ganze Minuten, abgerundet; die Wiederholung in der Zukunft zählt zu den Wartenden, nicht zur Überfälligkeit')
assert.deepEqual(await warteschlangeAlter(plus(MIN)), { wartet: 3, aeltesteMin: 31 })
await assert.rejects(() => warteschlangeAlter('jetzt'), TypeError)
})
test('warteschlangeAlter: nur Einträge mit Status wartet; gesendete, fehlgeschlagene und verworfene zählen nicht, auch wenn ihr Löschtermin vorbei ist', async () => {
for (const status of ['gesendet', 'fehler', 'verworfen']) legeEintrag(`o_${status}`, { status, naechsterVersuch: iso(plus(-3 * 24 * STUNDE)) })
assert.deepEqual(await warteschlangeAlter(JETZT), { wartet: 0, aeltesteMin: 0 })
})
test('warteschlangeAlter: zwei kurze Abfragen über status/naechsterVersuch, die Liste mit limit 1, sortiert, nur die Felder $id und naechsterVersuch (nie ein Mailtext)', async () => {
legeEintrag('o_1')
aw.resetListCalls()
await warteschlangeAlter(JETZT)
const abfragen = aw.listCalls('termineOutbox').map((aufruf) => aufruf.queries)
assert.equal(abfragen.length, 2)
const liste = abfragen.find((queries) => queries.some((q) => q.method === 'select'))
assert.ok(liste, 'die Liste des ältesten Eintrags')
assert.deepEqual(liste.find((q) => q.method === 'select').values, ['$id', 'naechsterVersuch'])
assert.deepEqual(liste.find((q) => q.method === 'limit').values, [1])
assert.equal(liste.find((q) => q.method === 'orderAsc').attribute, 'naechsterVersuch')
assert.ok(liste.some((q) => q.method === 'lessThanEqual'), 'nur fällige')
assert.ok(abfragen.every((queries) => queries.some((q) => q.method === 'equal')), 'beide nur für den Status wartet')
})
// ---------------------------------------------------------------- Budget
test('Budget: 139 von 200 ⇒ kein Vorfall, 140 ⇒ Vorfall; das Log „Mailbudget 140/200“ genau einmal, 30 Minuten später kein zweites; T20 genau einmal ohne Adresse und Namen', async () => {
await belegeBudget(139)
assert.equal(tr.senke.sent.length, 139)
assert.deepEqual(await pruefeBetrieb(JETZT), [])
assert.equal(betriebLog().length, 0)
assert.equal(teamMails().length, 0)
await belegeBudget(1)
assert.equal(tr.senke.sent.length, 140)
assert.deepEqual(await pruefeBetrieb(JETZT), ['budget'])
assert.equal(betriebLogMit(/Mailbudget 140\/200/).length, 1)
assert.equal(teamMails().length, 1)
assert.deepEqual(await pruefeBetrieb(plus(30 * MIN)), ['budget'], 'die Plätze stehen noch im Stundenfenster')
assert.equal(betriebLogMit(/Mailbudget 140\/200/).length, 1, 'kein zweites Log nach 30 Minuten')
assert.equal(teamMails().length, 1, 'keine zweite Mail')
const mail = teamMails()[0]
assert.match(mail.subject, /^\[Terminplaner\] /)
assert.deepEqual(mail.to.map((adresse) => adresse.address), TEAM, 'an das Team')
assert.match(mail.text, /Mailbudget 140\/200/)
assert.match(mail.text, /05\.10\.2026, 10:00/, 'Zeit in Berliner Wanduhr')
for (const teil of [mail.subject, mail.text, mail.html]) assert.doesNotMatch(teil, /anna|Anna|Salon Muster|Wert-/, 'kein Gast, kein Betrieb im Text')
assert.doesNotMatch(mail.text, /@/, 'keine Adresse im Text')
})
test('Budget: dauert die Überlastung über eine Stunde, folgt das Log nach 61 Minuten wieder, die Team-Mail erst nach der Abkühlzeit', async () => {
config.termine.mailsProStunde = 10 // Hinweis ab 7 Plätzen
const sende = async (minute) => {
await einreihen(gastMail('f_A'), plus(minute * MIN))
await warteAufVersand()
}
for (let minute = 0; minute <= 40; minute += 8) await sende(minute) // 6 Mails
assert.deepEqual(await pruefeBetrieb(plus(47 * MIN)), [], '6 von 10')
await sende(48) // 7 Mails im Fenster
assert.deepEqual(await pruefeBetrieb(plus(48 * MIN)), ['budget'])
assert.equal(betriebLogMit(/Mailbudget 7\/10/).length, 1)
assert.equal(teamMails().length, 1)
for (let minute = 56; minute <= 72; minute += 8) await sende(minute)
assert.deepEqual(await pruefeBetrieb(plus(78 * MIN)), ['budget'])
assert.equal(betriebLogMit(/Mailbudget/).length, 1, '30 Minuten später kein zweites Log')
for (let minute = 80; minute <= 104; minute += 8) await sende(minute)
assert.deepEqual(await pruefeBetrieb(plus(109 * MIN)), ['budget'], 'Fenster (49, 109]: 7 Mails')
assert.equal(betriebLogMit(/Mailbudget 7\/10/).length, 2, '61 Minuten nach dem ersten Log wieder')
assert.equal(teamMails().length, 1, 'die Mail kommt erst nach 6 Stunden wieder')
})
// ---------------------------------------------------------------- Warteschlange
test('Warteschlange: ein Eintrag 10 Minuten überfällig ⇒ kein Vorfall, 11 Minuten ⇒ Vorfall mit Zahlen im Log und in der Mail', async () => {
legeEintrag('o_zehn', { naechsterVersuch: iso(plus(-10 * MIN)) })
assert.deepEqual(await pruefeBetrieb(JETZT), [])
assert.equal(betriebLog().length, 0)
assert.equal(teamMails().length, 0)
assert.deepEqual(await pruefeBetrieb(plus(MIN)), ['queue'], 'dieselbe Fälligkeit, eine Minute später: 11 Minuten')
assert.equal(betriebLogMit(/älteste fällige Eintrag ist seit 11 Min überfällig, 1 wartend/).length, 1)
assert.equal(teamMails().length, 1)
assert.match(teamMails()[0].text, /11 Min/)
assert.doesNotMatch(teamMails()[0].text, /@|anna|Anna/)
})
test('Warteschlange: der älteste fällige Eintrag zählt; eine Wiederholung in der Zukunft und Einträge anderer Status zählen nicht', async () => {
legeEintrag('o_wiederholung', { versuche: 2, naechsterVersuch: iso(plus(5 * MIN)), createdAt: iso(plus(-2 * STUNDE)) })
legeEintrag('o_gesendet', { status: 'gesendet', naechsterVersuch: iso(plus(-3 * 24 * STUNDE)) })
legeEintrag('o_fehler', { status: 'fehler', naechsterVersuch: iso(plus(-60 * MIN)) })
assert.deepEqual(await pruefeBetrieb(JETZT), [], 'nichts fällig und überfällig')
legeEintrag('o_ueberfaellig', { naechsterVersuch: iso(plus(-12 * MIN)) })
legeEintrag('o_aelter', { naechsterVersuch: iso(plus(-45 * MIN)) })
assert.deepEqual(await pruefeBetrieb(JETZT), ['queue'])
assert.equal(betriebLogMit(/seit 45 Min überfällig, 3 wartend/).length, 1, 'ältester Eintrag 45 Minuten; wartend = alle Einträge mit Status wartet')
})
// ---------------------------------------------------------------- Anmeldepause
test('Pause: Anmeldefehler ⇒ Vorfall pause mit Endzeit im Log, nach Ablauf kein Vorfall mehr', async () => {
await loeseAnmeldepauseAus(JETZT)
assert.equal(pausiertBis().getTime(), plus(15 * MIN).getTime())
assert.deepEqual(await pruefeBetrieb(plus(MIN)), ['pause'])
assert.equal(betriebLogMit(/pausiert bis 05\.10\.2026, 10:15 Uhr/).length, 1, 'Endzeit in Berliner Wanduhr')
assert.equal(teamMails().length, 1)
assert.match(teamMails()[0].subject, /^\[Terminplaner\] /)
assert.match(teamMails()[0].text, /10:15/)
assert.deepEqual(await pruefeBetrieb(plus(14 * MIN)), ['pause'], 'eine Minute vor dem Ende')
assert.deepEqual(await pruefeBetrieb(plus(15 * MIN)), [], 'zum Ende der Pause ist der Vorfall vorbei')
assert.equal(teamMails().length, 1)
})
test('Pause: eine zweite Pause innerhalb von 6 Stunden ⇒ Log ja, Mail nein; nach 6 Stunden wieder Mail', async () => {
await loeseAnmeldepauseAus(JETZT)
assert.deepEqual(await pruefeBetrieb(plus(MIN)), ['pause']) // Mail um 10:01
assert.equal(teamMails().length, 1)
assert.deepEqual(await pruefeBetrieb(plus(20 * MIN)), [])
await loeseAnmeldepauseAus(plus(2 * STUNDE))
assert.deepEqual(await pruefeBetrieb(plus(2 * STUNDE + MIN)), ['pause'])
assert.equal(betriebLogMit(/pausiert bis/).length, 2, 'die zweite Pause steht im Log')
assert.equal(teamMails().length, 1, 'aber die Abkühlzeit hält die Mail zurück, obwohl der Vorfall dazwischen endete')
await loeseAnmeldepauseAus(plus(6 * STUNDE))
assert.deepEqual(await pruefeBetrieb(plus(6 * STUNDE + 30000)), ['pause'])
assert.equal(teamMails().length, 1, '5 Stunden 59 Minuten und 30 Sekunden nach der ersten Mail')
assert.deepEqual(await pruefeBetrieb(plus(6 * STUNDE + MIN)), ['pause'])
assert.equal(teamMails().length, 2, 'genau 6 Stunden nach der ersten Mail wieder')
})
// ---------------------------------------------------------------- Gemeinsamer Melder
test('meldeVorfall: Log höchstens stündlich, Mail erst nach 6 Stunden, je Schlüssel getrennt', async () => {
await meldeVorfall('beispiel', 'Beispiel-Vorfall 1', JETZT)
assert.equal(betriebLogMit(/Beispiel-Vorfall 1/).length, 1)
assert.equal(teamMails().length, 1)
assert.equal(teamMails()[0].subject, '[Terminplaner] Betriebsmeldung', 'ohne eigene Überschrift die allgemeine')
assert.match(teamMails()[0].text, /Beispiel-Vorfall 1/)
await meldeVorfall('beispiel', 'Beispiel-Vorfall 2', plus(59 * MIN))
assert.equal(betriebLog().length, 1, 'keine Stunde')
await meldeVorfall('beispiel', 'Beispiel-Vorfall 3', plus(60 * MIN))
assert.equal(betriebLogMit(/Beispiel-Vorfall 3/).length, 1, 'nach einer Stunde wieder')
assert.equal(teamMails().length, 1)
await meldeVorfall('anderer', 'Anderer Vorfall', plus(61 * MIN))
assert.equal(teamMails().length, 2, 'ein anderer Schlüssel hat seine eigene Abkühlzeit')
await meldeVorfall('beispiel', 'Beispiel-Vorfall 4', plus(6 * STUNDE - 1))
assert.equal(teamMails().length, 2)
await meldeVorfall('beispiel', 'Beispiel-Vorfall 5', plus(6 * STUNDE))
assert.equal(teamMails().length, 3)
assert.match(teamMails()[2].text, /Beispiel-Vorfall 5/)
resetBetriebForTests()
await meldeVorfall('beispiel', 'Nach dem Zurücksetzen', plus(6 * STUNDE + MIN))
assert.equal(teamMails().length, 4, 'resetBetriebForTests vergisst die Abkühlzeit')
})
test('meldeVorfall: verlangt Schlüssel, Text und eine gültige Uhrzeit', async () => {
await assert.rejects(() => meldeVorfall('', 'x', JETZT), TypeError)
await assert.rejects(() => meldeVorfall('a', '', JETZT), TypeError)
await assert.rejects(() => meldeVorfall('a', 'x', 'jetzt'), TypeError)
assert.equal(teamMails().length, 0)
})
// ---------------------------------------------------------------- Fehlerfälle
test('pruefeBetrieb wirft nie: ein Lesefehler (Appwrite 500) ⇒ ein Log, kein vorgetäuschter Vorfall, keine Mail', async () => {
aw.failNext({ collection: 'termineOutbox', method: 'GET', status: 500, message: 'Nachbau kaputt' })
assert.deepEqual(await pruefeBetrieb(JETZT), [])
assert.equal(betriebLog().length, 1)
assert.match(betriebLog()[0], /Nachbau kaputt/)
assert.equal(teamMails().length, 0)
assert.equal(tr.aufrufe, 0)
assert.deepEqual(await pruefeBetrieb(plus(MIN)), [], 'beim nächsten Lauf geht es normal weiter')
assert.equal(betriebLog().length, 1)
})
test('pruefeBetrieb: ein dauerhafter Lesefehler loggt höchstens stündlich und löst nie eine Mail-Schleife aus; das Budget wird trotzdem geprüft', async () => {
config.termine.mailsProStunde = 10
await belegeBudget(7)
await mitLesefehler('termineOutbox', async () => {
for (let minute = 1; minute <= 5; minute++) assert.deepEqual(await pruefeBetrieb(plus(minute * MIN)), ['budget'], 'das Budget liest nichts aus Appwrite')
assert.equal(betriebLogMit(/nicht möglich/).length, 1, 'ein Log für fünf Läufe')
assert.deepEqual(await pruefeBetrieb(plus(61 * MIN)), [])
assert.equal(betriebLogMit(/nicht möglich/).length, 2, 'nach einer Stunde wieder')
})
assert.equal(teamMails().length, 1, 'nur die Budget-Mail')
})
test('pruefeBetrieb: scheitert die Team-Mail, steht das im Log, der Vorfall bleibt gemerkt und die Mail wird nicht jede Minute neu versucht', async () => {
config.termine.mailsProStunde = 10
await belegeBudget(7)
tr.wirf = (mail) => (ist20(mail) ? new Error('Verbindung abgelehnt') : null)
assert.deepEqual(await pruefeBetrieb(plus(MIN)), ['budget'])
assert.equal(tr.teamVersuche, 1)
assert.equal(teamMails().length, 0)
assert.equal(betriebLogMit(/Mailbudget 7\/10/).length, 1, 'der Vorfall steht im Log (Rückfall)')
assert.ok(logZeilen().some((zeile) => /Admin-Hinweis fehlgeschlagen/.test(zeile)), 'der Mailer protokolliert den Fehler')
assert.equal(betriebLogMit(/Team-Mail.*nicht gesendet/).length, 1)
for (let minute = 2; minute <= 10; minute++) assert.deepEqual(await pruefeBetrieb(plus(minute * MIN)), ['budget'])
assert.equal(tr.teamVersuche, 1, 'kein weiterer Versuch innerhalb der Abkühlzeit')
assert.equal(betriebLogMit(/Mailbudget 7\/10/).length, 1)
tr.wirf = null
await meldeVorfall('budget', 'Noch einmal', plus(6 * STUNDE + MIN))
assert.equal(teamMails().length, 1, 'nach der Abkühlzeit klappt es wieder')
})
test('pruefeBetrieb: ohne Empfänger (CHAT_NOTIFY_EMAILS leer) wirft nichts, das Log bleibt', async () => {
config.chatNotify.emails = []
config.termine.mailsProStunde = 10
await belegeBudget(7)
assert.deepEqual(await pruefeBetrieb(JETZT), ['budget'])
assert.equal(betriebLogMit(/Mailbudget 7\/10/).length, 1)
assert.equal(betriebLogMit(/Team-Mail.*nicht gesendet.*nicht konfiguriert/).length, 1)
assert.equal(tr.teamVersuche, 0)
})
test('pruefeBetrieb: mehrere Vorfälle zugleich, jeder mit eigener Meldung und eigener Überschrift; verlangt eine gültige Uhrzeit', async () => {
await richteAlleVorfaelleEin()
assert.deepEqual(await pruefeBetrieb(JETZT), ['budget', 'queue', 'pause'])
assert.equal(teamMails().length, 3)
assert.deepEqual(
teamMails().map((mail) => mail.subject).sort(),
['[Terminplaner] Mail-Warteschlange staut sich', '[Terminplaner] Mailbudget fast aufgebraucht', '[Terminplaner] Mailversand pausiert'],
)
assert.equal(betriebLog().length, 3)
await assert.rejects(() => pruefeBetrieb('jetzt'), TypeError)
})
// ---------------------------------------------------------------- Minutenjob
test('minutenJob im Modus off ruft die Betriebsprüfung nicht auf: 0 Anfragen, keine Mail, kein Log', async () => {
await richteAlleVorfaelleEin()
tr.aufrufe = 0
tr.senke.sent.length = 0
log.warn.mock.resetCalls()
log.error.mock.resetCalls()
config.termine.mode = 'off'
let ergebnis
const n = await zaehleAnfragen(async () => {
ergebnis = await minutenJob(JETZT)
})
assert.deepEqual(ergebnis, { aus: true })
assert.equal(n, 0)
assert.equal(tr.aufrufe, 0)
assert.equal(betriebLog().length, 0)
})
test('minutenJob prüft den Betrieb am Ende des Laufs: Vorfälle führen zu Log und Team-Mail, das Ergebnis des Laufs bleibt unverändert', async () => {
await richteAlleVorfaelleEin()
tr.senke.sent.length = 0
const ergebnis = await minutenJob(JETZT)
assert.deepEqual(ergebnis, { verfallen: 0, faellig: 0, gesendet: 0, fehler: 0 }, 'die Pause hält die Warteschlange an, die Prüfung zählt nicht als Fehler')
assert.equal(teamMails().length, 3)
assert.equal(betriebLogMit(/Mailbudget 7\/10/).length, 1)
assert.equal(betriebLogMit(/seit 11 Min überfällig/).length, 1)
assert.equal(betriebLogMit(/pausiert bis 05\.10\.2026, 10:04 Uhr/).length, 1)
await minutenJob(plus(MIN))
assert.equal(teamMails().length, 3, 'der nächste Takt meldet nichts doppelt')
assert.equal(betriebLog().length, 3)
})
test('minutenJob: eine unlesbare Warteschlange für die Betriebsprüfung ändert nichts am Lauf (kein Wurf, Lauf ohne zusätzlichen Fehler)', async () => {
const ergebnis = await mitLesefehler('termineOutbox', () => minutenJob(JETZT))
assert.equal(ergebnis.verfallen, 0)
assert.equal(ergebnis.fehler, 1, 'nur der Schritt Warteschlange zählt; die Betriebsprüfung fügt keinen Fehler hinzu')
})
// ---------------------------------------------------------------- Tagesjob-Meldung (meldeTagesjob)
const TAG = 24 * STUNDE
const tag = (n) => plus(n * TAG)
/** Ergebnis eines Tagesjobs, soweit meldeTagesjob es liest. */
const tagesErgebnis = (over = {}) => ({ fehler: 0, gedeckelt: { loeschfristen: 0, abgeschaltet: 0, zellen: 0 }, geloescht: 0, aktiveBetriebe: 0, ...over })
const AM_DECKEL = { loeschfristen: 1, abgeschaltet: 0, zellen: 0 }
const textAllerMails = () => teamMails().map((mail) => `${mail.subject}\n${mail.text}`).join('\n')
test('meldeTagesjob: Fehler im Tagesjob ⇒ Vorfall tagesjob mit Log und Team-Mail (nur Zahlen); ohne Fehler kein Vorfall', async () => {
assert.deepEqual(await meldeTagesjob(tagesErgebnis(), JETZT), [])
assert.equal(teamMails().length, 0)
assert.equal(betriebLog().length, 0)
assert.deepEqual(await meldeTagesjob(tagesErgebnis({ fehler: 2 }), plus(MIN)), ['tagesjob'])
assert.equal(teamMails().length, 1)
assert.equal(teamMails()[0].subject, '[Terminplaner] Tagesjob mit Fehlern')
assert.match(teamMails()[0].text, /2 Fehler/)
assert.equal(betriebLogMit(/Tagesjob.*2 Fehler/).length, 1)
assert.doesNotMatch(textAllerMails(), /@(?!webklar)|\b[bfcpr]_[a-z0-9]{4,}/i, 'weder Adresse noch Kennung in der Meldung')
})
test('meldeTagesjob: ein Abbruch des Tagesjobs ist ein Vorfall tagesjob, auch ohne gezählten Fehler im Ergebnis', async () => {
assert.deepEqual(await meldeTagesjob({ abbruch: true, fehler: 1 }, JETZT), ['tagesjob'])
assert.equal(teamMails().length, 1)
assert.match(teamMails()[0].text, /abgebrochen/)
assert.deepEqual(letzterTagesjob(), { at: JETZT.toISOString(), fehler: 1, gedeckelt: 0, geloescht: 0 })
})
test('meldeTagesjob: der Vorfall tagesjob folgt der Abkühlzeit (Log stündlich, Mail erst nach 6 Stunden, am nächsten Tag wieder)', async () => {
await meldeTagesjob(tagesErgebnis({ fehler: 1 }), JETZT)
await meldeTagesjob(tagesErgebnis({ fehler: 1 }), plus(30 * MIN))
assert.equal(teamMails().length, 1)
assert.equal(betriebLogMit(/Tagesjob/).length, 1)
await meldeTagesjob(tagesErgebnis({ fehler: 1 }), plus(2 * STUNDE))
assert.equal(teamMails().length, 1, 'innerhalb der 6 Stunden keine zweite Mail')
assert.equal(betriebLogMit(/Tagesjob/).length, 2, 'das Log nach einer Stunde wieder')
await meldeTagesjob(tagesErgebnis({ fehler: 1 }), tag(1))
assert.equal(teamMails().length, 2, 'der Tagesjob am nächsten Tag meldet wieder')
})
test('meldeTagesjob: Löschdeckel an 2 Tagesjobs in Folge ⇒ kein Vorfall, am 3. ⇒ deckel; die Folge hält an', async () => {
const gedeckelt = tagesErgebnis({ gedeckelt: AM_DECKEL })
assert.deepEqual(await meldeTagesjob(gedeckelt, tag(0)), [])
assert.deepEqual(await meldeTagesjob(gedeckelt, tag(1)), [])
assert.equal(teamMails().length, 0)
assert.deepEqual(await meldeTagesjob(gedeckelt, tag(2)), ['deckel'])
assert.equal(teamMails().length, 1)
assert.equal(teamMails()[0].subject, '[Terminplaner] Löschläufe am Deckel')
assert.match(teamMails()[0].text, /3 Tagesjobs in Folge/)
assert.equal(betriebLogMit(/Deckel/).length, 1)
assert.deepEqual(await meldeTagesjob(gedeckelt, tag(3)), ['deckel'], 'solange der Rückstand anhält, bleibt es ein Vorfall')
assert.equal(teamMails().length, 2, 'am nächsten Tag nach der Abkühlzeit wieder eine Mail')
})
test('meldeTagesjob: ein Tagesjob ohne Deckel unterbricht die Folge; jeder Bereich (Fristen, abgeschaltete Funktionen, Zellen) zählt', async () => {
const frei = tagesErgebnis()
const mitDeckel = (gedeckelt) => tagesErgebnis({ gedeckelt })
assert.deepEqual(await meldeTagesjob(mitDeckel({ loeschfristen: 1, abgeschaltet: 0, zellen: 0 }), tag(0)), [])
assert.deepEqual(await meldeTagesjob(mitDeckel({ loeschfristen: 0, abgeschaltet: 3, zellen: 0 }), tag(1)), [])
assert.deepEqual(await meldeTagesjob(frei, tag(2)), [])
assert.deepEqual(await meldeTagesjob(mitDeckel({ loeschfristen: 0, abgeschaltet: 0, zellen: 1 }), tag(3)), [])
assert.deepEqual(await meldeTagesjob(mitDeckel(AM_DECKEL), tag(4)), [])
assert.deepEqual(await meldeTagesjob(mitDeckel({ loeschfristen: 2, abgeschaltet: 1, zellen: 1 }), tag(5)), ['deckel'], 'drei Tage in Folge, jeder mit anderem Bereich')
assert.equal(teamMails().length, 1)
})
test('meldeTagesjob: ein abgebrochener Tagesjob verändert die Deckel-Folge nicht', async () => {
const gedeckelt = tagesErgebnis({ gedeckelt: AM_DECKEL })
await meldeTagesjob(gedeckelt, tag(0))
await meldeTagesjob(gedeckelt, tag(1))
await meldeTagesjob({ abbruch: true, fehler: 1 }, tag(2))
assert.deepEqual(await meldeTagesjob(gedeckelt, tag(3)), ['deckel'], 'Abbruch unterbricht die Folge nicht und zählt nicht mit')
})
test('meldeTagesjob: 19 aktive Betriebe ⇒ kein Vorfall, 20 ⇒ betriebe (D8) mit Hinweis auf den Umstieg ab 25; unbekannte Zahl (null) ⇒ nichts', async () => {
assert.deepEqual(await meldeTagesjob(tagesErgebnis({ aktiveBetriebe: 19 }), JETZT), [])
assert.deepEqual(await meldeTagesjob(tagesErgebnis({ aktiveBetriebe: null }), plus(MIN)), [])
assert.equal(teamMails().length, 0)
assert.deepEqual(await meldeTagesjob(tagesErgebnis({ aktiveBetriebe: 20 }), plus(2 * MIN)), ['betriebe'])
assert.equal(teamMails().length, 1)
assert.equal(teamMails()[0].subject, '[Terminplaner] Viele aktive Betriebe')
assert.match(teamMails()[0].text, /20 aktive Betriebe/)
assert.match(teamMails()[0].text, /25/)
assert.equal(betriebLogMit(/20 aktive Betriebe/).length, 1)
assert.deepEqual(await meldeTagesjob(tagesErgebnis({ aktiveBetriebe: 21 }), plus(3 * MIN)), ['betriebe'])
assert.equal(teamMails().length, 1, 'die Abkühlzeit gilt je Vorfall')
})
test('meldeTagesjob: mehrere Vorfälle zugleich ⇒ je eine Meldung mit eigener Überschrift', async () => {
const erg = tagesErgebnis({ fehler: 1, gedeckelt: AM_DECKEL, aktiveBetriebe: 22 })
assert.deepEqual(await meldeTagesjob(erg, tag(0)), ['tagesjob', 'betriebe'])
assert.deepEqual(await meldeTagesjob(erg, tag(1)), ['tagesjob', 'betriebe'])
assert.equal(teamMails().length, 4, 'je Tag eine Mail je Vorfall (Abkühlzeit 6 Stunden)')
assert.deepEqual(await meldeTagesjob(erg, tag(2)), ['tagesjob', 'deckel', 'betriebe'], 'am dritten Tag kommt deckel dazu')
assert.equal(teamMails().length, 7)
assert.deepEqual(
[...new Set(teamMails().map((mail) => mail.subject))].sort(),
['[Terminplaner] Löschläufe am Deckel', '[Terminplaner] Tagesjob mit Fehlern', '[Terminplaner] Viele aktive Betriebe'],
)
})
test('letzterTagesjob: null bis zur ersten Meldung, danach nur Zählungen { at, fehler, gedeckelt, geloescht } als Zahlen (gedeckelt = Summe der Bereiche)', async () => {
assert.equal(letzterTagesjob(), null)
await meldeTagesjob(tagesErgebnis({ fehler: 3, gedeckelt: { loeschfristen: 2, abgeschaltet: 1, zellen: 1 }, geloescht: 17, aktiveBetriebe: 5 }), JETZT)
const letzter = letzterTagesjob()
assert.deepEqual(letzter, { at: JETZT.toISOString(), fehler: 3, gedeckelt: 4, geloescht: 17 })
for (const schluessel of ['fehler', 'gedeckelt', 'geloescht']) assert.equal(typeof letzter[schluessel], 'number', schluessel)
letzter.fehler = 99
assert.equal(letzterTagesjob().fehler, 3, 'der Aufrufer bekommt eine Kopie')
await meldeTagesjob(tagesErgebnis({ geloescht: 4 }), plus(TAG))
assert.deepEqual(letzterTagesjob(), { at: plus(TAG).toISOString(), fehler: 0, gedeckelt: 0, geloescht: 4 }, 'immer der letzte Lauf')
resetBetriebForTests()
assert.equal(letzterTagesjob(), null)
})
test('meldeTagesjob wirft nie: scheitert die Team-Mail, steht das im Log, der Lauf ist gemerkt und die Mail wird nicht neu versucht', async () => {
tr.wirf = (mail) => (ist20(mail) ? new Error('Verbindung abgelehnt') : null)
assert.deepEqual(await meldeTagesjob(tagesErgebnis({ fehler: 1 }), JETZT), ['tagesjob'])
assert.equal(tr.teamVersuche, 1)
assert.equal(teamMails().length, 0)
assert.equal(betriebLogMit(/Team-Mail.*nicht gesendet/).length, 1)
assert.equal(letzterTagesjob().fehler, 1)
await meldeTagesjob(tagesErgebnis({ fehler: 1 }), plus(MIN))
assert.equal(tr.teamVersuche, 1)
config.chatNotify.emails = []
assert.deepEqual(await meldeTagesjob(tagesErgebnis({ aktiveBetriebe: 30 }), plus(2 * MIN)), ['betriebe'], 'ohne Empfänger bleibt das Log')
assert.equal(betriebLogMit(/30 aktive Betriebe/).length, 1)
})
test('meldeTagesjob: unbrauchbare Ergebnisse ergeben keinen Wurf und keinen Vorfall; eine ungültige Uhrzeit ist ein TypeError', async () => {
for (const unbrauchbar of [undefined, null, {}, 'x', { fehler: 'viele', gedeckelt: 'ja', geloescht: -3, aktiveBetriebe: 'zwanzig' }, { fehler: -1, aktiveBetriebe: 2.5 }]) {
assert.deepEqual(await meldeTagesjob(unbrauchbar, JETZT), [], JSON.stringify(unbrauchbar))
}
assert.equal(teamMails().length, 0)
assert.deepEqual(letzterTagesjob(), { at: JETZT.toISOString(), fehler: 0, gedeckelt: 0, geloescht: 0 })
await assert.rejects(() => meldeTagesjob(tagesErgebnis(), 'jetzt'), TypeError)
})
test('zaehleAktiveBetriebe: zählt nur Termine-Dokumente im Zustand active (kein Entwurf, nicht abgeschaltet, keine andere Funktion); Appwrite 500 wirft', async () => {
const doc = (id, over) => aw.seed('portalFeatures', { $id: id, customerId: 'c_k', projectId: `p_${id}`, featureKey: 'termine', state: 'active', ...over })
assert.equal(await zaehleAktiveBetriebe(), 0)
doc('f_aktiv01')
doc('f_aktiv02')
doc('f_aus0001', { state: 'disabled' })
doc('f_bestellt', { state: 'ordered' })
doc('f_einbau01', { state: 'setup' })
doc('f_entwurf1', { state: 'off' })
doc('f_news0001', { featureKey: 'news' })
assert.equal(await zaehleAktiveBetriebe(), 2)
aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau kaputt' })
await assert.rejects(() => zaehleAktiveBetriebe(), (err) => err.status === 500)
})