diff --git a/server/services/termine/betrieb.js b/server/services/termine/betrieb.js new file mode 100644 index 0000000..b1c118d --- /dev/null +++ b/server/services/termine/betrieb.js @@ -0,0 +1,157 @@ +import { sendAdminNotice } from '../mailer.js' +import { mailBudget, pausiertBis, warteschlangeAlter } from './outbox.js' +import { pruefeNow, pruefeText } from './util.js' +import { datumKurz, uhrzeit } from './zeit.js' + +/** + * Betriebspruefung des Terminplaners (Spezifikation 2026-10-03-terminplaner-tp2-verkauf-design.md, Abschnitt 8, Punkt 5 + * „Sichtbarkeit“): Das Team soll es erfahren, bevor Gaeste es merken. Der Minutenjob ruft pruefeBetrieb am Ende jedes Laufs auf (Modus off: gar nicht). + * + * Vorfaelle dieses Bausteins (Schluessel): + * budget mindestens 70 % von config.termine.mailsProStunde im Stundenfenster belegt (mailBudget) + * queue der aelteste faellige Eintrag der Warteschlange ist mehr als 10 Minuten ueberfaellig (warteschlangeAlter); + * eine Wiederholung mit naechsterVersuch in der Zukunft ist nicht faellig und zaehlt nicht + * pause die Anmeldepause des Mailservers laeuft (pausiertBis) + * + * Gemeinsamer Melder (meldeVorfall, auch fuer die Tagesjob-Vorfaelle von A10a3): je Schluessel + * - Warnlog `[termine-betrieb] ...` hoechstens stuendlich (nur Zahlen, nie Adresse oder Name eines Gastes), + * - Team-Mail T20 (sendAdminNotice) beim Beginn und danach fruehestens nach 6 Stunden, auch wenn der Vorfall zwischendurch + * endete. Die Abkuehlzeit beginnt mit dem Versuch, nicht erst mit dem Erfolg: faellt der Mailserver aus (bei der Anmeldepause + * ist er es), scheitert auch die Team-Mail, und das Log bleibt der Rueckfall, statt dass jede Minute ein neuer Versuch folgt. + * Zustand (Zeit der letzten Warnung und der letzten Mail je Schluessel) lebt im Speicher dieses Prozesses wie das Mailbudget. + * + * Lesefehler (Appwrite) ergeben keinen Vorfall und keine Mail, nur ein Log (ebenfalls hoechstens stuendlich je Pruefung). + * `now` ist immer ein Parameter (feste Uhr in Tests). + */ + +const LOG = '[termine-betrieb]' +const MIN_MS = 60 * 1000 +const STUNDE_MS = 60 * MIN_MS +const LOG_ABSTAND_MS = STUNDE_MS +const MAIL_ABKUEHLZEIT_MS = 6 * STUNDE_MS +/** Die Warteschlange gilt als gestaut, wenn der aelteste faellige Eintrag laenger als so viele Minuten ueberfaellig ist. */ +const QUEUE_ALTER_MIN = 10 +/** Das Budget gilt als fast aufgebraucht ab 7/10 (ganzzahlig gerechnet: benutzt x 10 >= max x 7, kein Fliesskommafehler). */ +const budgetErreicht = (benutzt, max) => max > 0 && benutzt * 10 >= max * 7 + +/** Kurze Ueberschrift der Team-Mail (Betreff `[Terminplaner] `); ohne Eintrag gilt die allgemeine. */ +const UEBERSCHRIFTEN = new Map([ + ['budget', 'Mailbudget fast aufgebraucht'], + ['queue', 'Mail-Warteschlange staut sich'], + ['pause', 'Mailversand pausiert'], +]) +const ALLGEMEINE_UEBERSCHRIFT = 'Betriebsmeldung' + +/** schluessel => { logMs, mailMs } (null = noch nie). */ +const gemerkt = new Map() + +function merkzettel(schluessel) { + let zettel = gemerkt.get(schluessel) + if (!zettel) { + zettel = { logMs: null, mailMs: null } + gemerkt.set(schluessel, zettel) + } + return zettel +} + +/** Ist seit dem letzten Mal (ms oder null = nie) mindestens abstandMs vergangen? */ +const abgelaufen = (letzteMs, nowMs, abstandMs) => letzteMs === null || nowMs - letzteMs >= abstandMs + +/** '05.10.2026, 10:15' (Berliner Wanduhr), so steht die Zeit in T20 und in den Meldungen. */ +const zeitText = (date) => `${datumKurz(date)}, ${uhrzeit(date)}` + +const fehlerText = (err) => `${err?.name ?? 'Fehler'} ${String(err?.message ?? '').replace(/\s+/g, ' ').slice(0, 200)}` + +/** + * Meldet einen Vorfall: Warnlog (hoechstens stuendlich je Schluessel) und Team-Mail T20 (beim Beginn und danach fruehestens nach + * 6 Stunden je Schluessel). Wirft nur bei ungueltigen Eingaben (TypeError); ein Mailfehler steht im Log. + * @param {string} schluessel Kennung des Vorfalls ('budget', 'queue', 'pause', ...); die Abkuehlzeit gilt je Schluessel + * @param {string} text die Meldung (Log und Mail-Details): nur Zahlen, nie Adressen oder Namen + * @param {Date} now + * @returns {Promise} + */ +export async function meldeVorfall(schluessel, text, now) { + pruefeText(schluessel, 'schluessel') + pruefeText(text, 'text') + const nowMs = pruefeNow(now, 'meldeVorfall') + const zettel = merkzettel(schluessel) + + if (abgelaufen(zettel.logMs, nowMs, LOG_ABSTAND_MS)) { + zettel.logMs = nowMs + console.warn(`${LOG} ${text}`) + } + if (!abgelaufen(zettel.mailMs, nowMs, MAIL_ABKUEHLZEIT_MS)) return + // Vor dem Senden gemerkt: ein zweiter Aufruf, waehrend die Mail noch unterwegs ist, sendet nicht noch einmal + zettel.mailMs = nowMs + const ergebnis = await sendAdminNotice({ + template: 'T20', + values: { vorfall: UEBERSCHRIFTEN.get(schluessel) ?? ALLGEMEINE_UEBERSCHRIFT, details: text, zeit: zeitText(now) }, + }) + if (!ergebnis?.sent) { + console.error(`${LOG} Team-Mail zu „${schluessel}“ nicht gesendet: ${String(ergebnis?.reason ?? 'unbekannt').slice(0, 200)}`) + } +} + +/** Lesefehler einer Pruefung ins Log: hoechstens stuendlich je Pruefung, damit ein langer Ausfall das Log nicht fuellt. */ +function meldeFehler(schluessel, err, nowMs) { + const zettel = merkzettel(`fehler:${schluessel}`) + if (!abgelaufen(zettel.logMs, nowMs, LOG_ABSTAND_MS)) return + zettel.logMs = nowMs + console.error(`${LOG} Prüfung „${schluessel}“ nicht möglich: ${fehlerText(err)}`) +} + +// Die Pruefungen: Text des Vorfalls oder null (kein Vorfall); werfen duerfen sie (Lesefehler), pruefeBetrieb faengt das ab. + +function budgetVorfall(now) { + const { benutzt, max } = mailBudget(now).stunde + if (!budgetErreicht(benutzt, max)) return null + return `Mailbudget ${benutzt}/${max} in der letzten Stunde (Hinweis ab 70 %); ist es voll, warten weitere Mails bis zur nächsten freien Stunde` +} + +async function queueVorfall(now) { + const { wartet, aeltesteMin } = await warteschlangeAlter(now) + if (!(aeltesteMin > QUEUE_ALTER_MIN)) return null + const anzahl = wartet === null ? '' : `, ${wartet} wartend` + return `Mail-Warteschlange: der älteste fällige Eintrag ist seit ${aeltesteMin} Min überfällig${anzahl}` +} + +function pauseVorfall(now) { + const bis = pausiertBis() + if (!bis || !(bis.getTime() > now.getTime())) return null + return `Mailversand pausiert bis ${zeitText(bis)} Uhr (SMTP-Anmeldung fehlgeschlagen), die Mails warten in der Warteschlange` +} + +/** In dieser Reihenfolge werden Vorfaelle geprueft und gemeldet. */ +const PRUEFUNGEN = [ + ['budget', budgetVorfall], + ['queue', queueVorfall], + ['pause', pauseVorfall], +] + +/** + * Prueft Mailbudget, Warteschlange und Anmeldepause und meldet jeden aktiven Vorfall ueber meldeVorfall. Wirft nie (ausser mit + * TypeError bei ungueltigem `now`, wie alle Termine-Bausteine): ein Lesefehler oder ein fehlgeschlagener Mailversand steht im Log + * und laesst die uebrigen Pruefungen laufen. Eine Pruefung, die nicht lesen konnte, meldet keinen Vorfall. + * @param {Date} now + * @returns {Promise} die Schluessel der aktiven Vorfaelle ('budget', 'queue', 'pause') + */ +export async function pruefeBetrieb(now) { + const nowMs = pruefeNow(now, 'pruefeBetrieb') + const aktiv = [] + for (const [schluessel, pruefe] of PRUEFUNGEN) { + try { + const text = await pruefe(now) + if (text === null) continue + aktiv.push(schluessel) + await meldeVorfall(schluessel, text, now) + } catch (err) { + meldeFehler(schluessel, err, nowMs) + } + } + return aktiv +} + +/** Nur fuer Tests: den gemerkten Zustand (Zeit der letzten Warnung und Mail je Schluessel) zuruecksetzen. */ +export function resetBetriebForTests() { + gemerkt.clear() +} diff --git a/server/services/termine/jobs.js b/server/services/termine/jobs.js index 1484160..0e4fc4e 100644 --- a/server/services/termine/jobs.js +++ b/server/services/termine/jobs.js @@ -2,6 +2,7 @@ import { config } from '../../config.js' import { Query, deleteDocument, getDocument, listAllDocuments } from '../appwriteAdmin.js' import { withLocks } from '../keyedLock.js' import { gibFreiSicher, iso, vertragAm } from './ablauf.js' +import { pruefeBetrieb } from './betrieb.js' import { abgelaufeneHaltungen, aendere, @@ -26,7 +27,8 @@ import { gibZellenFrei, raeumeVerwaisteZellen } from './zellen.js' * Hintergrund-Jobs des Terminplaners (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitte 4 und 11). server/index.js * ruft sie mit new Date() auf; `now` ist hier immer ein Parameter. * - * minutenJob jede Minute abgelaufene Haltungen => verfallen (Zellen frei); Mail-Warteschlange senden + * minutenJob jede Minute abgelaufene Haltungen => verfallen (Zellen frei); Mail-Warteschlange senden; zuletzt die + * Betriebspruefung (Budget, Warteschlange, Anmeldepause => Log und Team-Mail, betrieb.js) * fuenfMinutenJob alle 5 Minuten Erinnerungen (G05); bestaetigt 1 Stunde nach Ende => erledigt; Anfragen mit Beginn in * der Vergangenheit => abgelehnt (vom System, Zellen frei, keine Mail) * taeglicherJob taeglich Loeschfristen (11), festgefahrene und alte Mails, abgeschaltete Funktionen, verwaiste Zellen @@ -210,7 +212,8 @@ async function verfalleHaltungen(lauf, now) { } /** - * Jede Minute: abgelaufene Haltungen verfallen lassen, Warteschlange senden. + * Jede Minute: abgelaufene Haltungen verfallen lassen, Warteschlange senden, zuletzt den Betrieb pruefen (Vorfaelle stehen im Log und + * gehen als T20 ans Team; die Pruefung wirft nie und aendert das Ergebnis des Laufs nicht). * @param {Date} now * @returns {Promise<{ verfallen: number, faellig: number, gesendet: number, fehler: number } | { aus: true } | { uebersprungen: true }>} */ @@ -218,6 +221,8 @@ export async function minutenJob(now) { return fuehreAus('minutenJob', now, async (lauf) => { const verfallen = await schritt(lauf, 'Verfall', () => verfalleHaltungen(lauf, now), 0) const { faellig, gesendet } = await schritt(lauf, 'Warteschlange', () => verarbeite(now), { faellig: 0, gesendet: 0 }) + // Zuletzt: die Pruefung sieht den Stand nach Verfall und Versand (ein eben gesendeter Eintrag staut nichts mehr) + await schritt(lauf, 'Betriebsprüfung', () => pruefeBetrieb(now), []) return { verfallen, faellig, gesendet, fehler: lauf.fehler } }) } diff --git a/server/services/termine/outbox.js b/server/services/termine/outbox.js index b805c1d..df994b8 100644 --- a/server/services/termine/outbox.js +++ b/server/services/termine/outbox.js @@ -1,5 +1,5 @@ import { config } from '../../config.js' -import { Query, createDocument, deleteDocument, getDocument, listAllDocuments, listDocuments, updateDocument } from '../appwriteAdmin.js' +import { Query, countDocuments, createDocument, deleteDocument, getDocument, listAllDocuments, listDocuments, updateDocument } from '../appwriteAdmin.js' import { withLocks } from '../keyedLock.js' import { sendGuestMail } from '../mailer.js' import { renderMail } from '../mailTemplates.js' @@ -457,6 +457,31 @@ export async function verarbeite(now) { return { faellig: lauf.faellig, gesendet: lauf.gesendet } } +/** + * Stand der Warteschlange fuer die Betriebspruefung und die Statusroute: nur Zahlen, nie ein Mailtext. `wartet` = alle Eintraege mit + * Status `wartet` (auch Wiederholungen, die erst spaeter faellig werden), `aeltesteMin` = ganze Minuten (abgerundet), die der aelteste + * faellige Eintrag (naechsterVersuch <= jetzt) schon ueberfaellig ist, 0 ohne faelligen Eintrag. Zwei kurze Abfragen ueber den Index + * idx_status_next, parallel: die Anzahl und der aelteste faellige Eintrag (limit 1, nur $id und naechsterVersuch). Scheitert eine, + * wirft die Funktion; der Aufrufer meldet den Lesefehler. + * @param {Date} now + * @returns {Promise<{ wartet: number|null, aeltesteMin: number }>} wartet ist null, wenn Appwrite keine Anzahl liefert + */ +export async function warteschlangeAlter(now) { + const nowMs = pruefeNow(now, 'warteschlangeAlter') + const [wartet, aelteste] = await Promise.all([ + countDocuments(col(), [Query.equal('status', 'wartet')]), + listDocuments(col(), [ + Query.equal('status', 'wartet'), + Query.lessThanEqual('naechsterVersuch', iso(nowMs)), + Query.orderAsc('naechsterVersuch'), + Query.limit(1), + Query.select(['$id', 'naechsterVersuch']), + ]), + ]) + const faelligSeit = aelteste.length > 0 ? zeitMs(aelteste[0].naechsterVersuch) : Number.NaN + return { wartet, aeltesteMin: Number.isNaN(faelligSeit) ? 0 : Math.max(0, Math.floor((nowMs - faelligSeit) / MIN_MS)) } +} + /** Nur fuer Tests: wartet, bis alle laufenden Sendeversuche (auch die aus einreihen) beendet sind. */ export async function warteAufVersand() { while (laufend.size > 0) await Promise.allSettled([...laufend]) diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js index 6957c82..74ab663 100644 --- a/test/mailTemplates.test.js +++ b/test/mailTemplates.test.js @@ -503,7 +503,7 @@ test('T07: Hinweis auf nicht genutzte Arbeitszeit nur im Block wartung', () => { }) // Etappe 4: das Admin-Panel ist abgeschaltet, Team-Mails fuehren ins Ticketsystem -const TEAM_FOOTER = ['T01', 'T02', 'T03', 'T04', 'T05', 'T06', 'T07', 'T08', 'T09', 'T10', 'T11', 'T12', 'T13', 'T14', 'T16'] +const TEAM_FOOTER = ['T01', 'T02', 'T03', 'T04', 'T05', 'T06', 'T07', 'T08', 'T09', 'T10', 'T11', 'T12', 'T13', 'T14', 'T16', 'T20'] test('keine Vorlage verlinkt admin.html oder nennt den Admin-Bereich', () => { for (const id of EXPECTED) { diff --git a/test/termineBetrieb.test.js b/test/termineBetrieb.test.js new file mode 100644 index 0000000..f7c0479 --- /dev/null +++ b/test/termineBetrieb.test.js @@ -0,0 +1,500 @@ +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) 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, 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') +})