Terminplaner: Aufbewahrungs-Loeschung je Funktion mit Deckel, Sicherheitsnetz und Bericht

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 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 09:09:48 +00:00
1 parent 5c95dfc322
commit 9b4305e5de
4 files changed
+423 -43

No files matched your search

+4 -1
View File
@@ -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
+90 -41
View File
@@ -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
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
/**
* 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 arbeiteSeiten(lauf, {
was: `Löschfrist ${status}`,
deckel: config.termine.loeschDeckel,
holeSeite: () => loeschKandidaten(status, new Date(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
},
})
if (erg.gedeckelt) gedeckelt += 1
})
}),
{ geloescht: 0, gedeckelt: false },
)
rest -= erg.geloescht
if (erg.gedeckelt) {
gedeckelt += 1
if (amGesamt) amGesamtdeckel = true
}
return erg
}
// Bisheriger Weg fuer erledigt/nichterschienen: Kandidaten ab der fruehesten moeglichen Frist, dann entscheidet die Frist der 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
// Durchgang (1): verfallen, abgesagt, abgelehnt
for (const [status, behalten] of [
['verfallen', VERFALLEN_BEHALTEN_MS],
['abgesagt', ABGESAGT_BEHALTEN_MS],
['abgelehnt', ABGESAGT_BEHALTEN_MS],
]) {
await loescheSeiten(`Löschfrist ${status}`, status, deckelJeSchritt, undefined, lauf.nowMs - behalten)
}
})
// 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 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`
)
}
+328 -1
View File
@@ -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) })
+1
View File
@@ -48,6 +48,7 @@ test('Konfiguration: Vorgaben ohne Umgebungsvariablen', async () => {
zelleMin: 5,
mailsProStunde: 200,
loeschDeckel: 2000,
loeschDeckelGesamt: 20000,
formMinSekunden: 3,
minAntwortMs: 300,
})