diff --git a/server/index.js b/server/index.js index 53ff4fb..8c94ac2 100644 --- a/server/index.js +++ b/server/index.js @@ -23,6 +23,7 @@ import { purgeViewLog } from './services/customerViewLog.js' import { purgeUnclaimedNewsImages } from './services/newsImages.js' import { runActivation } from './services/siteFeatures.js' import { runAboCheck, runAnalyseReconcile } from './services/analyse.js' +import { fuenfMinutenJob, minutenJob, taeglicherJob } from './services/termine/jobs.js' import giteaWebhookRoutes from './routes/webhook/gitea.js' import pushTimeRoutes from './routes/pushTime.js' import adminGiteaRoutes from './routes/admin/gitea.js' @@ -192,6 +193,21 @@ const server = app.listen(config.port, '0.0.0.0', () => { setTimeout(analyseAboCheck, 2 * 60 * 1000).unref() setInterval(analyseAboCheck, 24 * 60 * 60 * 1000).unref() + // Terminplaner (Modus off: jeder Lauf kehrt sofort zurueck, ohne Abfrage; ein noch laufender Job wird nicht ueberlappt): + // jede Minute abgelaufene Haltungen verfallen lassen und die Mail-Warteschlange senden, alle 5 Minuten Erinnerungen, erledigte + // Termine und vergangene Anfragen, taeglich (3 Minuten nach dem Start beginnend) Loeschfristen und Aufraeumen. Die Jobs melden + // einzelne Fehler selbst; hier faengt der Rahmen alles Unerwartete ab. Im Log stehen keine Gastdaten. + const termineMinutenJob = () => + minutenJob(new Date()).catch((err) => console.warn('[termine] Minutenjob fehlgeschlagen:', err.message)) + setInterval(termineMinutenJob, 60 * 1000).unref() + const termineFuenfMinutenJob = () => + fuenfMinutenJob(new Date()).catch((err) => console.warn('[termine] Fünf-Minuten-Job fehlgeschlagen:', err.message)) + setInterval(termineFuenfMinutenJob, 5 * 60 * 1000).unref() + const termineTagesJob = () => + taeglicherJob(new Date()).catch((err) => console.warn('[termine] Tagesjob fehlgeschlagen:', err.message)) + setTimeout(termineTagesJob, 180 * 1000).unref() + setInterval(termineTagesJob, 24 * 60 * 60 * 1000).unref() + // Neue Gitea-Repos erkennen, Zuordnungs-Mail senden, Erinnerungen/Wiederholungen (alle 10 Minuten) const repoAssign = () => runRepoAssignJob({ now: new Date() }).catch((err) => diff --git a/server/services/termine/ablauf.js b/server/services/termine/ablauf.js index 964d3dc..0626239 100644 --- a/server/services/termine/ablauf.js +++ b/server/services/termine/ablauf.js @@ -441,7 +441,7 @@ export function kannAbsagen(einstellung, buchung, now) { * Zeitpunkt des Vertragsschlusses in ms: bestaetigtAt bei einer sofort buchbaren Leistung, angenommenAt bei einer vom * Betrieb angenommenen Anfrage; NaN, wenn es keinen gibt (auch eine Anfrage, die nur bestaetigt wurde, ist kein Vertrag). */ -function vertragAm(buchung) { +export function vertragAm(buchung) { if (buchung.buchungsart === 'sofort') return zeitMs(buchung.bestaetigtAt) if (buchung.buchungsart === 'anfrage') return zeitMs(buchung.angenommenAt) return Number.NaN diff --git a/server/services/termine/jobs.js b/server/services/termine/jobs.js new file mode 100644 index 0000000..8d1e535 --- /dev/null +++ b/server/services/termine/jobs.js @@ -0,0 +1,394 @@ +import { config } from '../../config.js' +import { Query, deleteDocument, listAllDocuments } from '../appwriteAdmin.js' +import { withLocks } from '../keyedLock.js' +import { gibFreiSicher, iso, vertragAm } from './ablauf.js' +import { + abgelaufeneHaltungen, + aendere, + erinnerungsKandidaten, + hole, + loesche, + loeschKandidaten, + vergangeneAnfragen, + zuErledigen, +} from './buchungen.js' +import { kontextPerFeature, ladeEinstellung } from './kontext.js' +import { anGast } from './mails.js' +import { beendeFestgefahrene, raeumeAuf, verarbeite } from './outbox.js' +import { termineAus } from './schalter.js' +import { buchungsNummer } from './token.js' +import { pruefeNow, zeitMs } from './util.js' +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 + * 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 + * + * Gemeinsame Regeln: + * - Modus off: jeder Job kehrt sofort zurueck, ohne Abfrage, mit { aus: true }. + * - Keine ueberlappenden Laeufe: laeuft ein Job noch, wird der naechste Takt uebersprungen ({ uebersprungen: true }, einmal im + * Log je Ueberlappung). + * - Ein Schritt eines Jobs scheitert nie den ganzen Job: der Fehler steht im Log (Text des Fehlers, bei einer Buchung mit + * Buchungsnummer, nie mit Gastdaten), `fehler` im Ergebnis zaehlt ihn, die uebrigen Schritte laufen. Eine fehlende Collection + * (404 collection_not_found, Einrichtung unvollstaendig) meldet ein Job hoechstens einmal je Stunde. + * - Jede Zustandsaenderung einer Buchung laeuft unter `termine-buchung:` mit frischem Lesen und erneuter Pruefung der + * Bedingung (Verfall nur, solange noch `gehalten` mit heldUntil <= jetzt usw.): ein Gast, der gerade bestaetigt, und der Job + * ergeben genau einen Endzustand. Mails gehen erst nach der Aenderung und ausserhalb der Sperre in die Warteschlange. + * - Zellen gibt nur zellen.js frei (gibZellenFrei / raeumeVerwaisteZellen, jeweils unter der Ressourcensperre). + */ + +const MIN_MS = 60 * 1000 +const STUNDE_MS = 60 * MIN_MS +const TAG_MS = 24 * STUNDE_MS +const VERFALLEN_BEHALTEN_MS = 48 * STUNDE_MS +const ABGESAGT_BEHALTEN_MS = 30 * TAG_MS +const ABGESCHALTET_BEHALTEN_MS = 30 * TAG_MS +/** Aufbewahrung von erledigt/nichterschienen: Vorgabe und erlaubter Bereich (Tage nach Terminende). */ +const AUFBEWAHRUNG_VORGABE = 90 +const AUFBEWAHRUNG_MIN = 14 +const AUFBEWAHRUNG_MAX = 365 + +const laufend = new Set() +const ueberlappGemeldet = new Set() +/** Zeitpunkt (ms) der letzten Warnung wegen fehlender Collection je Job. */ +const letzteWarnung = new Map() + +const fehlendeCollection = (err) => err?.status === 404 && (err.type === 'collection_not_found' || err.type === 'database_not_found') +const fehlerText = (err) => `${err?.name ?? 'Fehler'} ${String(err?.message ?? '').slice(0, 200)}` + +/** Fehler eines Schritts ins Log (ohne Gastdaten) und in die Zaehlung; fehlende Collection hoechstens einmal je Stunde und Job. */ +function melde(lauf, was, err, bookingId = null) { + lauf.fehler += 1 + if (fehlendeCollection(err)) { + const zuletzt = letzteWarnung.get(lauf.job) + if (zuletzt !== undefined && lauf.nowMs - zuletzt < STUNDE_MS) return + letzteWarnung.set(lauf.job, lauf.nowMs) + console.warn(`[termine-jobs] ${lauf.job}: ${err.type}, die Termine-Einrichtung ist unvollständig (Warnung höchstens einmal je Stunde)`) + return + } + const nummer = bookingId ? ` (${buchungsNummer(bookingId)})` : '' + console.error(`[termine-jobs] ${lauf.job}: ${was}${nummer}: ${fehlerText(err)}`) +} + +/** Fuehrt einen Schritt aus; ein Fehler wird gemeldet und ergibt `ersatz`. */ +async function schritt(lauf, was, fn, ersatz) { + try { + return await fn() + } catch (err) { + melde(lauf, was, err) + return ersatz + } +} + +/** + * Rahmen jedes Jobs: Schalter, Ueberlappung, Laufdaten. => Ergebnis von arbeit(lauf) | { aus: true } | { uebersprungen: true } + */ +async function fuehreAus(job, now, arbeit) { + const nowMs = pruefeNow(now, job) + if (termineAus()) return { aus: true } + if (laufend.has(job)) { + if (!ueberlappGemeldet.has(job)) { + ueberlappGemeldet.add(job) + console.warn(`[termine-jobs] ${job}: der vorige Lauf ist noch nicht fertig, dieser Takt wird übersprungen`) + } + return { uebersprungen: true } + } + laufend.add(job) + try { + return await arbeit({ job, nowMs, fehler: 0 }) + } finally { + laufend.delete(job) + ueberlappGemeldet.delete(job) + } +} + +/** Laufdaten fuer einen Baustein, der ohne Job-Rahmen aufgerufen wird (Tests). */ +const einzelLauf = (job, now) => ({ job, nowMs: pruefeNow(now, job), fehler: 0 }) + +/** + * Eine Buchung unter ihrer Sperre, frisch gelesen: fn(frisch) laeuft nur, wenn es sie noch gibt. + * => Ergebnis von fn | false (Buchung fehlt) | undefined (Fehler, im Log; die Liste laeuft weiter) + */ +async function mitFrischer(lauf, was, id, fn) { + try { + return await withLocks([`termine-buchung:${id}`], async () => { + const frisch = await hole(id) + return frisch ? fn(frisch) : false + }) + } catch (err) { + melde(lauf, was, err, id) + return undefined + } +} + +// ---------------------------------------------------------------- Minutenjob + +/** Abgelaufene Haltungen: verfallen und Zellen frei – nur, wenn die Buchung unter der Sperre noch `gehalten` und abgelaufen ist. */ +async function verfalleHaltungen(lauf, now) { + let verfallen = 0 + for (const kandidat of await abgelaufeneHaltungen(now)) { + const ok = await mitFrischer(lauf, 'Haltung nicht verfallen', kandidat.$id, async (frisch) => { + if (frisch.status !== 'gehalten' || !(zeitMs(frisch.heldUntil) <= lauf.nowMs)) return false + await aendere(frisch.$id, { status: 'verfallen', updatedAt: iso(now) }) + await gibFreiSicher(frisch.$id) + return true + }) + if (ok) verfallen += 1 + } + return verfallen +} + +/** + * Jede Minute: abgelaufene Haltungen verfallen lassen, Warteschlange senden. + * @param {Date} now + * @returns {Promise<{ verfallen: number, faellig: number, gesendet: number, fehler: number } | { aus: true } | { uebersprungen: true }>} + */ +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 }) + return { verfallen, faellig, gesendet, fehler: lauf.fehler } + }) +} + +// ---------------------------------------------------------------- Fuenf-Minuten-Job + +/** + * Ist die Erinnerung jetzt zu senden? bestaetigt, Wunsch, kein Marker, Adresse da, Beginn in (jetzt + 1 h, jetzt + stundenVorher] + * und Vertragsschluss VOR Beginn - stundenVorher (wer erst danach gebucht hat, bekommt keine). Ohne Vertragsschluss nein. + */ +function erinnerungFaellig(buchung, stundenVorher, nowMs) { + if (buchung.status !== 'bestaetigt' || buchung.erinnerung !== true || buchung.erinnerungGesendetAt) return false + if (typeof buchung.gastEmail !== 'string' || buchung.gastEmail === '') return false + const start = zeitMs(buchung.startsAt) + const fruehestens = start - stundenVorher * STUNDE_MS + if (!(start > nowMs + STUNDE_MS && fruehestens <= nowMs)) return false + return vertragAm(buchung) < fruehestens +} + +/** + * Erinnerungen: unter der Buchungssperre frisch lesen und pruefen, erst den Marker erinnerungGesendetAt setzen, dann (ausserhalb + * der Sperre) G05 einreihen. Scheitert das Einreihen, bleibt der Marker: eine Erinnerung geht hoechstens einmal raus. + * Der Kontext einer Funktion (Einstellung mit stundenVorher) wird je Lauf einmal gelesen. Ohne Funktion oder Einstellung: keine + * Erinnerung, kein Marker. + */ +async function sendeErinnerungen(lauf, now) { + const kontexte = new Map() + const kontext = (featureId) => { + if (!kontexte.has(featureId)) { + kontexte.set( + featureId, + kontextPerFeature(featureId).catch((err) => { + melde(lauf, 'Funktion nicht gelesen', err) + return null + }), + ) + } + return kontexte.get(featureId) + } + + let gesendet = 0 + for (const kandidat of await erinnerungsKandidaten(now)) { + const id = kandidat.$id + const ctx = await kontext(kandidat.featureId) + const stundenVorher = ctx?.einstellung?.erinnerung?.stundenVorher + if (!Number.isInteger(stundenVorher) || stundenVorher < 1) continue + if (!erinnerungFaellig(kandidat, stundenVorher, lauf.nowMs)) continue + + const markiert = await mitFrischer(lauf, 'Erinnerung nicht vermerkt', id, async (frisch) => + erinnerungFaellig(frisch, stundenVorher, lauf.nowMs) ? aendere(id, { erinnerungGesendetAt: iso(now), updatedAt: iso(now) }) : false, + ) + if (!markiert) continue + try { + await anGast('G05', ctx, markiert, {}, now) + gesendet += 1 + } catch (err) { + melde(lauf, 'G05 nicht eingereiht', err, id) + } + } + return gesendet +} + +/** bestaetigt mit Ende vor mehr als 1 Stunde (auch Sperren) => erledigt. Zellen bleiben (vergangen, der Tagesjob raeumt sie). */ +async function erledige(lauf, now) { + let erledigt = 0 + for (const kandidat of await zuErledigen(now)) { + const ok = await mitFrischer(lauf, 'Termin nicht erledigt', kandidat.$id, async (frisch) => { + if (frisch.status !== 'bestaetigt' || !(zeitMs(frisch.endsAt) < lauf.nowMs - STUNDE_MS)) return false + await aendere(frisch.$id, { status: 'erledigt', updatedAt: iso(now) }) + return true + }) + if (ok) erledigt += 1 + } + return erledigt +} + +/** angefragt mit Beginn in der Vergangenheit => abgelehnt vom System, Zellen frei, keine Mail. */ +async function lehneVergangeneAnfragenAb(lauf, now) { + let abgelehnt = 0 + for (const kandidat of await vergangeneAnfragen(now)) { + const ok = await mitFrischer(lauf, 'Anfrage nicht abgelehnt', kandidat.$id, async (frisch) => { + if (frisch.status !== 'angefragt' || !(zeitMs(frisch.startsAt) < lauf.nowMs)) return false + await aendere(frisch.$id, { status: 'abgelehnt', abgesagtVon: 'system', updatedAt: iso(now) }) + await gibFreiSicher(frisch.$id) + return true + }) + if (ok) abgelehnt += 1 + } + return abgelehnt +} + +/** + * Alle 5 Minuten: Erinnerungen, erledigte Termine, vergangene Anfragen. + * @param {Date} now + * @returns {Promise<{ erinnerungen: number, erledigt: number, abgelehnt: number, fehler: number } | { aus: true } | { uebersprungen: true }>} + */ +export async function fuenfMinutenJob(now) { + return fuehreAus('fuenfMinutenJob', now, async (lauf) => { + const erinnerungen = await schritt(lauf, 'Erinnerungen', () => sendeErinnerungen(lauf, now), 0) + const erledigt = await schritt(lauf, 'Erledigt', () => erledige(lauf, now), 0) + const abgelehnt = await schritt(lauf, 'Vergangene Anfragen', () => lehneVergangeneAnfragenAb(lauf, now), 0) + return { erinnerungen, erledigt, abgelehnt, fehler: lauf.fehler } + }) +} + +// ---------------------------------------------------------------- Tagesjob + +/** + * Aufbewahrung (Tage nach Terminende) einer Funktion. Nicht gespeichert oder unlesbar (null aus ladeEinstellung) oder ausserhalb + * 14-365: 90. Scheitert das Lesen selbst (Datenbank), null: die Buchungen dieser Funktion bleiben bis zum naechsten Lauf, es wird + * nie frueher geloescht als eingestellt. + */ +async function aufbewahrungTage(lauf, featureId) { + if (typeof featureId !== 'string' || featureId === '') return AUFBEWAHRUNG_VORGABE + try { + const tage = (await ladeEinstellung(featureId))?.einstellung?.aufbewahrungTage + return Number.isInteger(tage) && tage >= AUFBEWAHRUNG_MIN && tage <= AUFBEWAHRUNG_MAX ? tage : AUFBEWAHRUNG_VORGABE + } catch (err) { + melde(lauf, 'Aufbewahrung nicht lesbar, Funktion bleibt unberührt', err) + return null + } +} + +/** Buchung samt Zellen loeschen, wenn sie unter der Sperre noch im erwarteten Status ist (status: null = jeder). => true | false | undefined (Fehler) */ +function loescheMitZellen(lauf, id, status) { + return mitFrischer(lauf, 'Buchung nicht gelöscht', id, async (frisch) => { + if (status !== null && frisch.status !== status) return false + await gibZellenFrei(id) + await loesche(id) + return true + }) +} + +async function loeschfristen(lauf, now) { + const geloescht = { verfallen: 0, abgesagt: 0, abgelehnt: 0, erledigt: 0, nichterschienen: 0 } + const tage = new Map() + const tageFuer = (featureId) => { + if (!tage.has(featureId)) tage.set(featureId, aufbewahrungTage(lauf, featureId)) + return tage.get(featureId) + } + // Je Status: Kandidaten ab der fruehesten moeglichen Frist; bei erledigt/nichterschienen entscheidet die Frist der Funktion + const fristen = [ + ['verfallen', VERFALLEN_BEHALTEN_MS, false], + ['abgesagt', ABGESAGT_BEHALTEN_MS, false], + ['abgelehnt', ABGESAGT_BEHALTEN_MS, false], + ['erledigt', AUFBEWAHRUNG_MIN * TAG_MS, true], + ['nichterschienen', AUFBEWAHRUNG_MIN * TAG_MS, true], + ] + for (const [status, behalten, jeFunktion] of fristen) { + await schritt(lauf, `Löschfrist ${status}`, async () => { + for (const kandidat of await loeschKandidaten(status, new Date(lauf.nowMs - behalten))) { + if (jeFunktion) { + const t = await tageFuer(kandidat.featureId) + if (t === null || !(zeitMs(kandidat.endsAt) < lauf.nowMs - t * TAG_MS)) continue + } + if (await loescheMitZellen(lauf, kandidat.$id, status)) geloescht[status] += 1 + } + }) + } + return geloescht +} + +/** + * Funktionen im Zustand `disabled` seit mehr als 30 Tagen (disabledAt): alle Buchungen mit Zellen und zuletzt die Einstellung + * loeschen. Scheitert eine Buchung, bleibt die Einstellung bis zum naechsten Lauf. Zellen ohne Buchung raeumt danach der Tagesjob. + */ +async function loescheAbgeschaltete(lauf) { + const erg = { funktionen: 0, buchungen: 0 } + const grenze = lauf.nowMs - ABGESCHALTET_BEHALTEN_MS + const funktionen = await listAllDocuments(config.collections.portalFeatures, [Query.equal('featureKey', 'termine'), Query.equal('state', 'disabled')]) + for (const funktion of funktionen) { + if (!(zeitMs(funktion.disabledAt) < grenze)) continue + erg.funktionen += 1 + const buchungen = await schritt(lauf, 'Buchungen abgeschalteter Funktion', () => listAllDocuments(config.collections.termineBookings, [Query.equal('featureId', funktion.$id)]), null) + if (buchungen === null) continue + let vollstaendig = true + for (const buchung of buchungen) { + const weg = await loescheMitZellen(lauf, buchung.$id, null) + if (weg) erg.buchungen += 1 + if (weg === undefined) vollstaendig = false + } + if (vollstaendig) { + await schritt(lauf, 'Einstellung abgeschalteter Funktion', () => + withLocks([`termine-cfg:${funktion.$id}`], async () => { + try { + await deleteDocument(config.collections.termineConfig, funktion.$id) + } catch (err) { + if (err?.status !== 404) throw err + } + }), + ) + } + } + return erg +} + +/** + * Loeschfristen (Spezifikation 11) fuer Buchungen: verfallen 48 Stunden nach heldUntil, abgesagt/abgelehnt 30 Tage nach der letzten + * Aenderung, erledigt/nichterschienen aufbewahrungTage der Funktion (Vorgabe 90) nach Terminende; jeweils samt Zellen. Baustein des + * Tagesjobs, ohne Schalter- und Ueberlappungspruefung (fuer Tests). + * @param {Date} now + * @returns {Promise<{ verfallen: number, abgesagt: number, abgelehnt: number, erledigt: number, nichterschienen: number }>} + */ +export function wendeLoeschfristenAn(now) { + return loeschfristen(einzelLauf('loeschfristen', now), now) +} + +/** + * Abgeschaltete Funktionen nach 30 Tagen: Buchungen, Zellen, Einstellung loeschen. Baustein des Tagesjobs, ohne Schalter- und + * Ueberlappungspruefung (fuer Tests). + * @param {Date} now + * @returns {Promise<{ funktionen: number, buchungen: number }>} + */ +export function loescheAbgeschalteteFunktionen(now) { + return loescheAbgeschaltete(einzelLauf('abgeschalteteFunktionen', now)) +} + +/** + * Taeglich: festgefahrene Mails beenden (vor dem Aufraeumen, damit sie 48 Stunden lang als Zustellfehler sichtbar bleiben), + * Loeschfristen, abgeschaltete Funktionen, alte Warteschlangen-Eintraege, verwaiste Zellen. + * @param {Date} now + * @returns {Promise} + */ +export async function taeglicherJob(now) { + return fuehreAus('taeglicherJob', now, async (lauf) => { + const festgefahren = await schritt(lauf, 'Festgefahrene Mails', () => beendeFestgefahrene(now), 0) + const loeschung = await loeschfristen(lauf, now) + const abgeschaltet = await schritt(lauf, 'Abgeschaltete Funktionen', () => loescheAbgeschaltete(lauf), { funktionen: 0, buchungen: 0 }) + const aufgeraeumt = await schritt(lauf, 'Warteschlange aufräumen', () => raeumeAuf(now), 0) + const zellen = await schritt(lauf, 'Verwaiste Zellen', () => raeumeVerwaisteZellen(now), 0) + return { festgefahren, loeschfristen: loeschung, abgeschaltet, aufgeraeumt, zellen, fehler: lauf.fehler } + }) +} + +/** Nur fuer Tests: Ueberlappungsmerker und Warnungs-Zeitpunkte zuruecksetzen. */ +export function resetJobsForTests() { + laufend.clear() + ueberlappGemeldet.clear() + letzteWarnung.clear() +} diff --git a/server/services/termine/outbox.js b/server/services/termine/outbox.js index 2e268a1..15e04ae 100644 --- a/server/services/termine/outbox.js +++ b/server/services/termine/outbox.js @@ -3,7 +3,7 @@ import { Query, createDocument, deleteDocument, getDocument, listAllDocuments, u import { withLocks } from '../keyedLock.js' import { sendGuestMail } from '../mailer.js' import { renderMail } from '../mailTemplates.js' -import { pruefeNow, pruefeText } from './util.js' +import { pruefeNow, pruefeText, zeitMs } from './util.js' /** * Mail-Warteschlange des Terminplaners (termineOutbox; Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 6). @@ -39,6 +39,8 @@ const WIEDERHOLUNG_MS = [1 * MIN_MS, 5 * MIN_MS, 30 * MIN_MS] const ANMELDE_PAUSE_MS = 15 * MIN_MS const GESENDET_BEHALTEN_MS = 7 * TAG_MS const FEHLER_BEHALTEN_MS = 48 * STUNDE_MS +/** Laenger als das in `wartet` (ab createdAt) ist ein Eintrag festgefahren (beendeFestgefahrene). */ +const WARTEN_MAX_MS = 48 * STUNDE_MS const ICS_METHODEN = ['REQUEST', 'CANCEL', 'PUBLISH'] /** Buchungs-IDs je Abfrage in zustellFehler (haelt die Adresse kurz). */ const ID_BLOCK = 50 @@ -314,6 +316,41 @@ export async function raeumeAuf(now) { return geloescht } +/** + * Taeglicher Job, vor raeumeAuf: Eintraege, die laenger als 48 Stunden (ab createdAt) in `wartet` stehen, werden zu + * `fehler` (das Portal zeigt dann den Zustellhinweis) und bekommen als Loeschtermin jetzt + 48 Stunden, damit raeumeAuf + * sie erst dann loescht und nicht noch im selben Lauf. Je Eintrag unter der Sendesperre `termine-mail:` nach neuem + * Lesen: ein Eintrag, der inzwischen gesendet wurde (oder dessen Versand gerade laeuft), bleibt, wie er ist. + * @param {Date} now + * @returns {Promise} Anzahl beendeter Eintraege + */ +export async function beendeFestgefahrene(now) { + const nowMs = pruefeNow(now, 'beendeFestgefahrene') + const grenze = nowMs - WARTEN_MAX_MS + const alt = (await listAllDocuments(col(), [Query.equal('status', 'wartet')])).filter((eintrag) => zeitMs(eintrag.createdAt) < grenze) + let beendet = 0 + for (const eintrag of alt) { + const geaendert = await withLocks([`termine-mail:${eintrag.$id}`], async () => { + let frisch + try { + frisch = await getDocument(col(), eintrag.$id) + } catch (err) { + if (err.status === 404) return false + throw err + } + if (frisch.status !== 'wartet' || !(zeitMs(frisch.createdAt) < grenze) || unverbucht.has(frisch.$id)) return false + await updateDocument(col(), frisch.$id, { + status: 'fehler', + naechsterVersuch: iso(nowMs + FEHLER_BEHALTEN_MS), + fehler: 'Nicht zugestellt: länger als 48 Stunden in der Warteschlange.', + }) + return true + }) + if (geaendert) beendet += 1 + } + return beendet +} + /** Ende der letzten Anmeldepause (Date) oder null, wenn es noch keine gab. Eine Pause gilt, solange das Ende nach `now` liegt. */ export function pausiertBis() { return pauseBisMs ? new Date(pauseBisMs) : null diff --git a/test/termineJobs.test.js b/test/termineJobs.test.js new file mode 100644 index 0000000..e1dbd6c --- /dev/null +++ b/test/termineJobs.test.js @@ -0,0 +1,930 @@ +import { test, after, beforeEach, afterEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +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' + +// Hintergrund-Jobs des Terminplaners (Minuten-, Fuenf-Minuten- und Tagesjob): Nachbau von Appwrite mit Schema und +// Standard-Limit 25, Mails landen in der Senke, 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 { normalisiereEinstellung } = await import('../server/services/termine/einstellungen.js') +const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js') +const { neuesToken, verwaltungsToken, buchungsNummer } = await import('../server/services/termine/token.js') +const { speichereEinstellung, resetKontextForTests } = await import('../server/services/termine/kontext.js') +const { belegeZellen } = await import('../server/services/termine/zellen.js') +const { bestaetige } = await import('../server/services/termine/ablauf.js') +const J = await import('../server/services/termine/jobs.js') +const { minutenJob, fuenfMinutenJob, taeglicherJob, wendeLoeschfristenAn, loescheAbgeschalteteFunktionen, resetJobsForTests } = J +after(() => setMailTransportForTests(null)) + +const MIN = 60000 +const STUNDE = 60 * MIN +const TAG = 24 * STUNDE +const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin +const KUNDE = 'c_kunde01' +const PROJEKT = 'p_projekt01' +const F1 = 'f_p_projekt01_termine' +const F2 = 'f_p_projekt02_termine' +const F3 = 'f_p_projekt03_termine' +const R1 = 'r_aaaaaaa1' +const S1 = 's_bbbbbbb1' +const ABSENDER_VORHER = config.smtp.from +const MODUS_VORHER = config.termine.mode +const BASIS_VORHER = config.publicBaseUrl + +const iso = (wert) => new Date(wert).toISOString() +const plus = (von, ms) => new Date(new Date(von).getTime() + ms) +const ms = (wert) => new Date(wert).getTime() + +const feature = (id = F1, over = {}) => ({ + $id: id, + customerId: KUNDE, + projectId: id === F1 ? PROJEKT : `p_${id}`, + featureKey: 'termine', + state: 'active', + enabled: true, + siteKey: 'abcdefghijklmnop', + ...over, +}) + +const eingabeEinstellung = (over = {}) => ({ + betrieb: { + name: 'Salon Müller', + email: 'info@salon.de', + telefon: '06301 1234', + adresse: 'Hauptstr. 1, 67722 Winnweiler', + websiteUrl: 'https://salon.de', + impressumUrl: 'https://salon.de/impressum', + datenschutzUrl: 'https://salon.de/datenschutz', + }, + anrede: 'sie', + bundesland: 'RP', + ressourcen: [{ id: R1, name: 'Anna', plan: { 1: [['09:00', '18:00']] } }], + leistungen: [{ id: S1, name: 'Damenschnitt', dauer: 45, pufferNach: 5, preisArt: 'fest', preisCent: 4500, buchungsart: 'sofort', ressourcen: [R1] }], + regeln: { raster: 15, vorlaufMin: 120, horizontTage: 90, absageBisStd: 24, maxOffeneJeEmail: 3, personWaehlbar: true }, + gastFelder: { telefon: 'optional', nachricht: 'aus', zusatz: [] }, + erinnerung: { anbieten: true, stundenVorher: 24 }, + storno: { text: 'Kostenlose Absage bis 24 Stunden vor dem Termin.' }, + recht: { gaesteSind: 'verbraucher', freizeit: false }, + zahlung: { modus: 'vorort', vorOrtArten: ['bar', 'ec'] }, + ...over, +}) +const einstellung = (over) => { + const erg = normalisiereEinstellung(eingabeEinstellung(over), { now: JETZT }) + assert.equal(erg.fehler, undefined, JSON.stringify(erg.fehler)) + return erg.einstellung +} + +let sink +let log +/** Funktion samt Einstellung anlegen (portalFeatures + termineConfig). */ +async function richteFunktionEin(id = F1, over = {}, einstellungOver = {}) { + aw.seed('portalFeatures', feature(id, over)) + await speichereEinstellung(feature(id, over), einstellung(einstellungOver), { version: 0, now: JETZT }) +} +/** Wie richteFunktionEin, aber nur die Einstellung (die Funktion selbst ist schon angelegt). */ +async function speichere(id, einstellungOver) { + const doc = aw.docs('termineConfig').get(id) + await speichereEinstellung(feature(id), einstellung(einstellungOver), { version: doc ? doc.version : 0, now: JETZT }) +} + +beforeEach(async () => { + for (const collection of ['portalFeatures', 'termineConfig', 'termineBookings', 'termineClaims', 'termineOutbox', 'customers', 'websiteProjects']) { + for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id) + } + resetKontextForTests() + resetOutboxForTests() + resetJobsForTests() + config.termine.mode = 'pilot' + config.smtp.from = 'noreply@webklar.com' + config.publicBaseUrl = 'https://project.webklar.com' + sink = mailSink() + setMailTransportForTests(sink) + log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}) } + aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, subdomain: 'salon' }) + aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: 'silke@example.com' }) +}) +afterEach(async () => { + await warteAufVersand() + // Logs enthalten nie personenbezogene Daten der Gaeste (Name, Adresse) + const text = [...log.error.mock.calls, ...log.warn.mock.calls].map((aufruf) => aufruf.arguments.map(String).join(' ')).join('\n') + mock.restoreAll() + config.termine.mode = MODUS_VORHER + config.smtp.from = ABSENDER_VORHER + config.publicBaseUrl = BASIS_VORHER + assert.doesNotMatch(text, /Mustermann|max@example\.com/i, 'kein Gastname und keine Gastadresse im Log') +}) + +// ---------------------------------------------------------------- Hilfen + +/** + * Buchung samt Zellen anlegen. start (Date) oder ende (Date; dann beginnt sie 45 Minuten vorher); Voreinstellung: bestaetigt, + * Termin in 3 Tagen, eigene Ressource je Buchung (kein Zellenkonflikt). zellen: false = nur das Dokument. + */ +async function legeBuchung(id, { start, ende, zellen = true, ...over } = {}) { + const beginn = start ?? (ende ? plus(ende, -45 * MIN) : plus(JETZT, 3 * TAG)) + const doc = { + $id: id, + featureId: F1, + customerId: KUNDE, + projectId: PROJEKT, + leistungId: S1, + leistungName: 'Damenschnitt', + ressourceId: `r_${id}`, + ressourceName: 'Anna', + startsAt: iso(beginn), + endsAt: iso(plus(beginn, 45 * MIN)), + blockEndsAt: iso(plus(beginn, 50 * MIN)), + status: 'bestaetigt', + quelle: 'online', + gastName: 'Max Mustermann', + gastEmail: 'max@example.com', + gastTelefon: '', + gastNachricht: '', + gastZusatz: '', + erinnerung: false, + buchungsart: 'sofort', + preisArt: 'keiner', + preisCent: 0, + verbraucher: false, + icsSequenz: 0, + bestaetigtAt: iso(plus(beginn, -10 * TAG)), + createdAt: iso(plus(beginn, -10 * TAG)), + updatedAt: iso(plus(beginn, -10 * TAG)), + ...over, + } + aw.seed('termineBookings', doc) + if (zellen) { + await belegeZellen({ featureId: doc.featureId, ressourceId: doc.ressourceId, bookingId: id, start: new Date(doc.startsAt), blockEnde: new Date(doc.blockEndsAt), now: JETZT }) + } + return doc +} +/** Bestaetigte Buchung mit Erinnerungswunsch; Vertragsschluss 3 Tage vor `jetzt`. */ +const mitErinnerung = (id, abJetzt, over = {}) => + legeBuchung(id, { start: plus(JETZT, abJetzt), erinnerung: true, bestaetigtAt: iso(plus(JETZT, -3 * TAG)), ...over }) + +const buchungen = () => [...aw.docs('termineBookings').values()] +const buchung = (id) => aw.docs('termineBookings').get(id) +const status = (id) => buchung(id)?.status +const claims = (bookingId) => [...aw.docs('termineClaims').values()].filter((c) => c.bookingId === bookingId) +const eintraege = () => [...aw.docs('termineOutbox').values()] +const fehlerLog = () => log.error.mock.calls.map((aufruf) => aufruf.arguments.map(String).join(' ')) +const warnLog = () => log.warn.mock.calls.map((aufruf) => aufruf.arguments.map(String).join(' ')) + +/** Abgelegte Warteschlangen-Eintraege direkt in der Datenbank (ohne Sendeversuch). */ +function legeEintrag(id, over = {}) { + aw.seed('termineOutbox', { + $id: id, + featureId: F1, + bookingId: 'b_direkt', + art: 'G05', + an: 'max@example.com', + fromName: 'Salon Müller', + replyTo: 'info@salon.de', + subject: 'Erinnerung: Ihr Termin', + html: '

Hallo

', + text: 'Hallo', + ics: '', + icsMethod: '', + status: 'wartet', + versuche: 0, + naechsterVersuch: iso(JETZT), + fehler: '', + createdAt: iso(JETZT), + ...over, + }) +} + +/** Anzahl der Anfragen an den Appwrite-Nachbau, die fn ausloest. */ +async function zaehleAnfragen(fn, muster = null) { + const echt = globalThis.fetch + let n = 0 + globalThis.fetch = (url, optionen) => { + if (!muster || muster.test(String(url))) n += 1 + return echt(url, optionen) + } + try { + await fn() + } finally { + globalThis.fetch = echt + } + return n +} + +/** + * Nach der ersten Antwort auf eine Anfrage, auf die passt(urlText, methode) zutrifft, laeuft zuerst dazwischen() zu Ende, + * dann erst bekommt der Aufrufer die (inzwischen veraltete) Antwort: ein anderer Vorgang schiebt sich dazwischen. + */ +async function mitEinwurf(passt, dazwischen, fn) { + const echt = globalThis.fetch + let offen = true + globalThis.fetch = async (url, optionen) => { + const antwort = await echt(url, optionen) + if (offen && passt(decodeURIComponent(String(url)), optionen?.method ?? 'GET')) { + offen = false + globalThis.fetch = echt + await dazwischen() + } + return antwort + } + try { + return await fn() + } finally { + globalThis.fetch = echt + } +} +const listeVonBuchungen = (muster) => (url, methode) => + methode === 'GET' && /\/collections\/termineBookings\/documents(\?|$)/.test(url) && muster.test(url) + +/** Alle Lese-Anfragen an die Collection scheitern wie in Appwrite mit 404 collection_not_found. */ +async function mitFehlenderCollection(collection, fn) { + const echt = globalThis.fetch + globalThis.fetch = async (url, optionen) => { + if (new RegExp(`/collections/${collection}/documents`).test(String(url)) && (optionen?.method ?? 'GET') === 'GET') { + const body = { message: 'Collection with the requested ID could not be found.', code: 404, type: 'collection_not_found' } + return new Response(JSON.stringify(body), { status: 404, headers: { 'content-type': 'application/json' } }) + } + return echt(url, optionen) + } + try { + return await fn() + } finally { + globalThis.fetch = echt + } +} + +// ---------------------------------------------------------------- Schalter + +test('Modus off: kein Job fragt Appwrite oder sendet, auch nicht bei fälligen Daten', async () => { + await richteFunktionEin() + await legeBuchung('b_abgelaufen', { status: 'gehalten', heldUntil: iso(plus(JETZT, -5 * MIN)) }) + await mitErinnerung('b_erinnern', 23 * STUNDE) + legeEintrag('o_wartet') + config.termine.mode = 'off' + + const n = await zaehleAnfragen(async () => { + for (const job of [minutenJob, fuenfMinutenJob, taeglicherJob]) assert.deepEqual(await job(JETZT), { aus: true }) + }) + assert.equal(n, 0, 'keine einzige Anfrage an Appwrite') + assert.equal(status('b_abgelaufen'), 'gehalten') + assert.equal(buchung('b_erinnern').erinnerungGesendetAt, undefined) + assert.equal(sink.sent.length, 0) + assert.equal(eintraege()[0].status, 'wartet') +}) + +test('jeder Job verlangt eine gültige Uhrzeit', async () => { + for (const job of [minutenJob, fuenfMinutenJob, taeglicherJob]) await assert.rejects(() => job(), TypeError) +}) + +// ---------------------------------------------------------------- Minutenjob + +test('minutenJob: abgelaufene Haltung ⇒ verfallen und Zellen frei; gültige Haltung und andere Buchungen bleiben', async () => { + await legeBuchung('b_genau', { status: 'gehalten', heldUntil: iso(JETZT) }) + await legeBuchung('b_vorbei', { status: 'gehalten', heldUntil: iso(plus(JETZT, -5 * MIN)) }) + await legeBuchung('b_gueltig', { status: 'gehalten', heldUntil: iso(plus(JETZT, 1)) }) + await legeBuchung('b_fest', { status: 'bestaetigt' }) + assert.ok(claims('b_genau').length > 0) + + const erg = await minutenJob(JETZT) + assert.equal(erg.verfallen, 2) + assert.equal(erg.fehler, 0) + assert.equal(status('b_genau'), 'verfallen') + assert.equal(status('b_vorbei'), 'verfallen') + assert.equal(ms(buchung('b_vorbei').updatedAt), JETZT.getTime(), 'updatedAt = jetzt (feste Uhr)') + assert.equal(claims('b_genau').length, 0) + assert.equal(claims('b_vorbei').length, 0) + assert.equal(status('b_gueltig'), 'gehalten') + assert.ok(claims('b_gueltig').length > 0) + assert.equal(status('b_fest'), 'bestaetigt') + assert.ok(claims('b_fest').length > 0) + + await warteAufVersand() + assert.equal(sink.sent.length, 0, 'ein Verfall verschickt nichts') + assert.deepEqual((await minutenJob(JETZT)).verfallen, 0, 'zweiter Lauf: nichts mehr zu tun') +}) + +test('minutenJob: eine Haltung, die unter der Sperre nicht mehr abgelaufen ist, verfällt nicht', async () => { + const doc = await legeBuchung('b_race', { status: 'gehalten', heldUntil: iso(plus(JETZT, -MIN)) }) + const erg = await mitEinwurf( + listeVonBuchungen(/heldUntil/), + async () => aw.seed('termineBookings', { ...doc, heldUntil: iso(plus(JETZT, 10 * MIN)) }), + () => minutenJob(JETZT), + ) + assert.equal(erg.verfallen, 0) + assert.equal(status('b_race'), 'gehalten') + assert.ok(claims('b_race').length > 0) +}) + +test('minutenJob: sendet fällige Einträge der Warteschlange', async () => { + legeEintrag('o_faellig') + legeEintrag('o_spaeter', { naechsterVersuch: iso(plus(JETZT, 10 * MIN)) }) + const erg = await minutenJob(JETZT) + assert.equal(erg.gesendet, 1) + assert.equal(sink.sent.length, 1) + assert.equal(aw.docs('termineOutbox').get('o_faellig').status, 'gesendet') + assert.equal(aw.docs('termineOutbox').get('o_spaeter').status, 'wartet') +}) + +test('minutenJob: scheitert die Abfrage der Haltungen, läuft die Warteschlange trotzdem (Fehler im Log, kein Wurf)', async () => { + legeEintrag('o_faellig') + await legeBuchung('b_vorbei', { status: 'gehalten', heldUntil: iso(plus(JETZT, -MIN)) }) + aw.failNext({ collection: 'termineBookings', method: 'GET', status: 500, message: 'Nachbau kaputt' }) + const erg = await minutenJob(JETZT) + assert.equal(erg.fehler, 1) + assert.equal(erg.gesendet, 1) + assert.equal(sink.sent.length, 1) + assert.equal(fehlerLog().length, 1) + assert.match(fehlerLog()[0], /minutenJob/) + assert.equal((await minutenJob(JETZT)).verfallen, 1, 'beim nächsten Lauf geht es weiter') +}) + +// ---------------------------------------------------------------- Erinnerungen + +test('Erinnerung: genau einmal (zwei Läufe, auch gleichzeitig), Marker gesetzt, G05 mit Verwaltungslink', async () => { + await richteFunktionEin() + await mitErinnerung('b_erinnern', 23 * STUNDE) + + const [a, b] = await Promise.all([fuenfMinutenJob(JETZT), fuenfMinutenJob(JETZT)]) + assert.equal([a, b].filter((r) => r.uebersprungen).length, 1, 'der gleichzeitige zweite Lauf wird übersprungen') + assert.equal((await fuenfMinutenJob(JETZT)).erinnerungen, 0) + assert.equal((await fuenfMinutenJob(plus(JETZT, 5 * MIN))).erinnerungen, 0) + await warteAufVersand() + + assert.equal(sink.sent.length, 1) + const mail = sink.sent[0] + assert.equal(mail.to[0].address, 'max@example.com') + assert.match(mail.subject, /^Erinnerung: Ihr Termin am /) + assert.ok(mail.text.includes(`/termin.html#m=${verwaltungsToken('b_erinnern')}`), 'Verwaltungslink') + assert.equal(mail.bcc, undefined) + assert.equal(ms(buchung('b_erinnern').erinnerungGesendetAt), JETZT.getTime()) + assert.equal(eintraege().filter((e) => e.art === 'G05').length, 1) +}) + +test('Erinnerung: Fenster (jetzt + 1 h, jetzt + stundenVorher], Rand genau', async () => { + await richteFunktionEin() // 24 Stunden + const faelle = { + b_drei: [3 * STUNDE, true], + b_vier: [24 * STUNDE, true], + b_zuweit: [24 * STUNDE + 1, false], + b_ueberm: [2 * TAG, false], + b_minus: [STUNDE + 1, true], + b_stunde: [STUNDE, false], + b_naeher: [59 * MIN, false], + b_vorbei: [-MIN, false], + } + for (const [id, [abstand]] of Object.entries(faelle)) await mitErinnerung(id, abstand) + await fuenfMinutenJob(JETZT) + await warteAufVersand() + for (const [id, [, erwartet]] of Object.entries(faelle)) { + assert.equal(Boolean(buchung(id).erinnerungGesendetAt), erwartet, id) + } + assert.equal(sink.sent.length, Object.values(faelle).filter(([, erwartet]) => erwartet).length) +}) + +test('Erinnerung: nicht bei einer Buchung, die erst innerhalb von stundenVorher vor Beginn geschlossen wurde', async () => { + await richteFunktionEin() // 24 Stunden + const start = plus(JETZT, 23 * STUNDE) + await mitErinnerung('b_kurzfristig', 23 * STUNDE, { bestaetigtAt: iso(plus(start, -10 * STUNDE)) }) + await mitErinnerung('b_genau', 23 * STUNDE, { bestaetigtAt: iso(plus(start, -24 * STUNDE)) }) + await mitErinnerung('b_davor', 23 * STUNDE, { bestaetigtAt: iso(plus(start, -24 * STUNDE - 1)) }) + // angenommene Anfrage: der Vertrag beginnt mit angenommenAt, nicht mit der Bestätigung per E-Mail + await mitErinnerung('b_anfrage_frueh', 23 * STUNDE, { buchungsart: 'anfrage', bestaetigtAt: null, angenommenAt: iso(plus(JETZT, -2 * TAG)) }) + await mitErinnerung('b_anfrage_spaet', 23 * STUNDE, { buchungsart: 'anfrage', bestaetigtAt: null, angenommenAt: iso(plus(JETZT, -MIN)) }) + await mitErinnerung('b_ohne_vertrag', 23 * STUNDE, { bestaetigtAt: null }) + + await fuenfMinutenJob(JETZT) + await warteAufVersand() + const gesendet = buchungen().filter((b) => b.erinnerungGesendetAt).map((b) => b.$id).sort() + assert.deepEqual(gesendet, ['b_anfrage_frueh', 'b_davor']) + assert.equal(sink.sent.length, 2) +}) + +test('Erinnerung: wird nach einem Ausfall des Jobs nachgeholt (20 Minuten zu spät)', async () => { + await richteFunktionEin() // 24 Stunden + await mitErinnerung('b_ausfall', 24 * STUNDE) + assert.equal((await fuenfMinutenJob(plus(JETZT, -5 * MIN))).erinnerungen, 0, 'noch nicht fällig') + // der Job lief von JETZT bis JETZT + 20 min nicht + assert.equal((await fuenfMinutenJob(plus(JETZT, 20 * MIN))).erinnerungen, 1) + await warteAufVersand() + assert.equal(sink.sent.length, 1) + assert.equal(ms(buchung('b_ausfall').erinnerungGesendetAt), plus(JETZT, 20 * MIN).getTime()) +}) + +test('Erinnerung: nur bestätigte Buchungen mit Wunsch, ohne Marker und mit Adresse', async () => { + await richteFunktionEin() + await mitErinnerung('b_ohne_wunsch', 5 * STUNDE, { erinnerung: false }) + await mitErinnerung('b_schon', 5 * STUNDE, { erinnerungGesendetAt: iso(plus(JETZT, -TAG)) }) + await mitErinnerung('b_angefragt', 5 * STUNDE, { status: 'angefragt' }) + await mitErinnerung('b_gehalten', 5 * STUNDE, { status: 'gehalten', heldUntil: iso(plus(JETZT, 10 * MIN)) }) + await mitErinnerung('b_abgesagt', 5 * STUNDE, { status: 'abgesagt' }) + await mitErinnerung('b_ohne_adresse', 5 * STUNDE, { gastEmail: '' }) + await mitErinnerung('b_ok', 5 * STUNDE) + await fuenfMinutenJob(JETZT) + await warteAufVersand() + assert.equal(sink.sent.length, 1) + assert.equal(ms(buchung('b_schon').erinnerungGesendetAt), plus(JETZT, -TAG).getTime(), 'alter Marker bleibt') + assert.equal(buchung('b_ohne_wunsch').erinnerungGesendetAt, undefined) + assert.equal(buchung('b_ohne_adresse').erinnerungGesendetAt, undefined) + assert.ok(buchung('b_ok').erinnerungGesendetAt) +}) + +test('Erinnerung: stundenVorher gilt je Funktion, der Kontext wird je Lauf nur einmal geladen', async () => { + await richteFunktionEin(F1) // 24 Stunden + await richteFunktionEin(F2, {}, { erinnerung: { anbieten: true, stundenVorher: 12 } }) + await mitErinnerung('b_a1', 20 * STUNDE) + await mitErinnerung('b_a2', 21 * STUNDE) + await mitErinnerung('b_a3', 22 * STUNDE) + await mitErinnerung('b_f2_nah', 10 * STUNDE, { featureId: F2 }) + await mitErinnerung('b_f2_fern', 20 * STUNDE, { featureId: F2 }) + + let erg + const ladungen = await zaehleAnfragen(async () => { + erg = await fuenfMinutenJob(JETZT) + }, new RegExp(`/collections/portalFeatures/documents/${F1}$`)) + await warteAufVersand() + assert.equal(ladungen, 1, 'drei Buchungen derselben Funktion, ein Lesen') + assert.equal(erg.erinnerungen, 4) + assert.ok(buchung('b_f2_nah').erinnerungGesendetAt) + assert.equal(buchung('b_f2_fern').erinnerungGesendetAt, undefined, '20 Stunden sind mehr als die 12 dieser Funktion') + assert.equal(sink.sent.length, 4) +}) + +test('Erinnerung: eine Buchung ohne lesbare Funktion oder Einstellung wird übersprungen, ohne Marker und ohne Fehler', async () => { + aw.seed('portalFeatures', feature(F1)) // Einstellung nie gespeichert + await mitErinnerung('b_ohne_einstellung', 5 * STUNDE) + await mitErinnerung('b_ohne_funktion', 5 * STUNDE, { featureId: 'f_weg' }) + const erg = await fuenfMinutenJob(JETZT) + assert.equal(erg.erinnerungen, 0) + assert.equal(erg.fehler, 0) + assert.equal(buchung('b_ohne_einstellung').erinnerungGesendetAt, undefined) + assert.equal(buchung('b_ohne_funktion').erinnerungGesendetAt, undefined) + assert.equal(sink.sent.length, 0) +}) + +test('Erinnerung: auch für eine abgeschaltete Funktion (die bestehenden Termine gelten weiter)', async () => { + await richteFunktionEin(F1, { state: 'disabled', disabledAt: iso(plus(JETZT, -2 * TAG)) }) + await mitErinnerung('b_abgeschaltet', 5 * STUNDE) + await fuenfMinutenJob(JETZT) + await warteAufVersand() + assert.equal(sink.sent.length, 1) +}) + +test('Erinnerung: Marker zuerst – scheitert der Marker, geht keine Mail raus und der nächste Lauf holt es nach', async () => { + await richteFunktionEin() + await mitErinnerung('b_marker', 5 * STUNDE) + aw.failNext({ collection: 'termineBookings', method: 'PATCH', status: 500, message: 'Nachbau kaputt' }) + const erster = await fuenfMinutenJob(JETZT) + await warteAufVersand() + assert.equal(erster.erinnerungen, 0) + assert.equal(erster.fehler, 1) + assert.equal(sink.sent.length, 0) + assert.equal(buchung('b_marker').erinnerungGesendetAt, undefined) + assert.ok(fehlerLog().some((zeile) => zeile.includes(buchungsNummer('b_marker'))), 'Buchungsnummer im Log') + + assert.equal((await fuenfMinutenJob(plus(JETZT, 5 * MIN))).erinnerungen, 1) + await warteAufVersand() + assert.equal(sink.sent.length, 1) +}) + +test('Erinnerung: scheitert das Einreihen nach dem Marker, wird nichts doppelt gesendet (höchstens einmal)', async () => { + await richteFunktionEin() + await mitErinnerung('b_einreihen', 5 * STUNDE) + aw.failNext({ collection: 'termineOutbox', method: 'POST', status: 500, message: 'Nachbau kaputt' }) + const erster = await fuenfMinutenJob(JETZT) + assert.equal(erster.erinnerungen, 0) + assert.equal(erster.fehler, 1) + assert.ok(buchung('b_einreihen').erinnerungGesendetAt, 'Marker steht') + assert.ok(fehlerLog().some((zeile) => zeile.includes(buchungsNummer('b_einreihen')))) + + await fuenfMinutenJob(plus(JETZT, 5 * MIN)) + await warteAufVersand() + assert.equal(sink.sent.length, 0) + assert.equal(eintraege().length, 0) +}) + +test('Erinnerung: wird die Buchung zwischen Auflisten und Sperre abgesagt, geht keine Erinnerung raus', async () => { + await richteFunktionEin() + const doc = await mitErinnerung('b_race', 5 * STUNDE) + await mitEinwurf( + listeVonBuchungen(/erinnerung/), + async () => aw.seed('termineBookings', { ...doc, status: 'abgesagt' }), + () => fuenfMinutenJob(JETZT), + ) + await warteAufVersand() + assert.equal(status('b_race'), 'abgesagt') + assert.equal(buchung('b_race').erinnerungGesendetAt, undefined) + assert.equal(sink.sent.length, 0) +}) + +// ---------------------------------------------------------------- Erledigt und vergangene Anfragen + +test('fuenfMinutenJob: bestätigte Buchungen (auch Sperren) 1 Stunde nach Ende ⇒ erledigt, sonst nichts', async () => { + await legeBuchung('b_alt', { ende: plus(JETZT, -STUNDE - MIN) }) + await legeBuchung('b_sperre', { ende: plus(JETZT, -3 * STUNDE), quelle: 'sperre', gastName: '', gastEmail: '' }) + await legeBuchung('b_knapp', { ende: plus(JETZT, -59 * MIN) }) + await legeBuchung('b_genau', { ende: plus(JETZT, -STUNDE) }) + await legeBuchung('b_zukunft', { ende: plus(JETZT, TAG) }) + await legeBuchung('b_angefragt', { ende: plus(JETZT, -3 * STUNDE), status: 'angefragt', startsAt: iso(plus(JETZT, -4 * STUNDE)) }) + + const erg = await fuenfMinutenJob(JETZT) + assert.equal(erg.erledigt, 2) + assert.equal(status('b_alt'), 'erledigt') + assert.equal(status('b_sperre'), 'erledigt') + assert.equal(ms(buchung('b_alt').updatedAt), JETZT.getTime()) + for (const id of ['b_knapp', 'b_genau', 'b_zukunft']) assert.equal(status(id), 'bestaetigt', id) + assert.equal((await fuenfMinutenJob(JETZT)).erledigt, 0) + await warteAufVersand() + assert.equal(sink.sent.length, 0) +}) + +test('fuenfMinutenJob: markiert der Betrieb den Termin zwischen Auflisten und Sperre als nicht erschienen, bleibt es dabei', async () => { + const doc = await legeBuchung('b_race', { ende: plus(JETZT, -2 * STUNDE) }) + await mitEinwurf( + listeVonBuchungen(/endsAt/), + async () => aw.seed('termineBookings', { ...doc, status: 'nichterschienen' }), + () => fuenfMinutenJob(JETZT), + ) + assert.equal(status('b_race'), 'nichterschienen') +}) + +test('fuenfMinutenJob: Anfrage mit Beginn in der Vergangenheit ⇒ abgelehnt vom System, Zellen frei, keine Mail', async () => { + await legeBuchung('b_vergangen', { start: plus(JETZT, -MIN), status: 'angefragt', buchungsart: 'anfrage', bestaetigtAt: null }) + await legeBuchung('b_genau', { start: JETZT, status: 'angefragt', buchungsart: 'anfrage', bestaetigtAt: null }) + await legeBuchung('b_offen', { start: plus(JETZT, MIN), status: 'angefragt', buchungsart: 'anfrage', bestaetigtAt: null }) + assert.ok(claims('b_vergangen').length > 0) + + const erg = await fuenfMinutenJob(JETZT) + assert.equal(erg.abgelehnt, 1) + const b = buchung('b_vergangen') + assert.equal(b.status, 'abgelehnt') + assert.equal(b.abgesagtVon, 'system') + assert.equal(ms(b.updatedAt), JETZT.getTime()) + assert.equal(claims('b_vergangen').length, 0) + assert.equal(status('b_genau'), 'angefragt', 'Beginn genau jetzt ist noch nicht vergangen') + assert.equal(status('b_offen'), 'angefragt') + assert.ok(claims('b_offen').length > 0) + await warteAufVersand() + assert.equal(sink.sent.length, 0) + assert.equal(eintraege().length, 0, 'auch kein Eintrag in der Warteschlange') +}) + +test('fuenfMinutenJob: wird die Anfrage zwischen Auflisten und Sperre angenommen, bleibt sie bestätigt', async () => { + const doc = await legeBuchung('b_race', { start: plus(JETZT, -MIN), status: 'angefragt', buchungsart: 'anfrage', bestaetigtAt: null }) + await mitEinwurf( + listeVonBuchungen(/angefragt/), + async () => aw.seed('termineBookings', { ...doc, status: 'bestaetigt', angenommenAt: iso(JETZT) }), + () => fuenfMinutenJob(JETZT), + ) + assert.equal(status('b_race'), 'bestaetigt') + assert.ok(claims('b_race').length > 0) +}) + +// ---------------------------------------------------------------- Verfall und Bestätigung gleichzeitig + +/** Haltung mit echtem Bestaetigungstoken; heldUntil in 30 Minuten ab JETZT. */ +async function haltung(id = 'b_halt') { + const { token, hash } = neuesToken() + await legeBuchung(id, { status: 'gehalten', heldUntil: iso(plus(JETZT, 30 * MIN)), verifyHash: hash, bestaetigtAt: null }) + return { id, token, heldUntil: plus(JETZT, 30 * MIN) } +} +test('verfall_und_bestaetigung_gleichzeitig: Job zuerst (Bestätigung kommt mit veralteter Sicht) ⇒ verfallen, keine G02', async () => { + await richteFunktionEin() + const { id, token, heldUntil } = await haltung() + // bestaetige hat die Buchung gefunden (gehalten) – bevor es die Sperre bekommt, läuft der Minutenjob zu Ende + const ergebnis = await mitEinwurf( + listeVonBuchungen(/verifyHash/), + async () => assert.equal((await minutenJob(heldUntil)).verfallen, 1), + () => bestaetige(token, plus(heldUntil, -MIN)).then((r) => ({ r }), (err) => ({ err })), + ) + await warteAufVersand() + assert.equal(ergebnis.err?.status, 410) + assert.equal(ergebnis.err?.code, 'abgelaufen') + assert.equal(status(id), 'verfallen') + assert.equal(claims(id).length, 0) + assert.equal(sink.sent.length, 0, 'keine G02 und keine K20 nach verfallen') + assert.equal(eintraege().length, 0) +}) + +test('verfall_und_bestaetigung_gleichzeitig: Bestätigung zuerst (Job mit veralteter Liste) ⇒ bestätigt bleibt bestätigt, genau eine G02', async () => { + await richteFunktionEin() + const { id, token, heldUntil } = await haltung() + const lauf = await mitEinwurf( + listeVonBuchungen(/heldUntil/), + async () => { + const erg = await bestaetige(token, plus(heldUntil, -MIN)) + assert.equal(erg.status, 'bestaetigt') + }, + () => minutenJob(heldUntil), + ) + await warteAufVersand() + assert.equal(lauf.verfallen, 0, 'der Job sah die Buchung in der Liste, aber unter der Sperre ist sie bestätigt') + assert.equal(status(id), 'bestaetigt') + assert.ok(claims(id).length > 0, 'Zellen bleiben') + assert.equal(sink.sent.filter((m) => /Ihr Termin bei/.test(m.subject)).length, 1, 'genau eine G02') +}) + +test('verfall_und_bestaetigung_gleichzeitig: parallel gestartet ⇒ immer genau ein Endzustand, G02 nur bei bestätigt', async () => { + await richteFunktionEin() + for (const versatz of [0, 1, 2, 5, 10, 20]) { + const { id, token, heldUntil } = await haltung(`b_par${versatz}`) + const [job, klick] = await Promise.allSettled([ + (async () => { + for (let i = 0; i < versatz; i++) await new Promise((resolve) => setImmediate(resolve)) + return minutenJob(heldUntil) + })(), + bestaetige(token, plus(heldUntil, -MIN)), + ]) + await warteAufVersand() + assert.equal(job.status, 'fulfilled') + const end = status(id) + assert.ok(['bestaetigt', 'verfallen'].includes(end), `${id}: ${end}`) + assert.equal(klick.status === 'fulfilled', end === 'bestaetigt', `${id}: Antwort passt zum Endzustand`) + assert.equal(claims(id).length > 0, end === 'bestaetigt', `${id}: Zellen nur bei bestätigt`) + const mails = sink.sent.filter((m) => /Ihr Termin bei/.test(m.subject)).length + assert.equal(mails, end === 'bestaetigt' ? 1 : 0, `${id}: G02`) + sink.sent.length = 0 + for (const eintrag of eintraege()) await deleteDocument('termineOutbox', eintrag.$id) + resetJobsForTests() + } +}) + +// ---------------------------------------------------------------- Löschfristen + +test('wendeLoeschfristenAn: verfallen 48 Stunden nach heldUntil, abgesagt/abgelehnt 30 Tage nach der letzten Änderung', async () => { + await legeBuchung('b_v_alt', { status: 'verfallen', heldUntil: iso(plus(JETZT, -48 * STUNDE - 1)), start: plus(JETZT, TAG) }) + await legeBuchung('b_v_jung', { status: 'verfallen', heldUntil: iso(plus(JETZT, -48 * STUNDE + 1)), start: plus(JETZT, TAG) }) + await legeBuchung('b_s_alt', { status: 'abgesagt', updatedAt: iso(plus(JETZT, -30 * TAG - 1)) }) + await legeBuchung('b_s_jung', { status: 'abgesagt', updatedAt: iso(plus(JETZT, -30 * TAG + 1)) }) + await legeBuchung('b_l_alt', { status: 'abgelehnt', updatedAt: iso(plus(JETZT, -31 * TAG)) }) + await legeBuchung('b_l_jung', { status: 'abgelehnt', updatedAt: iso(plus(JETZT, -29 * TAG)) }) + + const erg = await wendeLoeschfristenAn(JETZT) + assert.deepEqual(erg, { verfallen: 1, abgesagt: 1, abgelehnt: 1, erledigt: 0, nichterschienen: 0 }) + for (const id of ['b_v_alt', 'b_s_alt', 'b_l_alt']) { + assert.equal(buchung(id), undefined, `${id} gelöscht`) + assert.equal(claims(id).length, 0, `${id}: Zellen mit der Buchung gelöscht`) + } + for (const id of ['b_v_jung', 'b_s_jung', 'b_l_jung']) { + assert.ok(buchung(id), `${id} bleibt`) + assert.ok(claims(id).length > 0, `${id}: Zellen bleiben (nur der Tagesjob räumt verwaiste Zellen)`) + } +}) + +test('wendeLoeschfristenAn: erledigt/nichterschienen nach aufbewahrungTage der Funktion, ohne lesbare Einstellung 90', async () => { + await richteFunktionEin(F1, {}, { aufbewahrungTage: 30 }) + await richteFunktionEin(F3, {}, { aufbewahrungTage: 365 }) + // F2: keine Einstellung gespeichert ⇒ 90; F4: unlesbare Einstellung ⇒ 90 + aw.seed('termineConfig', { $id: 'f_p_projekt04_termine', customerId: KUNDE, projectId: 'p4', einstellung: '{kaputt', version: 1, updatedAt: iso(JETZT), updatedBy: '' }) + const faelle = [ + // [id, Funktion, Status, Tage seit Ende, gelöscht?] + ['b_30_alt', F1, 'erledigt', 30, true], + ['b_30_jung', F1, 'erledigt', 29, false], + ['b_30_nicht_alt', F1, 'nichterschienen', 31, true], + ['b_30_nicht_jung', F1, 'nichterschienen', 14, false], + ['b_90_alt', F2, 'erledigt', 91, true], + ['b_90_jung', F2, 'erledigt', 89, false], + ['b_90_nicht', F2, 'nichterschienen', 91, true], + ['b_kaputt_alt', 'f_p_projekt04_termine', 'erledigt', 91, true], + ['b_kaputt_jung', 'f_p_projekt04_termine', 'erledigt', 89, false], + ['b_365_alt', F3, 'erledigt', 366, true], + ['b_365_jung', F3, 'erledigt', 200, false], + ] + // Das Ende liegt eine Millisekunde hinter (gelöscht) bzw. vor (bleibt) der Frist + const ende = (tage, weg) => plus(JETZT, -tage * TAG + (weg ? -1 : 1)) + for (const [id, featureId, st, tage, weg] of faelle) await legeBuchung(id, { featureId, status: st, ende: ende(tage, weg) }) + // andere Status werden nie nach Frist gelöscht + await legeBuchung('b_fest', { status: 'bestaetigt', ende: ende(400, true) }) + await legeBuchung('b_anfrage', { status: 'angefragt', ende: ende(400, true) }) + await legeBuchung('b_halt', { status: 'gehalten', heldUntil: iso(plus(JETZT, MIN)), ende: ende(400, true) }) + + const erg = await wendeLoeschfristenAn(JETZT) + assert.equal(erg.erledigt + erg.nichterschienen, faelle.filter((f) => f[4]).length) + for (const [id, , , , weg] of faelle) { + assert.equal(buchung(id) === undefined, weg, id) + assert.equal(claims(id).length === 0, weg, `${id}: Zellen`) + } + for (const id of ['b_fest', 'b_anfrage', 'b_halt']) assert.ok(buchung(id), id) +}) + +test('wendeLoeschfristenAn: Fehler beim Lesen der Einstellung ⇒ diese Funktion bleibt bis zum nächsten Lauf unberührt', async () => { + await richteFunktionEin(F1, {}, { aufbewahrungTage: 365 }) + await legeBuchung('b_zu_frueh', { status: 'erledigt', ende: plus(JETZT, -100 * TAG) }) // 365 Tage Frist, nicht 90 + await legeBuchung('b_v_alt', { status: 'verfallen', heldUntil: iso(plus(JETZT, -3 * TAG)) }) + aw.failNext({ collection: 'termineConfig', method: 'GET', status: 500, message: 'Nachbau kaputt' }) + const erg = await wendeLoeschfristenAn(JETZT) + assert.ok(buchung('b_zu_frueh'), 'nie früher löschen als eingestellt, nur weil die Einstellung nicht lesbar war') + assert.equal(buchung('b_v_alt'), undefined, 'Status ohne Einstellungswert laufen weiter') + assert.equal(erg.verfallen, 1) + assert.ok(fehlerLog().length >= 1) + assert.equal((await wendeLoeschfristenAn(JETZT)).erledigt, 0, 'auch beim nächsten Lauf gilt die Einstellung (365)') + assert.ok(buchung('b_zu_frueh')) +}) + +test('wendeLoeschfristenAn: wird die Buchung zwischen Auflisten und Sperre geändert, bleibt sie', async () => { + const doc = await legeBuchung('b_race', { status: 'abgesagt', updatedAt: iso(plus(JETZT, -40 * TAG)) }) + await mitEinwurf( + listeVonBuchungen(/updatedAt/), + async () => aw.seed('termineBookings', { ...doc, status: 'bestaetigt' }), + () => wendeLoeschfristenAn(JETZT), + ) + assert.equal(status('b_race'), 'bestaetigt') + assert.ok(claims('b_race').length > 0) +}) + +test('loescheAbgeschalteteFunktionen: disabled länger als 30 Tage ⇒ Buchungen, Zellen und Einstellung weg; alles andere bleibt', async () => { + const seit = (tage, zusatz = 0) => iso(plus(JETZT, -tage * TAG + zusatz)) + await richteFunktionEin(F1, { state: 'disabled', disabledAt: seit(31) }) + await richteFunktionEin(F2, { state: 'disabled', disabledAt: seit(30, 1) }) // noch knapp unter 30 Tagen + await richteFunktionEin(F3, { state: 'active', disabledAt: seit(90) }) // wieder eingeschaltet: alter Marker zählt nicht + await richteFunktionEin('f_p_projekt04_termine', { state: 'disabled', disabledAt: seit(90), featureKey: 'news' }) // andere Funktion + await richteFunktionEin('f_p_projekt05_termine', { state: 'disabled' }) // kein disabledAt + const funktionen = [F1, F2, F3, 'f_p_projekt04_termine', 'f_p_projekt05_termine'] + for (const [i, featureId] of funktionen.entries()) { + await legeBuchung(`b_fest_${i}`, { featureId, status: 'bestaetigt' }) + await legeBuchung(`b_alt_${i}`, { featureId, status: 'erledigt', ende: plus(JETZT, -2 * TAG) }) + } + const abgeschaltet = ['b_fest_0', 'b_alt_0'] + + const erg = await loescheAbgeschalteteFunktionen(JETZT) + assert.deepEqual(erg, { funktionen: 1, buchungen: 2 }) + for (const id of abgeschaltet) { + assert.equal(buchung(id), undefined, id) + assert.equal(claims(id).length, 0, `${id}: Zellen`) + } + assert.equal(aw.docs('termineConfig').get(F1), undefined, 'Einstellung gelöscht') + assert.equal(buchungen().length, 8, 'die Buchungen der anderen vier Funktionen bleiben') + for (const featureId of [F2, F3, 'f_p_projekt04_termine', 'f_p_projekt05_termine']) assert.ok(aw.docs('termineConfig').get(featureId), `${featureId}: Einstellung bleibt`) + assert.ok(aw.docs('portalFeatures').get(F1), 'das Funktionsdokument selbst bleibt') + assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 1, buchungen: 0 }, 'zweiter Lauf: nichts mehr zu löschen') +}) + +test('loescheAbgeschalteteFunktionen: scheitert das Löschen einer Buchung, bleibt die Einstellung bis zum nächsten Lauf', async () => { + await richteFunktionEin(F1, { state: 'disabled', disabledAt: iso(plus(JETZT, -40 * TAG)) }) + await legeBuchung('b_eins', { start: plus(JETZT, TAG) }) + aw.failNext({ collection: 'termineBookings', method: 'DELETE', status: 500, message: 'Nachbau kaputt' }) + await loescheAbgeschalteteFunktionen(JETZT) + assert.ok(buchung('b_eins')) + assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt, solange Buchungen übrig sind') + assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 1, buchungen: 1 }) + assert.equal(buchung('b_eins'), undefined) + assert.equal(aw.docs('termineConfig').get(F1), undefined) +}) + +// ---------------------------------------------------------------- Tagesjob + +test('taeglicherJob: Löschfristen, Warteschlange, festgefahrene Mails, verwaiste Zellen und abgeschaltete Funktionen in einem Lauf', async () => { + await richteFunktionEin(F1) + await richteFunktionEin(F2, { state: 'disabled', disabledAt: iso(plus(JETZT, -45 * TAG)) }) + await legeBuchung('b_verfallen', { status: 'verfallen', heldUntil: iso(plus(JETZT, -3 * TAG)) }) + await legeBuchung('b_erledigt', { status: 'erledigt', ende: plus(JETZT, -100 * TAG) }) + await legeBuchung('b_zukunft', { start: plus(JETZT, 5 * TAG) }) + await legeBuchung('b_abgeschaltet', { featureId: F2, start: plus(JETZT, 5 * TAG) }) + // verwaiste Zelle: Buchung fehlt + await belegeZellen({ featureId: F1, ressourceId: 'r_geist', bookingId: 'b_geist', start: plus(JETZT, 2 * TAG), blockEnde: plus(JETZT, 2 * TAG + 30 * MIN), now: JETZT }) + legeEintrag('o_gesendet', { status: 'gesendet', an: '', html: '', text: '', naechsterVersuch: iso(plus(JETZT, -MIN)) }) + legeEintrag('o_festgefahren', { createdAt: iso(plus(JETZT, -3 * TAG)) }) + + const erg = await taeglicherJob(JETZT) + assert.equal(erg.fehler, 0) + assert.equal(erg.festgefahren, 1) + assert.equal(erg.loeschfristen.verfallen, 1) + assert.equal(erg.loeschfristen.erledigt, 1) + assert.deepEqual(erg.abgeschaltet, { funktionen: 1, buchungen: 1 }) + assert.equal(erg.aufgeraeumt, 1) + assert.ok(erg.zellen >= 1) + + assert.equal(buchung('b_verfallen'), undefined) + assert.equal(buchung('b_erledigt'), undefined) + assert.equal(buchung('b_abgeschaltet'), undefined) + assert.equal(aw.docs('termineConfig').get(F2), undefined) + assert.ok(buchung('b_zukunft')) + assert.ok(claims('b_zukunft').length > 0, 'Zellen einer gültigen Buchung bleiben') + assert.equal(claims('b_geist').length, 0, 'verwaiste Zelle geräumt') + assert.equal(aw.docs('termineClaims').size, claims('b_zukunft').length, 'sonst bleibt keine Zelle übrig') + assert.equal(aw.docs('termineOutbox').get('o_gesendet'), undefined) + assert.equal(aw.docs('termineOutbox').get('o_festgefahren').status, 'fehler') +}) + +test('taeglicherJob: festgefahrene Mail wird fehler und erst 48 Stunden später gelöscht (nicht im selben Lauf)', async () => { + legeEintrag('o_alt', { createdAt: iso(plus(JETZT, -49 * STUNDE)), naechsterVersuch: iso(plus(JETZT, -48 * STUNDE)) }) + legeEintrag('o_neu', { createdAt: iso(plus(JETZT, -47 * STUNDE)), naechsterVersuch: iso(plus(JETZT, 10 * MIN)) }) + + await taeglicherJob(JETZT) + assert.equal(aw.docs('termineOutbox').get('o_alt').status, 'fehler') + assert.equal(ms(aw.docs('termineOutbox').get('o_alt').naechsterVersuch), JETZT.getTime() + 48 * STUNDE) + assert.equal(aw.docs('termineOutbox').get('o_neu').status, 'wartet') + + await taeglicherJob(plus(JETZT, 47 * STUNDE)) + assert.ok(aw.docs('termineOutbox').get('o_alt'), 'noch nicht') + const erg = await taeglicherJob(plus(JETZT, 48 * STUNDE)) + assert.equal(erg.aufgeraeumt, 1) + assert.equal(aw.docs('termineOutbox').get('o_alt'), undefined) +}) + +// ---------------------------------------------------------------- Fehlende Collection, Überlappung + +test('fehlende Collection: höchstens eine Warnung je Stunde und Job, kein Wurf', async () => { + await richteFunktionEin() + await mitFehlenderCollection('termineBookings', async () => { + for (let i = 0; i < 3; i++) { + assert.equal((await minutenJob(plus(JETZT, i * MIN))).verfallen, 0) + assert.ok((await fuenfMinutenJob(plus(JETZT, i * MIN))).fehler >= 1) + assert.ok((await taeglicherJob(plus(JETZT, i * MIN))).fehler >= 1) + } + assert.equal(warnLog().filter((zeile) => /minutenJob/.test(zeile)).length, 1) + assert.equal(warnLog().filter((zeile) => /fuenfMinutenJob/.test(zeile)).length, 1, 'ein Job meldet nicht je Schritt') + assert.equal(warnLog().filter((zeile) => /taeglicherJob/.test(zeile)).length, 1) + assert.equal(fehlerLog().length, 0, 'keine Fehlerzeilen für fehlende Collections') + + await minutenJob(plus(JETZT, 59 * MIN)) + assert.equal(warnLog().filter((zeile) => /minutenJob/.test(zeile)).length, 1, 'noch keine Stunde') + await minutenJob(plus(JETZT, 61 * MIN)) + assert.equal(warnLog().filter((zeile) => /minutenJob/.test(zeile)).length, 2, 'nach einer Stunde wieder') + }) + assert.match(warnLog()[0], /collection_not_found/) +}) + +test('fehlende Collection der Warteschlange: der Minutenjob verfällt trotzdem Haltungen', async () => { + await legeBuchung('b_vorbei', { status: 'gehalten', heldUntil: iso(plus(JETZT, -MIN)) }) + const erg = await mitFehlenderCollection('termineOutbox', () => minutenJob(JETZT)) + assert.equal(erg.verfallen, 1) + assert.equal(erg.fehler, 1) + assert.equal(warnLog().length, 1) +}) + +test('überlappende Läufe: ein langsamer Lauf wird vom nächsten Takt übersprungen (einmal im Log), danach geht es normal weiter', async () => { + await legeBuchung('b_vorbei', { status: 'gehalten', heldUntil: iso(plus(JETZT, -MIN)) }) + const echt = globalThis.fetch + let freigeben + const tor = new Promise((resolve) => (freigeben = resolve)) + let erste = true + globalThis.fetch = async (url, optionen) => { + if (erste) { + erste = false + await tor + } + return echt(url, optionen) + } + try { + const langsam = minutenJob(JETZT) + assert.deepEqual(await minutenJob(plus(JETZT, MIN)), { uebersprungen: true }) + assert.deepEqual(await minutenJob(plus(JETZT, 2 * MIN)), { uebersprungen: true }) + assert.equal(warnLog().filter((zeile) => /minutenJob/.test(zeile)).length, 1, 'nur einmal gemeldet') + // andere Jobs sind davon nicht betroffen + assert.equal((await fuenfMinutenJob(JETZT)).uebersprungen, undefined) + freigeben() + assert.equal((await langsam).verfallen, 1) + } finally { + globalThis.fetch = echt + } + assert.equal((await minutenJob(plus(JETZT, 3 * MIN))).uebersprungen, undefined, 'danach läuft der Job wieder') +}) + +test('ein Job, der mit einem Fehler endet, gibt die Überlappungssperre frei', async () => { + const echt = globalThis.fetch + globalThis.fetch = async () => { + throw new TypeError('fetch failed') + } + try { + const erg = await minutenJob(JETZT) + assert.equal(erg.fehler, 2, 'Verfall und Warteschlange scheitern, der Job wirft nicht') + } finally { + globalThis.fetch = echt + } + assert.equal((await minutenJob(JETZT)).uebersprungen, undefined) +}) + +// ---------------------------------------------------------------- Einbindung + +test('server/index.js: die drei Jobs laufen alle 60 s, alle 5 min und täglich (+180 s), mit unref und eigener Fehlerbehandlung', () => { + const index = readFileSync(new URL('../server/index.js', import.meta.url), 'utf8') + assert.match(index, /import \{[^}]*\bminutenJob\b[^}]*\} from '\.\/services\/termine\/jobs\.js'/) + assert.match(index, /import \{[^}]*\bfuenfMinutenJob\b[^}]*\} from '\.\/services\/termine\/jobs\.js'/) + assert.match(index, /import \{[^}]*\btaeglicherJob\b[^}]*\} from '\.\/services\/termine\/jobs\.js'/) + for (const [job, takt] of [['minutenJob', '60 \\* 1000'], ['fuenfMinutenJob', '5 \\* 60 \\* 1000'], ['taeglicherJob', '24 \\* 60 \\* 60 \\* 1000']]) { + const lauf = new RegExp(`const (\\w+) = \\(\\) =>\\s*${job}\\(new Date\\(\\)\\)\\s*\\.catch\\(`) + const treffer = lauf.exec(index) + assert.ok(treffer, `${job} mit .catch eingebunden`) + assert.match(index, new RegExp(`setInterval\\(${treffer[1]}, ${takt}\\)\\.unref\\(\\)`), `${job}: Intervall`) + if (job === 'taeglicherJob') assert.match(index, new RegExp(`setTimeout\\(${treffer[1]}, 180 \\* 1000\\)\\.unref\\(\\)`), 'täglicher Job: +180 s') + } +}) diff --git a/test/termineOutbox.test.js b/test/termineOutbox.test.js index 5a898fe..b729aab 100644 --- a/test/termineOutbox.test.js +++ b/test/termineOutbox.test.js @@ -23,7 +23,7 @@ const { setMailTransportForTests } = await import('../server/services/mailer.js' const { loadTemplate } = await import('../server/services/mailTemplates.js') const { icsTermin } = await import('../server/services/termine/ics.js') const O = await import('../server/services/termine/outbox.js') -const { einreihen, sendeEintrag, verarbeite, warteAufVersand, zustellFehler, raeumeAuf, pausiertBis, resetOutboxForTests } = O +const { einreihen, sendeEintrag, verarbeite, warteAufVersand, zustellFehler, raeumeAuf, beendeFestgefahrene, pausiertBis, resetOutboxForTests } = O after(() => setMailTransportForTests(null)) const MIN = 60000 @@ -550,6 +550,54 @@ test('raeumeAuf: gesendet 7 Tage nach dem Versand, fehler 48 Stunden nach dem Fe await assert.rejects(() => raeumeAuf(), TypeError) }) +test('beendeFestgefahrene: wartet länger als 48 Stunden (createdAt) ⇒ fehler mit Löschtermin in 48 Stunden; Jüngere und andere Status bleiben', async () => { + const alt = await legeAn({ bookingId: 'b_alt', createdAt: plus(-48 * STUNDE - 1).toISOString() }) + const altVertagt = await legeAn({ bookingId: 'b_alt2', createdAt: plus(-5 * TAG).toISOString(), naechsterVersuch: plus(20 * MIN).toISOString() }) + const grenze = await legeAn({ bookingId: 'b_grenze', createdAt: plus(-48 * STUNDE).toISOString() }) + const jung = await legeAn({ bookingId: 'b_jung', createdAt: plus(-2 * STUNDE).toISOString() }) + const fertig = await legeAn({ bookingId: 'b_fertig', status: 'gesendet', createdAt: plus(-5 * TAG).toISOString(), naechsterVersuch: plus(3 * TAG).toISOString() }) + + assert.equal(await beendeFestgefahrene(JETZT), 2) + for (const eintragDoc of [alt, altVertagt]) { + const e = await lies(eintragDoc.$id) + assert.equal(e.status, 'fehler') + assert.equal(ms(e.naechsterVersuch), JETZT.getTime() + 48 * STUNDE, 'Löschtermin = jetzt + 48 Stunden') + assert.ok(e.fehler.length > 0 && e.fehler.length <= 300) + assert.equal(e.versuche, 0) + } + for (const eintragDoc of [grenze, jung]) assert.equal((await lies(eintragDoc.$id)).status, 'wartet') + assert.equal((await lies(fertig.$id)).status, 'gesendet') + assert.deepEqual([...(await zustellFehler(['b_alt', 'b_alt2', 'b_jung']))].sort(), ['b_alt', 'b_alt2'], 'das Portal zeigt den Zustellfehler') + + assert.equal(await beendeFestgefahrene(JETZT), 0, 'zweiter Lauf ändert nichts') + assert.equal(await raeumeAuf(JETZT), 0, 'nicht sofort gelöscht') + assert.equal(await raeumeAuf(plus(48 * STUNDE - 1)), 0) + assert.equal(await raeumeAuf(plus(48 * STUNDE)), 2, 'erst 48 Stunden später') + await assert.rejects(() => beendeFestgefahrene(), TypeError) +}) + +test('beendeFestgefahrene: ein Eintrag, der inzwischen gesendet wurde, bleibt gesendet', async () => { + const doc = await legeAn({ bookingId: 'b_race', createdAt: plus(-3 * TAG).toISOString() }) + const echt = globalThis.fetch + let einmal = true + // zwischen Auflisten und Sperre sendet ein anderer Lauf den Eintrag + globalThis.fetch = async (url, optionen) => { + const antwort = await echt(url, optionen) + if (einmal && (optionen?.method ?? 'GET') === 'GET' && String(url).includes('/documents?')) { + einmal = false + await sendeEintrag(doc.$id, JETZT) + } + return antwort + } + try { + assert.equal(await beendeFestgefahrene(JETZT), 0) + } finally { + globalThis.fetch = echt + } + assert.equal(einmal, false, 'der Versand lief zwischen Auflisten und Sperre') + assert.equal((await lies(doc.$id)).status, 'gesendet') +}) + test('raeumeAuf löscht mehr als eine Seite (über 25 Einträge)', async () => { for (let i = 0; i < 40; i++) await legeAn({ bookingId: `b_${i}`, status: 'gesendet', an: '', html: '', text: '', naechsterVersuch: JETZT.toISOString() }) assert.equal(await raeumeAuf(JETZT), 40)