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: '
Hallo
', 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) })