From 9b4305e5de6b6cf884636e6048cc97c09e7a9e06 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 09:09:48 +0000 Subject: [PATCH] Terminplaner: Aufbewahrungs-Loeschung je Funktion mit Deckel, Sicherheitsnetz und Bericht MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit erledigt/nichterschienen werden jetzt je Funktion (portalFeatures termine, active|disabled) nach deren aufbewahrungTage seitenweise geloescht; beide Status teilen sich den Deckel loeschDeckel der Funktion, alle Schritte eines Laufs zusammen loeschDeckelGesamt (neu, Standard 20000). Ein Sicherheitsnetz loescht erledigt/nichterschienen ohne Funktionsfilter nach 365 Tagen (Buchungen ohne Funktionsdokument, unlesbare Einstellung). Der Tagesbericht nennt "Loeschlaeufe am Deckel: n, Rest folgt an den nächsten Tagen" nur bei gedeckelt > 0. Co-Authored-By: Claude Sonnet 5.5 --- server/config.js | 5 +- server/services/termine/jobs.js | 131 +++++++++---- test/termineJobs.test.js | 329 +++++++++++++++++++++++++++++++- test/termineSchalter.test.js | 1 + 4 files changed, 423 insertions(+), 43 deletions(-) diff --git a/server/config.js b/server/config.js index de4af8c..99502ab 100644 --- a/server/config.js +++ b/server/config.js @@ -386,8 +386,11 @@ export const config = { holdMinutes: 30, zelleMin: 5, mailsProStunde: 200, - // Hoechstens so viele Buchungen loescht ein Schritt der Aufbewahrungs-Loeschung je Lauf (Tagesjob); der Rest folgt am naechsten Tag + // Hoechstens so viele Buchungen loescht ein Schritt der Aufbewahrungs-Loeschung je Lauf (Tagesjob): je Status, bei erledigt und + // nichterschienen zusammen je Funktion; der Rest folgt am naechsten Tag loeschDeckel: 2000, + // Alle Schritte der Aufbewahrungs-Loeschung eines Laufs zusammen loeschen hoechstens so viele Buchungen (Gesamtdeckel ueber alle Funktionen) + loeschDeckelGesamt: 20000, // Mindestzeit zwischen Laden und Absenden des Buchungsformulars (schneller = Bot) formMinSekunden: nonNegativeNumber(process.env.TERMINE_FORM_MIN_SECONDS, 3), // Kuerzeste Antwortzeit (ms) von POST /buchungen und den Widerrufs-POSTs der oeffentlichen Schnittstelle: verwischt, ob diff --git a/server/services/termine/jobs.js b/server/services/termine/jobs.js index fe37971..394ac47 100644 --- a/server/services/termine/jobs.js +++ b/server/services/termine/jobs.js @@ -42,8 +42,9 @@ import { gibZellenFrei, raeumeVerwaisteZellen } from './zellen.js' * 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). * - Loeschlaeufe (Aufbewahrung) arbeiten seitenweise (arbeiteSeiten): eine Seite hoechstens 100 Buchungen ohne Gastfelder, - * Frist in der Abfrage, danach neu lesen. Ein Schritt loescht hoechstens config.termine.loeschDeckel Buchungen je Lauf; der - * Rest folgt am naechsten Tag und wird gezaehlt (`gedeckelt`), nie still uebersprungen. + * Frist in der Abfrage, danach neu lesen. Ein Schritt loescht hoechstens config.termine.loeschDeckel Buchungen je Lauf (erledigt + * und nichterschienen zusammen je Funktion), alle Schritte eines Laufs zusammen hoechstens config.termine.loeschDeckelGesamt; + * der Rest folgt am naechsten Tag und wird gezaehlt (`gedeckelt`), nie still uebersprungen. */ const MIN_MS = 60 * 1000 @@ -136,7 +137,8 @@ async function mitFrischer(lauf, was, id, fn) { * alles auf einmal lesen duerfen. Der Lauf endet * - bei einer leeren Seite (alles erledigt), * - am Deckel: sind `deckel` Eintraege geloescht und es kommt noch einer, zaehlt lauf.gedeckelt, eine Warnung ohne Kennungen - * steht im Log und der Rest folgt im naechsten Lauf, + * steht im Log und der Rest folgt im naechsten Lauf (`deckel` 0 = nur nachsehen, ob noch etwas ansteht: eine leere Seite endet + * ohne Zaehlung, sonst zaehlt es als Deckel); `deckelText` benennt den Deckel in der Warnung, * - bei einer Seite ohne einen bearbeiteten Eintrag (alle gescheitert oder schon versucht): die Fehler stehen im Log; eine Menge * merkt die versuchten IDs, damit ein Eintrag, der sich nicht bearbeiten laesst, keinen Endlosdurchlauf ausloest. * holeSeite() => Eintraege mit $id (hoechstens eine Seite); bearbeite(eintrag) => true (geloescht, zaehlt zum Deckel) | false (nichts @@ -144,8 +146,8 @@ async function mitFrischer(lauf, was, id, fn) { * Log und der Lauf endet unvollstaendig. * => { geloescht, gedeckelt (am Deckel beendet), vollstaendig (leere Seite erreicht und kein Fehler in diesem Lauf) } */ -async function arbeiteSeiten(lauf, { holeSeite, bearbeite, deckel, was = 'Löschung' }) { - if (!Number.isInteger(deckel) || deckel < 1) throw new TypeError(`arbeiteSeiten: deckel muss eine ganze Zahl ab 1 sein, nicht ${deckel}`) +async function arbeiteSeiten(lauf, { holeSeite, bearbeite, deckel, was = 'Löschung', deckelText = `von ${deckel} je Lauf` }) { + if (!Number.isInteger(deckel) || deckel < 0) throw new TypeError(`arbeiteSeiten: deckel muss eine ganze Zahl ab 0 sein, nicht ${deckel}`) const versucht = new Set() let geloescht = 0 let fehlerBeiEintrag = false @@ -164,7 +166,7 @@ async function arbeiteSeiten(lauf, { holeSeite, bearbeite, deckel, was = 'Lösch if (versucht.has(eintrag.$id)) continue if (geloescht >= deckel) { lauf.gedeckelt += 1 - console.warn(`[termine-jobs] ${lauf.job}: ${was} endet am Deckel von ${deckel} je Lauf, der Rest folgt an den nächsten Tagen`) + console.warn(`[termine-jobs] ${lauf.job}: ${was} endet am Deckel ${deckelText}, der Rest folgt an den nächsten Tagen`) return { geloescht, gedeckelt: true, vollstaendig: false } } versucht.add(eintrag.$id) @@ -351,53 +353,97 @@ function loescheMitZellen(lauf, id, status, frist = null) { } /** - * Aufbewahrungs-Loeschung (Spezifikation 11) fuer Buchungen. Durchgang (1): verfallen (48 Stunden nach heldUntil), abgesagt und - * abgelehnt (30 Tage nach der letzten Aenderung) ueber alle Funktionen, je Status seitenweise (arbeiteSeiten) bis zum Deckel - * config.termine.loeschDeckel. Die Frist steht in der Abfrage; unter der Buchungssperre wird Status und Bezugszeit noch einmal - * frisch geprueft. erledigt/nichterschienen laufen bis zum Umbau je Funktion noch ueber den bisherigen Weg (eine Liste, die Frist - * der Funktion am Kandidaten). + * Aufbewahrungs-Loeschung (Spezifikation 11) fuer Buchungen in drei Durchgaengen, jeweils seitenweise (arbeiteSeiten): + * (1) verfallen (48 Stunden nach heldUntil), abgesagt und abgelehnt (30 Tage nach der letzten Aenderung) ueber alle Funktionen, + * (2) erledigt und nichterschienen je Funktion (portalFeatures featureKey termine, active oder disabled; nur die IDs werden + * gelesen) nach deren aufbewahrungTage (Vorgabe 90). Ist die Einstellung einer Funktion nicht lesbar, bleibt sie in diesem + * Lauf unberuehrt (nie frueher loeschen als eingestellt), + * (3) Sicherheitsnetz: erledigt und nichterschienen ohne Funktionsfilter nach 365 Tagen, der groessten erlaubten Aufbewahrung. + * Es faengt Buchungen ohne Funktionsdokument und Funktionen mit unlesbarer Einstellung spaetestens dann (Buchungen ohne + * Funktionsdokument bleiben bis dahin, Waisen gibt es in Produktion nicht). + * Die Frist steht in der Abfrage; unter der Buchungssperre wird Status und Bezugszeit noch einmal frisch geprueft (zweiter Boden). + * Deckel: ein Schritt loescht hoechstens config.termine.loeschDeckel Buchungen (erledigt und nichterschienen einer Funktion + * zusammen), alle Schritte des Laufs zusammen hoechstens config.termine.loeschDeckelGesamt. Jeder Schritt, der daran endet, zaehlt + * in `gedeckelt` (Warnung ohne Kennungen im Log); der Rest folgt im naechsten Lauf. Endet ein Schritt am Gesamtdeckel, ist der Lauf + * zu Ende: die uebrigen Schritte folgen am naechsten Tag. Ist der Gesamtdeckel genau erreicht, sieht jeder weitere Schritt nur nach, + * ob noch etwas ansteht (leere Seite = nicht gedeckelt). * => Loeschzaehler je Status und `gedeckelt` = Zahl der Schritte, die am Deckel endeten */ async function loeschfristen(lauf, now) { const geloescht = { verfallen: 0, abgesagt: 0, abgelehnt: 0, erledigt: 0, nichterschienen: 0 } + const deckelJeSchritt = config.termine.loeschDeckel + const deckelGesamt = config.termine.loeschDeckelGesamt let gedeckelt = 0 - const tage = new Map() - const tageFuer = (featureId) => { - if (!tage.has(featureId)) tage.set(featureId, aufbewahrungTage(lauf, featureId)) - return tage.get(featureId) + let rest = deckelGesamt // was vom Gesamtdeckel noch uebrig ist + let amGesamtdeckel = false // ein Schritt endete am Gesamtdeckel: keine weiteren Schritte in diesem Lauf + + /** + * Ein Loeschschritt fuer Buchungen eines Status mit Bezugszeit vor `vor` (ms), optional nur einer Funktion, unter beiden Deckeln: + * hoechstens `eigener`, hoechstens der Rest des Gesamtdeckels. => { geloescht, gedeckelt } + */ + const loescheSeiten = async (was, status, eigener, eigenerText, vor, featureId = null) => { + if (amGesamtdeckel) return { geloescht: 0, gedeckelt: false } + const amGesamt = rest <= eigener + const frist = { feld: loeschBezugsfeld(status), vor } + const erg = await schritt( + lauf, + was, + () => + arbeiteSeiten(lauf, { + was, + deckel: amGesamt ? rest : eigener, + deckelText: amGesamt ? `von ${deckelGesamt} für alle Schritte des Laufs zusammen (Gesamtdeckel)` : eigenerText, + holeSeite: () => loeschKandidaten(status, new Date(vor), featureId === null ? {} : { featureId }), + bearbeite: async (kandidat) => { + const weg = await loescheMitZellen(lauf, kandidat.$id, status, frist) + if (weg) geloescht[status] += 1 + return weg + }, + }), + { geloescht: 0, gedeckelt: false }, + ) + rest -= erg.geloescht + if (erg.gedeckelt) { + gedeckelt += 1 + if (amGesamt) amGesamtdeckel = true + } + return erg } + // Durchgang (1): verfallen, abgesagt, abgelehnt for (const [status, behalten] of [ ['verfallen', VERFALLEN_BEHALTEN_MS], ['abgesagt', ABGESAGT_BEHALTEN_MS], ['abgelehnt', ABGESAGT_BEHALTEN_MS], ]) { - await schritt(lauf, `Löschfrist ${status}`, async () => { - const vor = lauf.nowMs - behalten - const frist = { feld: loeschBezugsfeld(status), vor } - const erg = await arbeiteSeiten(lauf, { - was: `Löschfrist ${status}`, - deckel: config.termine.loeschDeckel, - holeSeite: () => loeschKandidaten(status, new Date(vor)), - bearbeite: async (kandidat) => { - const weg = await loescheMitZellen(lauf, kandidat.$id, status, frist) - if (weg) geloescht[status] += 1 - return weg - }, - }) - if (erg.gedeckelt) gedeckelt += 1 - }) + await loescheSeiten(`Löschfrist ${status}`, status, deckelJeSchritt, undefined, lauf.nowMs - behalten) } - // Bisheriger Weg fuer erledigt/nichterschienen: Kandidaten ab der fruehesten moeglichen Frist, dann entscheidet die Frist der Funktion + // Durchgang (2): erledigt/nichterschienen je Funktion nach deren Aufbewahrung, die beiden Status teilen sich den Deckel der Funktion + const funktionen = amGesamtdeckel + ? [] + : await schritt( + lauf, + 'Löschfrist: Funktionen nicht gelesen', + () => listAllDocuments(config.collections.portalFeatures, [Query.equal('featureKey', 'termine'), Query.equal('state', ['active', 'disabled']), Query.select(['$id'])]), + [], + ) + for (const { $id: featureId } of funktionen) { + if (amGesamtdeckel) break + const tage = await aufbewahrungTage(lauf, featureId) + if (tage === null) continue + const vor = lauf.nowMs - tage * TAG_MS + let geloeschtJeFunktion = 0 + for (const status of ['erledigt', 'nichterschienen']) { + const erg = await loescheSeiten(`Löschfrist ${status} (je Funktion)`, status, deckelJeSchritt - geloeschtJeFunktion, `von ${deckelJeSchritt} je Funktion`, vor, featureId) + geloeschtJeFunktion += erg.geloescht + if (erg.gedeckelt) break // die Funktion endete am Deckel, einmal gezaehlt + } + } + + // Durchgang (3): Sicherheitsnetz, die groesste erlaubte Aufbewahrung gilt fuer jede Buchung, auch ohne lesbare Funktion for (const status of ['erledigt', 'nichterschienen']) { - await schritt(lauf, `Löschfrist ${status}`, async () => { - for (const kandidat of await loeschKandidaten(status, new Date(lauf.nowMs - AUFBEWAHRUNG_MIN * TAG_MS))) { - 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 - } - }) + await loescheSeiten(`Löschfrist ${status} (Sicherheitsnetz)`, status, deckelJeSchritt, undefined, lauf.nowMs - AUFBEWAHRUNG_MAX * TAG_MS) } return { ...geloescht, gedeckelt } } @@ -480,8 +526,9 @@ async function loescheAbgeschaltete(lauf) { /** * 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, je Schritt - * hoechstens config.termine.loeschDeckel (der Rest folgt im naechsten Lauf). Baustein des Tagesjobs, ohne Schalter- und + * Aenderung, erledigt/nichterschienen aufbewahrungTage der Funktion (Vorgabe 90) nach Terminende, spaetestens nach 365 Tagen + * (Sicherheitsnetz); jeweils samt Zellen, je Schritt hoechstens config.termine.loeschDeckel und je Lauf hoechstens + * config.termine.loeschDeckelGesamt (der Rest folgt im naechsten Lauf). Baustein des Tagesjobs, ohne Schalter- und * Ueberlappungspruefung (fuer Tests). * @param {Date} now * @returns {Promise<{ verfallen: number, abgesagt: number, abgelehnt: number, erledigt: number, nichterschienen: number, gedeckelt: number }>} @@ -531,11 +578,13 @@ export function tagesbericht(ergebnis) { const l = ergebnis.loeschfristen const a = ergebnis.abgeschaltet const gesamt = Object.values(l).reduce((summe, n) => summe + n, 0) + a.buchungen + const amDeckel = Object.values(ergebnis.gedeckelt ?? {}).reduce((summe, n) => summe + n, 0) + const deckel = amDeckel > 0 ? `Löschläufe am Deckel: ${amDeckel}, Rest folgt an den nächsten Tagen, ` : '' return ( `[termine] Tagesjob: ${gesamt} Buchungen gelöscht (verfallen ${l.verfallen}, abgesagt ${l.abgesagt}, abgelehnt ${l.abgelehnt}, ` + `erledigt ${l.erledigt}, nichterschienen ${l.nichterschienen}, abgeschaltete Funktionen ${a.buchungen}), ` + `${a.funktionen} Funktionen bereinigt, ${ergebnis.zellen} Zellen, ${ergebnis.aufgeraeumt} Outbox, ` + - `${ergebnis.festgefahren} Mails nicht zugestellt, ${ergebnis.fehler} Fehler` + `${ergebnis.festgefahren} Mails nicht zugestellt, ${deckel}${ergebnis.fehler} Fehler` ) } diff --git a/test/termineJobs.test.js b/test/termineJobs.test.js index 3ace60b..3f14d51 100644 --- a/test/termineJobs.test.js +++ b/test/termineJobs.test.js @@ -50,6 +50,7 @@ const S1 = 's_bbbbbbb1' const ABSENDER_VORHER = config.smtp.from const MODUS_VORHER = config.termine.mode const DECKEL_VORHER = config.termine.loeschDeckel +const GESAMT_DECKEL_VORHER = config.termine.loeschDeckelGesamt const BASIS_VORHER = config.publicBaseUrl const iso = (wert) => new Date(wert).toISOString() @@ -132,6 +133,7 @@ afterEach(async () => { mock.restoreAll() config.termine.mode = MODUS_VORHER config.termine.loeschDeckel = DECKEL_VORHER + config.termine.loeschDeckelGesamt = GESAMT_DECKEL_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') @@ -695,7 +697,10 @@ test('wendeLoeschfristenAn: verfallen 48 Stunden nach heldUntil, abgesagt/abgele 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 + // F2: Funktion ohne gespeicherte Einstellung ⇒ 90; F4: unlesbare Einstellung ⇒ 90 (beide Funktionen gibt es als portalFeatures-Dokument, + // die Aufbewahrung läuft je Funktion) + aw.seed('portalFeatures', feature(F2)) + aw.seed('portalFeatures', feature('f_p_projekt04_termine')) 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?] @@ -1001,6 +1006,328 @@ test('taeglicherJob: gedeckelt zählt je Schritt und verfälscht den Löschzähl assert.match(tagesbericht(erg), /^\[termine\] Tagesjob: 5 Buchungen gelöscht \(verfallen 2, abgesagt 2, abgelehnt 1, erledigt 0, nichterschienen 0, abgeschaltete Funktionen 0\)/) }) +// ---------------------------------------------------------------- Aufbewahrung je Funktion, Gesamtdeckel, Sicherheitsnetz (A3b) + +/** erledigt/nichterschienen einer Funktion: `ueber` Millisekunden hinter der Frist von `tage` Tagen (> 0 fällig, -1 = eine Millisekunde zu jung). */ +const legeAufbewahrungsFall = (id, featureId, art, tage, ueber, over = {}) => legeBuchung(id, { featureId, status: art, ende: plus(JETZT, -tage * TAG - ueber), ...over }) +const deckelWarnungen = () => warnLog().filter((zeile) => /deckel/i.test(zeile)) +/** Ids der Buchungen, die es noch gibt (sortiert). */ +const uebrigeIds = () => buchungen().map((b) => b.$id).sort() + +test('wendeLoeschfristenAn: viele Betriebe – 60 Funktionen mit 14/90/365 Tagen ⇒ genau die 180 fälligen samt Zellen, jede Abfrage kurz', async () => { + assert.ok(config.termine.loeschDeckel >= 2000 && config.termine.loeschDeckelGesamt >= 20000, 'die Standard-Deckel liegen über den fälligen Buchungen, der Test setzt sie nicht') + const faellig = [] + const jung = [] + for (let j = 0; j < 60; j += 1) { + const featureId = `f_betrieb${nr(j)}` + const tage = [14, 90, 365][j % 3] + await richteFunktionEin(featureId, {}, { aufbewahrungTage: tage }) + for (let k = 0; k < 6; k += 1) { + const id = `b_${nr(j)}_${k}` + const zuJung = k >= 3 // drei fällig, drei eine Millisekunde zu jung; Status gemischt + await legeAufbewahrungsFall(id, featureId, k % 2 === 0 ? 'erledigt' : 'nichterschienen', tage, zuJung ? -1 : 1) + ;(zuJung ? jung : faellig).push(id) + } + } + assert.equal(faellig.length, 180) + const zellenProBuchung = claims(jung[0]).length + assert.ok(zellenProBuchung > 0) + + const erg = await wendeLoeschfristenAn(JETZT) + assert.deepEqual(erg, { verfallen: 0, abgesagt: 0, abgelehnt: 0, erledigt: 120, nichterschienen: 60, gedeckelt: 0 }) + for (const id of faellig) { + assert.equal(buchung(id), undefined, `${id} gelöscht`) + assert.equal(claims(id).length, 0, `${id}: Zellen gelöscht`) + } + for (const id of jung) { + assert.ok(buchung(id), `${id}: eine Millisekunde zu jung, bleibt`) + assert.equal(claims(id).length, zellenProBuchung, `${id}: Zellen bleiben`) + } + assert.deepEqual(uebrigeIds(), [...jung].sort()) + assert.ok(pruefeSeitenabfragen() >= 120, 'jede Funktion und jeder Status liest eine eigene kurze Seite (limit ≤ 100, ohne Gastfelder, kein $id-Seitenlauf)') + // die Funktionsliste liest nur die IDs + const funktionsListen = aw.listCalls('portalFeatures') + assert.ok(funktionsListen.length >= 1) + for (const { queries } of funktionsListen) assert.deepEqual(queries.find((q) => q.method === 'select')?.values, ['$id']) + assert.equal(fehlerLog().length, 0) + assert.equal(deckelWarnungen().length, 0) +}) + +test('wendeLoeschfristenAn: Deckel je Funktion – loeschDeckel 7 bei 20 Fälligen ⇒ 7 / 7 / 6, gedeckelt 1 / 1 / 0, Warnung ohne Kennungen', async () => { + config.termine.loeschDeckel = 7 + await richteFunktionEin(F1, {}, { aufbewahrungTage: 14 }) + for (let i = 0; i < 20; i += 1) await legeAufbewahrungsFall(`b_erl${nr(i)}`, F1, 'erledigt', 14, 1 + i * 1000) // i = 19 ist am längsten fällig + await legeAufbewahrungsFall('b_jung', F1, 'erledigt', 14, -1) + const zellenProBuchung = claims('b_erl000').length + const stimmt = () => { + assert.ok(buchung('b_jung'), 'nie eine junge Buchung') + assert.equal(aw.docs('termineClaims').size, buchungen().length * zellenProBuchung, 'Zellen passen zum Rest') + } + + let erg = await wendeLoeschfristenAn(JETZT) + assert.deepEqual(erg, { verfallen: 0, abgesagt: 0, abgelehnt: 0, erledigt: 7, nichterschienen: 0, gedeckelt: 1 }) + assert.deepEqual(uebrigeIds(), ['b_jung', ...Array.from({ length: 13 }, (_, i) => `b_erl${nr(i)}`)].sort(), 'die sieben ältesten sind weg') + assert.equal(deckelWarnungen().length, 1) + assert.doesNotMatch(deckelWarnungen()[0], /\bb_|\bf_|c_kunde|Müller|Mustermann/, 'keine ID, kein Name in der Warnung') + stimmt() + + log.warn.mock.resetCalls() + erg = await wendeLoeschfristenAn(JETZT) + assert.equal(erg.erledigt, 7) + assert.equal(erg.gedeckelt, 1) + assert.equal(buchungen().length, 1 + 6) + assert.equal(deckelWarnungen().length, 1) + stimmt() + + log.warn.mock.resetCalls() + erg = await wendeLoeschfristenAn(JETZT) + assert.equal(erg.erledigt, 6) + assert.equal(erg.gedeckelt, 0, 'die letzten sechs passen unter den Deckel') + assert.deepEqual(uebrigeIds(), ['b_jung']) + assert.equal(deckelWarnungen().length, 0) + stimmt() + assert.equal(fehlerLog().length, 0) +}) + +test('wendeLoeschfristenAn: der Deckel einer Funktion gilt für erledigt und nichterschienen zusammen und zählt höchstens einmal', async () => { + config.termine.loeschDeckel = 7 + await richteFunktionEin(F1, {}, { aufbewahrungTage: 14 }) + for (let i = 0; i < 5; i += 1) await legeAufbewahrungsFall(`b_erl${nr(i)}`, F1, 'erledigt', 14, 1 + i) + for (let i = 0; i < 5; i += 1) await legeAufbewahrungsFall(`b_nic${nr(i)}`, F1, 'nichterschienen', 14, 1 + i) + + let erg = await wendeLoeschfristenAn(JETZT) + assert.equal(erg.erledigt, 5) + assert.equal(erg.nichterschienen, 2, 'zusammen höchstens sieben') + assert.equal(erg.gedeckelt, 1) + assert.equal(buchungen().length, 3) + erg = await wendeLoeschfristenAn(JETZT) + assert.deepEqual([erg.erledigt, erg.nichterschienen, erg.gedeckelt], [0, 3, 0]) + assert.equal(buchungen().length, 0) + + // der erste Status füllt den Deckel genau und der zweite hat noch Fälliges: gedeckelt 1 (nicht 0, nicht 2) + for (let i = 0; i < 7; i += 1) await legeAufbewahrungsFall(`b_erl${nr(i)}`, F1, 'erledigt', 14, 1 + i) + for (let i = 0; i < 3; i += 1) await legeAufbewahrungsFall(`b_nic${nr(i)}`, F1, 'nichterschienen', 14, 1 + i) + erg = await wendeLoeschfristenAn(JETZT) + assert.deepEqual([erg.erledigt, erg.nichterschienen, erg.gedeckelt], [7, 0, 1]) + assert.deepEqual(uebrigeIds(), ['b_nic000', 'b_nic001', 'b_nic002']) + // ... und der erste Status am Deckel zählt genau einmal, der zweite wird in dem Lauf nicht mehr angefasst + for (let i = 0; i < 9; i += 1) await legeAufbewahrungsFall(`b_erl${nr(i)}`, F1, 'erledigt', 14, 1 + i) + erg = await wendeLoeschfristenAn(JETZT) + assert.deepEqual([erg.erledigt, erg.nichterschienen, erg.gedeckelt], [7, 0, 1]) + // ... und füllt der erste Status den Deckel genau und der zweite hat nichts Fälliges, ist der Lauf nicht gedeckelt + for (const b of buchungen()) await deleteDocument('termineBookings', b.$id) + for (let i = 0; i < 7; i += 1) await legeAufbewahrungsFall(`b_erx${nr(i)}`, F1, 'erledigt', 14, 1 + i) + await legeAufbewahrungsFall('b_nic_jung', F1, 'nichterschienen', 14, -1) + erg = await wendeLoeschfristenAn(JETZT) + assert.deepEqual([erg.erledigt, erg.nichterschienen, erg.gedeckelt], [7, 0, 0]) + assert.deepEqual(uebrigeIds(), ['b_nic_jung']) +}) + +test('wendeLoeschfristenAn: der Deckel gilt je Funktion – A mit 50 Fälligen und B mit 2, loeschDeckel 5 ⇒ A 5, B 2 im ersten Lauf', async () => { + config.termine.loeschDeckel = 5 + const A = 'f_aaaaaaaa' + const B = 'f_bbbbbbbb' + await richteFunktionEin(A, {}, { aufbewahrungTage: 14 }) + await richteFunktionEin(B, {}, { aufbewahrungTage: 14 }) + for (let i = 0; i < 50; i += 1) await legeAufbewahrungsFall(`b_a${nr(i)}`, A, 'erledigt', 14, 1 + i) + for (let i = 0; i < 2; i += 1) await legeAufbewahrungsFall(`b_b${nr(i)}`, B, 'nichterschienen', 14, 1 + i) + await legeAufbewahrungsFall('b_a_jung', A, 'erledigt', 14, -1) + await legeAufbewahrungsFall('b_b_jung', B, 'erledigt', 14, -1) + + const erg = await wendeLoeschfristenAn(JETZT) + assert.equal(erg.erledigt, 5) + assert.equal(erg.nichterschienen, 2) + assert.equal(erg.gedeckelt, 1, 'nur A endet am Deckel') + assert.equal(buchungen().filter((b) => b.featureId === A).length, 45 + 1) + assert.equal(buchungen().filter((b) => b.featureId === B).length, 1, 'B ist fertig, nur die junge bleibt') + assert.ok(buchung('b_a_jung') && buchung('b_b_jung'), 'nie eine junge Buchung') + assert.equal(deckelWarnungen().length, 1) +}) + +test('wendeLoeschfristenAn: Gesamtdeckel je Lauf – loeschDeckel 5, loeschDeckelGesamt 6 ⇒ insgesamt 6, der Rest folgt im nächsten Lauf', async () => { + config.termine.loeschDeckel = 5 + config.termine.loeschDeckelGesamt = 6 + const A = 'f_aaaaaaaa' + const B = 'f_bbbbbbbb' + await richteFunktionEin(A, {}, { aufbewahrungTage: 14 }) + await richteFunktionEin(B, {}, { aufbewahrungTage: 14 }) + for (let i = 0; i < 50; i += 1) await legeAufbewahrungsFall(`b_a${nr(i)}`, A, 'erledigt', 14, 1 + i) + for (let i = 0; i < 2; i += 1) await legeAufbewahrungsFall(`b_b${nr(i)}`, B, 'erledigt', 14, 1 + i) + await legeAufbewahrungsFall('b_a_jung', A, 'erledigt', 14, -1) + await legeAufbewahrungsFall('b_b_jung', B, 'erledigt', 14, -1) + const imA = () => buchungen().filter((b) => b.featureId === A && b.$id !== 'b_a_jung').length + const imB = () => buchungen().filter((b) => b.featureId === B && b.$id !== 'b_b_jung').length + + let erg = await wendeLoeschfristenAn(JETZT) + assert.equal(erg.erledigt + erg.nichterschienen, 6, 'insgesamt sechs') + assert.deepEqual([imA(), imB()], [45, 1], 'A bis zu seinem Deckel, B nur noch der Rest des Gesamtdeckels') + assert.equal(erg.gedeckelt, 2, 'A am Deckel der Funktion, B am Gesamtdeckel') + const warnungen = deckelWarnungen() + assert.equal(warnungen.length, 2) + assert.ok(warnungen.some((zeile) => /Gesamtdeckel/.test(zeile))) + for (const zeile of warnungen) assert.doesNotMatch(zeile, /\bb_|\bf_|c_kunde|Müller|Mustermann/, 'keine ID, kein Name') + + log.warn.mock.resetCalls() + erg = await wendeLoeschfristenAn(JETZT) + assert.equal(erg.erledigt, 6, 'der Rest folgt im nächsten Lauf') + assert.deepEqual([imA(), imB()], [40, 0]) + assert.equal(erg.gedeckelt, 1, 'B passt jetzt unter den Gesamtdeckel') + assert.ok(buchung('b_a_jung') && buchung('b_b_jung'), 'in keinem Lauf eine junge Buchung') + + // mit dem Standard für die Funktion bremst nur der Gesamtdeckel, danach wird die nächste Funktion in dem Lauf nicht mehr angefasst + config.termine.loeschDeckel = DECKEL_VORHER + erg = await wendeLoeschfristenAn(JETZT) + assert.equal(erg.erledigt, 6) + assert.deepEqual([imA(), imB()], [34, 0]) + assert.equal(erg.gedeckelt, 1) +}) + +test('wendeLoeschfristenAn: genau so viele Fällige wie der Gesamtdeckel ⇒ nicht gedeckelt; der Gesamtdeckel zählt alle Schritte des Laufs', async () => { + config.termine.loeschDeckelGesamt = 6 + await richteFunktionEin('f_aaaaaaaa', {}, { aufbewahrungTage: 14 }) + await richteFunktionEin('f_bbbbbbbb', {}, { aufbewahrungTage: 14 }) + for (let i = 0; i < 3; i += 1) await legeAufbewahrungsFall(`b_a${nr(i)}`, 'f_aaaaaaaa', 'erledigt', 14, 1 + i) + for (let i = 0; i < 3; i += 1) await legeAufbewahrungsFall(`b_b${nr(i)}`, 'f_bbbbbbbb', 'erledigt', 14, 1 + i) + await legeAufbewahrungsFall('b_c_jung', 'f_bbbbbbbb', 'erledigt', 14, -1) + let erg = await wendeLoeschfristenAn(JETZT) + assert.equal(erg.erledigt, 6) + assert.equal(erg.gedeckelt, 0, 'der Gesamtdeckel ist genau erreicht, es bleibt nichts Fälliges') + assert.equal(deckelWarnungen().length, 0) + assert.deepEqual(uebrigeIds(), ['b_c_jung']) + + // verfallen (Durchgang 1) zählt mit: 4 + 4 Fällige, Gesamtdeckel 6 ⇒ verfallen 4, erledigt 2 + for (let i = 0; i < 4; i += 1) await legeLoeschFall(`b_ver${nr(i)}`, 'verfallen', 1 + i) + for (let i = 0; i < 4; i += 1) await legeAufbewahrungsFall(`b_e${nr(i)}`, 'f_aaaaaaaa', 'erledigt', 14, 1 + i) + erg = await wendeLoeschfristenAn(JETZT) + assert.deepEqual(erg, { verfallen: 4, abgesagt: 0, abgelehnt: 0, erledigt: 2, nichterschienen: 0, gedeckelt: 1 }) + erg = await wendeLoeschfristenAn(JETZT) + assert.deepEqual([erg.erledigt, erg.gedeckelt], [2, 0]) + assert.deepEqual(uebrigeIds(), ['b_c_jung']) +}) + +test('wendeLoeschfristenAn: ist der Gesamtdeckel genau aufgebraucht und die nächste Funktion hat noch Fälliges, wird das gezählt, nie still übersprungen', async () => { + config.termine.loeschDeckelGesamt = 6 + await richteFunktionEin('f_aaaaaaaa', {}, { aufbewahrungTage: 14 }) + await richteFunktionEin('f_bbbbbbbb', {}, { aufbewahrungTage: 14 }) + for (let i = 0; i < 6; i += 1) await legeAufbewahrungsFall(`b_a${nr(i)}`, 'f_aaaaaaaa', 'erledigt', 14, 1 + i) // füllt den Gesamtdeckel genau + for (let i = 0; i < 2; i += 1) await legeAufbewahrungsFall(`b_b${nr(i)}`, 'f_bbbbbbbb', 'nichterschienen', 14, 1 + i) + + let erg = await wendeLoeschfristenAn(JETZT) + assert.deepEqual([erg.erledigt, erg.nichterschienen, erg.gedeckelt], [6, 0, 1]) + assert.deepEqual(uebrigeIds(), ['b_b000', 'b_b001']) + assert.equal(deckelWarnungen().length, 1) + assert.match(deckelWarnungen()[0], /Gesamtdeckel/) + + erg = await wendeLoeschfristenAn(JETZT) + assert.deepEqual([erg.erledigt, erg.nichterschienen, erg.gedeckelt], [0, 2, 0]) + assert.deepEqual(uebrigeIds(), []) +}) + +test('wendeLoeschfristenAn: zweiter Boden – ändert sich das Ende zwischen Liste und Sperre, bleibt die Buchung', async () => { + await richteFunktionEin(F1, {}, { aufbewahrungTage: 14 }) + const doc = await legeAufbewahrungsFall('b_spaet', F1, 'erledigt', 14, 1000) + await legeAufbewahrungsFall('b_alt', F1, 'erledigt', 14, 2000) + let eingeworfen = false + await mitEinwurf( + listeVonBuchungen(/"erledigt"/), + async () => { + eingeworfen = true + aw.seed('termineBookings', { ...doc, endsAt: iso(plus(JETZT, -TAG)) }) // der Termin wurde verschoben: Ende liegt jetzt innerhalb der Frist + }, + () => wendeLoeschfristenAn(JETZT), + ) + assert.ok(eingeworfen) + assert.ok(buchung('b_spaet'), 'die Frist gilt ab dem frisch gelesenen Ende') + assert.ok(claims('b_spaet').length > 0) + assert.equal(buchung('b_alt'), undefined) +}) + +test('wendeLoeschfristenAn: Sicherheitsnetz – erledigt/nichterschienen ohne Funktionsdokument verschwinden nach 365 Tagen, nie früher', async () => { + await legeAufbewahrungsFall('b_waise_366', 'f_ohne_funktion', 'erledigt', 366, 0) + await legeAufbewahrungsFall('b_waise_nic', 'f_ohne_funktion', 'nichterschienen', 366, 0) + await legeAufbewahrungsFall('b_waise_200', 'f_ohne_funktion', 'erledigt', 200, 0) // dokumentierte Grenze: ohne Funktion bleibt sie bis 365 Tage + await legeAufbewahrungsFall('b_waise_grenze_alt', 'f_ohne_funktion', 'erledigt', 365, 1) + await legeAufbewahrungsFall('b_waise_grenze_jung', 'f_ohne_funktion', 'erledigt', 365, -1) + await legeAufbewahrungsFall('b_waise_fest', 'f_ohne_funktion', 'bestaetigt', 400, 1) // andere Status nie + + const erg = await wendeLoeschfristenAn(JETZT) + assert.deepEqual(erg, { verfallen: 0, abgesagt: 0, abgelehnt: 0, erledigt: 2, nichterschienen: 1, gedeckelt: 0 }) + assert.deepEqual(uebrigeIds(), ['b_waise_200', 'b_waise_fest', 'b_waise_grenze_jung']) + for (const id of ['b_waise_366', 'b_waise_nic', 'b_waise_grenze_alt']) assert.equal(claims(id).length, 0, `${id}: Zellen mit der Buchung weg`) + pruefeSeitenabfragen() +}) + +test('wendeLoeschfristenAn: Sicherheitsnetz – Funktion mit unlesbarer Einstellung: 100 Tage bleiben, 366 Tage sind weg', async () => { + await richteFunktionEin(F1, {}, { aufbewahrungTage: 365 }) + await legeAufbewahrungsFall('b_f1_100', F1, 'erledigt', 100, 1) + await legeAufbewahrungsFall('b_f1_366', F1, 'erledigt', 366, 0) + aw.failNext({ collection: 'termineConfig', method: 'GET', status: 500, message: 'Nachbau kaputt' }) + + let erg = await wendeLoeschfristenAn(JETZT) + assert.equal(erg.erledigt, 1) + assert.deepEqual(uebrigeIds(), ['b_f1_100'], 'nie früher, nur weil die Einstellung nicht lesbar war') + assert.ok(fehlerLog().length >= 1) + + erg = await wendeLoeschfristenAn(JETZT) // Einstellung wieder lesbar: 365 Tage + assert.equal(erg.erledigt, 0) + assert.ok(buchung('b_f1_100')) +}) + +test('wendeLoeschfristenAn: ist die Liste der Funktionen nicht lesbar, löscht das Sicherheitsnetz trotzdem erst nach 365 Tagen', async () => { + await richteFunktionEin(F1, {}, { aufbewahrungTage: 14 }) + await legeAufbewahrungsFall('b_f1_100', F1, 'erledigt', 100, 1) // nach der Einstellung (14) fällig + await legeAufbewahrungsFall('b_f1_366', F1, 'erledigt', 366, 0) + aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau kaputt' }) + + let erg = await wendeLoeschfristenAn(JETZT) + assert.equal(erg.erledigt, 1) + assert.deepEqual(uebrigeIds(), ['b_f1_100'], 'ohne gelesene Funktion läuft nur das Netz von 365 Tagen') + assert.ok(fehlerLog().length >= 1) + erg = await wendeLoeschfristenAn(JETZT) + assert.equal(erg.erledigt, 1, 'am nächsten Tag gilt die Einstellung der Funktion wieder') + assert.deepEqual(uebrigeIds(), []) +}) + +test('taeglicherJob: gedeckelt zählt je Schritt; der Tagesbericht nennt „Löschläufe am Deckel“ nur dann, direkt nach „Mails nicht zugestellt“', async () => { + config.termine.loeschDeckel = 2 + await richteFunktionEin(F1, {}, { aufbewahrungTage: 14 }) + for (let i = 0; i < 5; i += 1) await legeAufbewahrungsFall(`b_erl${nr(i)}`, F1, 'erledigt', 14, 1 + i) + for (let i = 0; i < 3; i += 1) await legeLoeschFall(`b_ver${nr(i)}`, 'verfallen', 1 + i) + + let erg = await taeglicherJob(JETZT) + assert.deepEqual(erg.loeschfristen, { verfallen: 2, abgesagt: 0, abgelehnt: 0, erledigt: 2, nichterschienen: 0 }) + assert.deepEqual(erg.gedeckelt, { loeschfristen: 2, abgeschaltet: 0, zellen: 0 }) + let zeile = tagesbericht(erg) + assert.match(zeile, /^\[termine\] Tagesjob: 4 Buchungen gelöscht \(/) + assert.match(zeile, /, 0 Mails nicht zugestellt, Löschläufe am Deckel: 2, Rest folgt an den nächsten Tagen, 0 Fehler$/) + assert.doesNotMatch(zeile, /\bb_|\bf_|@|Mustermann|Salon/) + + config.termine.loeschDeckel = DECKEL_VORHER + erg = await taeglicherJob(JETZT) + assert.deepEqual(erg.gedeckelt, { loeschfristen: 0, abgeschaltet: 0, zellen: 0 }) + zeile = tagesbericht(erg) + assert.doesNotMatch(zeile, /Deckel/, 'ohne Deckel bleibt die Zeile unverändert') + assert.match(zeile, /, 0 Mails nicht zugestellt, 0 Fehler$/) +}) + +test('tagesbericht: das Segment zählt die Schritte aller Bereiche und fehlt bei gedeckelt 0', () => { + const basis = { + festgefahren: 3, + loeschfristen: { verfallen: 1, abgesagt: 2, abgelehnt: 3, erledigt: 4, nichterschienen: 5 }, + abgeschaltet: { funktionen: 1, buchungen: 6 }, + aufgeraeumt: 7, + zellen: 8, + fehler: 9, + } + const ohne = tagesbericht({ ...basis, gedeckelt: { loeschfristen: 0, abgeschaltet: 0, zellen: 0 } }) + assert.equal( + ohne, + '[termine] Tagesjob: 21 Buchungen gelöscht (verfallen 1, abgesagt 2, abgelehnt 3, erledigt 4, nichterschienen 5, abgeschaltete Funktionen 6), 1 Funktionen bereinigt, 8 Zellen, 7 Outbox, 3 Mails nicht zugestellt, 9 Fehler', + ) + const mit = tagesbericht({ ...basis, gedeckelt: { loeschfristen: 2, abgeschaltet: 1, zellen: 1 } }) + assert.equal(mit, ohne.replace(', 9 Fehler', ', Löschläufe am Deckel: 4, Rest folgt an den nächsten Tagen, 9 Fehler')) +}) + 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) }) diff --git a/test/termineSchalter.test.js b/test/termineSchalter.test.js index bb53ad4..d6c2ff9 100644 --- a/test/termineSchalter.test.js +++ b/test/termineSchalter.test.js @@ -48,6 +48,7 @@ test('Konfiguration: Vorgaben ohne Umgebungsvariablen', async () => { zelleMin: 5, mailsProStunde: 200, loeschDeckel: 2000, + loeschDeckelGesamt: 20000, formMinSekunden: 3, minAntwortMs: 300, })