Terminplaner: Loeschung abgeschalteter Funktionen seitenweise, Rest an Folgetagen

loescheAbgeschaltete liest je abgeschalteter Funktion (disabledAt vor 30 Tagen) Seiten zu 100
(neu buchungen.ersteBuchungen: nur $id, limit, nie Gastfelder) und loescht sie ueber arbeiteSeiten
samt Zellen, bis die Seite leer ist. Die Funktion wird nach jedem Lesen einer Seite und alle 50
Buchungen frisch geprueft (nochAbgeschaltet); wird sie wieder eingeschaltet, endet die Loeschung
fuer sie sofort. Deckel config.termine.loeschDeckel je Funktion: am Deckel bleibt die Funktion
disabled mit altem disabledAt, eine Warnung ohne Kennungen steht im Log, der naechste Tag macht
weiter, die uebrigen Funktionen kommen im selben Lauf dran. Die Einstellung (termineConfig) wird
nur nach leerer Seite ohne Fehler und mit letzter Pruefung unter termine-cfg:<id> geloescht.
loescheAbgeschalteteFunktionen liefert { funktionen, buchungen, gedeckelt }; taeglicherJob traegt
gedeckelt.abgeschaltet getrennt vom Loeschzaehler ein (Tagesbericht: Loeschlaeufe am Deckel).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 09:24:33 +00:00
1 parent 9b4305e5de
commit a1732613c9
4 files changed
+366 -58

No files matched your search

+12
View File
@@ -210,6 +210,18 @@ export async function loeschKandidaten(status, vor, { featureId = null, limit =
/** Feld der Bezugszeit einer Loeschfrist (siehe loeschKandidaten); undefined fuer Status, die nicht automatisch geloescht werden. */
export const loeschBezugsfeld = (status) => (Object.hasOwn(LOESCH_BEZUG, status) ? LOESCH_BEZUG[status] : undefined)
/**
* Eine Seite Buchungen einer Funktion (jeder Status, auch Sperren): hoechstens `limit` (Standard 100), nur mit der Kennung ($id),
* nie mit Gastfeldern. Fuer die Loeschung abgeschalteter Funktionen: wer loescht, liest die Liste danach neu (Geloeschtes fehlt).
* Ohne Funktion oder mit ungueltiger Grenze: TypeError (nie eine Abfrage ohne Funktionsfilter oder ohne Grenze, die Treffer werden
* unwiderruflich geloescht).
*/
export async function ersteBuchungen(featureId, limit = LOESCH_SEITE) {
if (typeof featureId !== 'string' || featureId === '') throw new TypeError('ersteBuchungen: featureId muss ein nicht leerer Text sein')
if (!Number.isInteger(limit) || limit < 1) throw new TypeError(`ersteBuchungen: limit muss eine ganze Zahl ab 1 sein, nicht ${limit}`)
return listDocuments(col(), [Query.equal('featureId', featureId), Query.limit(limit), Query.select(['$id'])])
}
/** Zahl der offenen Anfragen (Status angefragt) einer Funktion. */
export async function zaehleAnfragen(featureId) {
return (await listAllDocuments(col(), [Query.equal('featureId', featureId), Query.equal('status', 'angefragt')])).length
+89 -48
View File
@@ -6,6 +6,7 @@ import {
abgelaufeneHaltungen,
aendere,
erinnerungsKandidaten,
ersteBuchungen,
hole,
loesche,
loeschBezugsfeld,
@@ -44,7 +45,8 @@ import { gibZellenFrei, raeumeVerwaisteZellen } from './zellen.js'
* - 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 (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.
* der Rest folgt am naechsten Tag und wird gezaehlt (`gedeckelt`), nie still uebersprungen. Ebenso die Loeschung abgeschalteter
* Funktionen: je Funktion Seiten zu 100, hoechstens config.termine.loeschDeckel je Lauf, die Einstellung erst nach einer leeren Seite.
*/
const MIN_MS = 60 * 1000
@@ -471,55 +473,89 @@ async function nochAbgeschaltet(lauf, funktionId, grenze) {
return false
}
/**
* Buchungen EINER abgeschalteten Funktion seitenweise loeschen (arbeiteSeiten): Seite lesen (hoechstens 100, nur Kennungen), jede
* Buchung samt Zellen unter ihrer Sperre loeschen, neu lesen, bis die Seite leer ist. Die Funktion wird frisch gelesen
* (nochAbgeschaltet) vor jeder Seite nach dem Lesen und nach jeweils 50 Buchungen; gilt sie nicht mehr als abgeschaltet, endet die
* Loeschung fuer sie sofort (Log, kein Wurf). Am Deckel config.termine.loeschDeckel endet der Lauf mit Warnung (der Rest folgt am
* naechsten Tag). Die Einstellung wird nur nach einer leeren Seite ohne Fehler geloescht, mit letzter Pruefung unter der Sperre
* termine-cfg:<id>; sonst bleibt die Funktion samt altem disabledAt stehen und der naechste Tag macht weiter.
* => { geloescht, gedeckelt, einstellungWeg }
*/
async function loescheAbgeschalteteFunktion(lauf, funktionId, grenze) {
const deckel = config.termine.loeschDeckel
let abgebrochen = false // die Funktion gilt nicht mehr als abgeschaltet (oder war nicht lesbar): nichts mehr loeschen
let begonnen = 0 // Buchungen, deren Loeschung begonnen wurde
let geprueftBei = 0 // Stand von `begonnen` bei der letzten Pruefung
const pruefe = async () => {
geprueftBei = begonnen
if (!(await nochAbgeschaltet(lauf, funktionId, grenze))) abgebrochen = true
return !abgebrochen
}
const seiten = await schritt(
lauf,
'Buchungen abgeschalteter Funktion',
() =>
arbeiteSeiten(lauf, {
was: 'Löschung abgeschalteter Funktion',
deckel,
deckelText: `von ${deckel} je Funktion`,
holeSeite: async () => {
if (abgebrochen) return []
const seite = await ersteBuchungen(funktionId)
// vor dem Loeschen der Seite frisch pruefen: das Personal kann die Funktion zwischen Lesen und Loeschen wieder einschalten
return seite.length > 0 && !(await pruefe()) ? [] : seite
},
bearbeite: async (buchung) => {
if (abgebrochen) return false
if (begonnen - geprueftBei >= ABGESCHALTET_NEU_PRUEFEN_ALLE && !(await pruefe())) return false
begonnen += 1
return loescheMitZellen(lauf, buchung.$id, null)
},
}),
{ geloescht: 0, gedeckelt: false, vollstaendig: false },
)
let einstellungWeg = false
if (seiten.vollstaendig && !abgebrochen) {
einstellungWeg = await schritt(
lauf,
'Einstellung abgeschalteter Funktion',
() =>
withLocks([`termine-cfg:${funktionId}`], async () => {
if (!(await nochAbgeschaltet(lauf, funktionId, grenze))) return false
try {
await deleteDocument(config.collections.termineConfig, funktionId)
return true
} catch (err) {
if (err?.status === 404 && !fehlendeCollection(err)) return false // schon weg
throw err
}
}),
false,
)
}
return { geloescht: seiten.geloescht, gedeckelt: seiten.gedeckelt, einstellungWeg }
}
/**
* Funktionen im Zustand `disabled` seit mehr als 30 Tagen (disabledAt): alle Buchungen mit Zellen und zuletzt die Einstellung
* loeschen. Vor der Buchungsliste, nach jeweils 50 Buchungen und vor dem Loeschen der Einstellung wird die Funktion frisch gelesen
* (nochAbgeschaltet); gilt sie nicht mehr als abgeschaltet, endet die Loeschung fuer diese Funktion sofort (Log, kein Wurf).
* Scheitert eine Buchung, bleibt die Einstellung bis zum naechsten Lauf. Zellen ohne Buchung raeumt danach der Tagesjob.
* funktionen zaehlt die Funktionen, bei denen etwas geloescht wurde (Buchungen oder Einstellung).
* loeschen, je Funktion seitenweise mit Deckel (loescheAbgeschalteteFunktion). Endet eine Funktion am Deckel oder an einem Fehler,
* bleibt sie `disabled` mit altem disabledAt und der naechste Tag macht weiter; die anderen Funktionen kommen im selben Lauf dran.
* funktionen zaehlt die Funktionen, bei denen etwas geloescht wurde (Buchungen oder Einstellung), gedeckelt die Funktionen, die am
* Deckel endeten. Zellen ohne Buchung raeumt danach der Tagesjob.
*/
async function loescheAbgeschaltete(lauf) {
const erg = { funktionen: 0, buchungen: 0 }
const erg = { funktionen: 0, buchungen: 0, gedeckelt: 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
const buchungen = await schritt(lauf, 'Buchungen abgeschalteter Funktion', () => listAllDocuments(config.collections.termineBookings, [Query.equal('featureId', funktion.$id)]), null)
if (buchungen === null || !(await nochAbgeschaltet(lauf, funktion.$id, grenze))) continue
let geloescht = 0
let vollstaendig = true
for (const [i, buchung] of buchungen.entries()) {
if (i > 0 && i % ABGESCHALTET_NEU_PRUEFEN_ALLE === 0 && !(await nochAbgeschaltet(lauf, funktion.$id, grenze))) {
vollstaendig = false
break
}
const weg = await loescheMitZellen(lauf, buchung.$id, null)
if (weg) geloescht += 1
if (weg === undefined) vollstaendig = false
}
erg.buchungen += geloescht
let einstellungWeg = false
if (vollstaendig) {
einstellungWeg = await schritt(
lauf,
'Einstellung abgeschalteter Funktion',
() =>
withLocks([`termine-cfg:${funktion.$id}`], async () => {
if (!(await nochAbgeschaltet(lauf, funktion.$id, grenze))) return false
try {
await deleteDocument(config.collections.termineConfig, funktion.$id)
return true
} catch (err) {
if (err?.status === 404 && !fehlendeCollection(err)) return false // schon weg
throw err
}
}),
false,
)
}
if (geloescht > 0 || einstellungWeg) erg.funktionen += 1
const weg = await loescheAbgeschalteteFunktion(lauf, funktion.$id, grenze)
erg.buchungen += weg.geloescht
if (weg.gedeckelt) erg.gedeckelt += 1
if (weg.geloescht > 0 || weg.einstellungWeg) erg.funktionen += 1
}
return erg
}
@@ -539,10 +575,11 @@ export function wendeLoeschfristenAn(now) {
}
/**
* Abgeschaltete Funktionen nach 30 Tagen: Buchungen, Zellen, Einstellung loeschen. Baustein des Tagesjobs, ohne Schalter- und
* Ueberlappungspruefung (fuer Tests).
* Abgeschaltete Funktionen nach 30 Tagen: Buchungen, Zellen, Einstellung loeschen, seitenweise und je Funktion bis config.termine.
* loeschDeckel Buchungen je Lauf. Baustein des Tagesjobs, ohne Schalter- und Ueberlappungspruefung (fuer Tests).
* @param {Date} now
* @returns {Promise<{ funktionen: number, buchungen: number }>}
* @returns {Promise<{ funktionen: number, buchungen: number, gedeckelt: number }>}
* gedeckelt = Zahl der Funktionen, die am Deckel endeten
*/
export function loescheAbgeschalteteFunktionen(now) {
return loescheAbgeschaltete(einzelLauf('abgeschalteteFunktionen', now))
@@ -558,11 +595,15 @@ export async function taeglicherJob(now) {
return fuehreAus('taeglicherJob', now, async (lauf) => {
const festgefahren = await schritt(lauf, 'Festgefahrene Mails', () => beendeFestgefahrene(now), 0)
const { gedeckelt: loeschfristenGedeckelt, ...loeschung } = await loeschfristen(lauf, now)
const abgeschaltet = await schritt(lauf, 'Abgeschaltete Funktionen', () => loescheAbgeschaltete(lauf), { funktionen: 0, buchungen: 0 })
const { gedeckelt: abgeschaltetGedeckelt, ...abgeschaltet } = await schritt(lauf, 'Abgeschaltete Funktionen', () => loescheAbgeschaltete(lauf), {
funktionen: 0,
buchungen: 0,
gedeckelt: 0,
})
const aufgeraeumt = await schritt(lauf, 'Warteschlange aufräumen', () => raeumeAuf(now), 0)
const zellen = await schritt(lauf, 'Verwaiste Zellen', () => raeumeVerwaisteZellen(now), 0)
// gedeckelt: Schritte, die am Deckel endeten (abgeschaltet und zellen fuellen spaetere Tasks); getrennt vom Loeschzaehler, damit die Summe im Tagesbericht stimmt
const gedeckelt = { loeschfristen: loeschfristenGedeckelt, abgeschaltet: 0, zellen: 0 }
// gedeckelt: Schritte bzw. Funktionen, die am Deckel endeten (zellen fuellt ein spaeterer Task); getrennt vom Loeschzaehler, damit die Summe im Tagesbericht stimmt
const gedeckelt = { loeschfristen: loeschfristenGedeckelt, abgeschaltet: abgeschaltetGedeckelt, zellen: 0 }
return { festgefahren, loeschfristen: loeschung, gedeckelt, abgeschaltet, aufgeraeumt, zellen, fehler: lauf.fehler }
})
}
+224 -10
View File
@@ -1343,7 +1343,7 @@ test('loescheAbgeschalteteFunktionen: disabled länger als 30 Tage ⇒ Buchungen
const abgeschaltet = ['b_fest_0', 'b_alt_0']
const erg = await loescheAbgeschalteteFunktionen(JETZT)
assert.deepEqual(erg, { funktionen: 1, buchungen: 2 })
assert.deepEqual(erg, { funktionen: 1, buchungen: 2, gedeckelt: 0 })
for (const id of abgeschaltet) {
assert.equal(buchung(id), undefined, id)
assert.equal(claims(id).length, 0, `${id}: Zellen`)
@@ -1352,7 +1352,7 @@ test('loescheAbgeschalteteFunktionen: disabled länger als 30 Tage ⇒ Buchungen
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: 0, buchungen: 0 }, 'zweiter Lauf: nichts mehr zu löschen, die Funktion zählt nicht noch einmal')
assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 0, buchungen: 0, gedeckelt: 0 }, 'zweiter Lauf: nichts mehr zu löschen, die Funktion zählt nicht noch einmal')
})
test('loescheAbgeschalteteFunktionen: scheitert das Löschen einer Buchung, bleibt die Einstellung bis zum nächsten Lauf', async () => {
@@ -1362,15 +1362,15 @@ test('loescheAbgeschalteteFunktionen: scheitert das Löschen einer Buchung, blei
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.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 1, buchungen: 1, gedeckelt: 0 })
assert.equal(buchung('b_eins'), undefined)
assert.equal(aw.docs('termineConfig').get(F1), undefined)
})
/** Eine abgeschaltete Funktion (seit 40 Tagen) mit n Buchungen; mit zellen bekommt jede ihre Zellen. */
async function abgeschaltetMitBuchungen(n, { zellen = true } = {}) {
await richteFunktionEin(F1, { state: 'disabled', disabledAt: iso(plus(JETZT, -40 * TAG)) })
for (let i = 0; i < n; i++) await legeBuchung(`b_ab${String(i).padStart(3, '0')}`, { start: plus(JETZT, TAG + i * 2 * STUNDE), zellen })
/** Eine abgeschaltete Funktion (seit 40 Tagen, Standard F1) mit n Buchungen `<praefix>000…`; mit zellen bekommt jede ihre Zellen. */
async function abgeschaltetMitBuchungen(n, { zellen = true, featureId = F1, praefix = 'b_ab' } = {}) {
await richteFunktionEin(featureId, { state: 'disabled', disabledAt: iso(plus(JETZT, -40 * TAG)) })
for (let i = 0; i < n; i++) await legeBuchung(`${praefix}${String(i).padStart(3, '0')}`, { featureId, start: plus(JETZT, TAG + i * 2 * STUNDE), zellen })
}
/** Das Personal schaltet die Funktion wieder ein: siteFeatures.activate sperrt nicht und lässt disabledAt stehen. */
const schalteEin = () => aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(F1), state: 'active', enabled: true })
@@ -1386,7 +1386,7 @@ for (const [wann, passt] of [['zwischen Funktionsliste und Buchungsliste', funkt
test(`loescheAbgeschalteteFunktionen: ${was} ${wann} ⇒ nichts wird gelöscht`, async () => {
await abgeschaltetMitBuchungen(3)
const erg = await mitEinwurf(passt, async () => umschalten(), () => loescheAbgeschalteteFunktionen(JETZT))
assert.deepEqual(erg, { funktionen: 0, buchungen: 0 })
assert.deepEqual(erg, { funktionen: 0, buchungen: 0, gedeckelt: 0 })
assert.equal(buchungen().length, 3, 'alle Buchungen bleiben')
assert.equal(aw.docs('termineClaims').size > 0, true)
for (const b of buchungen()) assert.ok(claims(b.$id).length > 0, `${b.$id}: Zellen bleiben`)
@@ -1416,7 +1416,7 @@ test('loescheAbgeschalteteFunktionen: wird die Funktion nach 50 Buchungen wieder
globalThis.fetch = echt
}
assert.equal(geloescht, 50, 'nach dem Einschalten kommt keine Löschung mehr')
assert.deepEqual(erg, { funktionen: 1, buchungen: 50 })
assert.deepEqual(erg, { funktionen: 1, buchungen: 50, gedeckelt: 0 })
assert.equal(buchungen().length, 10)
assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt')
})
@@ -1434,12 +1434,192 @@ test('loescheAbgeschalteteFunktionen: kann die Funktion vor dem Löschen nicht g
await abgeschaltetMitBuchungen(2)
aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau kaputt', after: 1 }) // 1. GET = Liste, 2. = Neulesen
const erg = await loescheAbgeschalteteFunktionen(JETZT)
assert.deepEqual(erg, { funktionen: 0, buchungen: 0 })
assert.deepEqual(erg, { funktionen: 0, buchungen: 0, gedeckelt: 0 })
assert.equal(buchungen().length, 2)
assert.ok(aw.docs('termineConfig').get(F1))
assert.equal(fehlerLog().length, 1)
})
// Seitenweise Loeschung abgeschalteter Funktionen (A4): Seiten zu 100, Deckel loeschDeckel je Funktion, Einstellung erst nach leerer Seite
test('loescheAbgeschalteteFunktionen: seitenweise mit Deckel – 230 Buchungen, loeschDeckel 100 ⇒ 100 / 100 / 30, die Einstellung erst im dritten Lauf', async () => {
config.termine.loeschDeckel = 100
await abgeschaltetMitBuchungen(230)
const zellenProBuchung = claims('b_ab000').length
assert.ok(zellenProBuchung > 0)
const funktion = () => aw.docs('portalFeatures').get(F1)
const vorher = { state: funktion().state, disabledAt: funktion().disabledAt }
const stimmt = (uebrig) => {
assert.equal(buchungen().length, uebrig, 'Rest')
assert.equal(aw.docs('termineClaims').size, uebrig * zellenProBuchung, 'Zellen passen zum Rest')
assert.deepEqual({ state: funktion().state, disabledAt: funktion().disabledAt }, vorher, 'Zustand und disabledAt bleiben, die nächste Frist beginnt nicht neu')
}
let erg = await loescheAbgeschalteteFunktionen(JETZT)
assert.deepEqual(erg, { funktionen: 1, buchungen: 100, gedeckelt: 1 })
stimmt(130)
assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt')
assert.equal(deckelWarnungen().length, 1)
assert.doesNotMatch(deckelWarnungen()[0], /\bb_|f_p_|c_kunde|Müller|Mustermann/, 'keine ID, kein Name in der Warnung')
log.warn.mock.resetCalls()
erg = await loescheAbgeschalteteFunktionen(JETZT)
assert.deepEqual(erg, { funktionen: 1, buchungen: 100, gedeckelt: 1 }, 'Lauf 2: weitere 100')
stimmt(30)
assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt')
assert.equal(deckelWarnungen().length, 1)
log.warn.mock.resetCalls()
erg = await loescheAbgeschalteteFunktionen(JETZT)
assert.deepEqual(erg, { funktionen: 1, buchungen: 30, gedeckelt: 0 }, 'Lauf 3: die letzten 30')
stimmt(0)
assert.equal(aw.docs('termineConfig').get(F1), undefined, 'Einstellung erst jetzt gelöscht')
assert.equal(deckelWarnungen().length, 0)
assert.equal(fehlerLog().length, 0)
assert.equal(pruefeSeitenabfragen(), 6, 'je Lauf zwei Listenabfragen (limit ≤ 100, nur die Kennung), keine mit 500')
})
test('loescheAbgeschalteteFunktionen: der Deckel gilt je Funktion – A 150, B 10, loeschDeckel 100 ⇒ B im selben Lauf komplett samt Einstellung, A noch nicht', async () => {
config.termine.loeschDeckel = 100
await abgeschaltetMitBuchungen(150) // A = F1 steht in der Funktionsliste vor B
await abgeschaltetMitBuchungen(10, { featureId: F2, praefix: 'b_cd' })
const jeFunktion = (featureId) => buchungen().filter((b) => b.featureId === featureId).length
let erg = await loescheAbgeschalteteFunktionen(JETZT)
assert.deepEqual(erg, { funktionen: 2, buchungen: 110, gedeckelt: 1 })
assert.equal(jeFunktion(F1), 50, 'A: 100 gelöscht, der Rest folgt')
assert.ok(aw.docs('termineConfig').get(F1), 'A: Einstellung bleibt')
assert.equal(jeFunktion(F2), 0, 'B: im selben Lauf komplett')
assert.equal(aw.docs('termineConfig').get(F2), undefined, 'B: Einstellung gelöscht')
assert.equal(deckelWarnungen().length, 1, 'nur A endete am Deckel')
erg = await loescheAbgeschalteteFunktionen(JETZT)
assert.deepEqual(erg, { funktionen: 1, buchungen: 50, gedeckelt: 0 }, 'B zählt nicht noch einmal')
assert.equal(jeFunktion(F1), 0)
assert.equal(aw.docs('termineConfig').get(F1), undefined)
assert.equal(fehlerLog().length, 0)
})
test('loescheAbgeschalteteFunktionen: genau so viele Buchungen wie der Deckel ⇒ nicht gedeckelt, Einstellung im selben Lauf weg', async () => {
config.termine.loeschDeckel = 7
await abgeschaltetMitBuchungen(7, { zellen: false })
assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 1, buchungen: 7, gedeckelt: 0 })
assert.equal(aw.docs('termineConfig').get(F1), undefined)
assert.equal(deckelWarnungen().length, 0)
})
test('loescheAbgeschalteteFunktionen: ohne Buchungen geht nur die Einstellung weg (funktionen 1, buchungen 0)', async () => {
await abgeschaltetMitBuchungen(0)
assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 1, buchungen: 0, gedeckelt: 0 })
assert.equal(aw.docs('termineConfig').get(F1), undefined)
assert.equal(fehlerLog().length, 0)
})
test('loescheAbgeschalteteFunktionen: wird die Funktion nach der ersten Seite wieder eingeschaltet, folgt keine weitere Löschung (Einstellung bleibt, Log, kein Fehler)', async () => {
await abgeschaltetMitBuchungen(230, { zellen: false })
let listen = 0
const zweiteListe = (url, methode) => listeVonBuchungen(/featureId/)(url, methode) && (listen += 1) === 2
const erg = await mitEinwurf(zweiteListe, async () => schalteEin(), () => loescheAbgeschalteteFunktionen(JETZT))
assert.deepEqual(erg, { funktionen: 1, buchungen: 100, gedeckelt: 0 })
assert.equal(buchungen().length, 130, 'nach dem Einschalten kommt keine Löschung mehr')
assert.equal(aw.listCalls('termineBookings').length, 2, 'und keine weitere Seite wird gelesen')
assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt')
assert.equal(aw.docs('portalFeatures').get(F1).state, 'active')
assert.ok(warnLog().some((zeile) => /inzwischen wieder aktiv/.test(zeile)), 'Abbruch steht im Log')
assert.equal(fehlerLog().length, 0, 'kein Fehler, kein Wurf')
})
test('loescheAbgeschalteteFunktionen: wird die Funktion wieder eingeschaltet, während die zweite Seite gelesen wird, wird auch von ihr nichts gelöscht', async () => {
await abgeschaltetMitBuchungen(120, { zellen: false })
let listen = 0
// nach der Antwort auf die zweite Listenabfrage (20 Buchungen), vor der Prüfung der Seite
const zweiteListe = (url, methode) => listeVonBuchungen(/featureId/)(url, methode) && (listen += 1) === 2
const erg = await mitEinwurf(zweiteListe, async () => schalteEin(), () => loescheAbgeschalteteFunktionen(JETZT))
assert.deepEqual(erg, { funktionen: 1, buchungen: 100, gedeckelt: 0 })
assert.equal(buchungen().length, 20, 'die zweite Seite blieb unberührt')
assert.ok(aw.docs('termineConfig').get(F1))
})
test('loescheAbgeschalteteFunktionen: scheitert jedes Löschen, endet der Lauf nach einer Seite – Einstellung bleibt, Anfragen begrenzt, kein Wurf; der nächste Lauf holt alles nach', async () => {
await abgeschaltetMitBuchungen(230, { zellen: false })
let erg
const zaehler = await mitScheiternderLoeschung(
() => true,
async () => {
erg = await loescheAbgeschalteteFunktionen(JETZT)
},
)
assert.deepEqual(erg, { funktionen: 0, buchungen: 0, gedeckelt: 0 })
assert.equal(zaehler.versuche, 100, 'jede Buchung der einen Seite genau einmal versucht')
assert.equal(zaehler.listen, 1, 'eine Seite, dann Schluss')
assert.equal(buchungen().length, 230)
assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt')
assert.equal(aw.docs('portalFeatures').get(F1).state, 'disabled')
assert.equal(fehlerLog().length, 100, 'ein Fehler je Buchung, im Log')
pruefeSeitenabfragen()
log.error.mock.resetCalls()
assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 1, buchungen: 230, gedeckelt: 0 })
assert.equal(buchungen().length, 0)
assert.equal(aw.docs('termineConfig').get(F1), undefined)
assert.equal(fehlerLog().length, 0)
})
test('loescheAbgeschalteteFunktionen: einzelne Buchungen, die sich nicht löschen lassen, halten den Rest nicht auf; die Einstellung bleibt und es gibt keine Endlosschleife', async () => {
await abgeschaltetMitBuchungen(30, { zellen: false })
const kaputt = new Set(['b_ab000', 'b_ab001', 'b_ab002'])
let erg
const zaehler = await mitScheiternderLoeschung(
(id) => kaputt.has(id),
async () => {
erg = await loescheAbgeschalteteFunktionen(JETZT)
},
)
assert.deepEqual(erg, { funktionen: 1, buchungen: 27, gedeckelt: 0 })
assert.deepEqual(buchungen().map((b) => b.$id).sort(), [...kaputt])
assert.equal(zaehler.versuche, 3, 'jede kaputte Buchung nur einmal versucht')
assert.ok(zaehler.listen <= 3, `Listenabfragen: ${zaehler.listen}`)
assert.equal(fehlerLog().length, 3)
assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt, solange Buchungen übrig sind')
})
test('loescheAbgeschalteteFunktionen: scheitert das Lesen einer Seite, bleibt alles stehen (Fehler im Log, kein Wurf, Einstellung bleibt)', async () => {
await abgeschaltetMitBuchungen(120, { zellen: false })
// die erste Seite geht, das Neulesen danach scheitert (nur Listenabfragen zählen, nicht das Lesen einzelner Buchungen)
const echt = globalThis.fetch
let listen = 0
globalThis.fetch = async (url, optionen) => {
if (listeVonBuchungen(/featureId/)(String(url), optionen?.method ?? 'GET') && (listen += 1) === 2) {
return new Response(JSON.stringify({ message: 'Nachbau kaputt', code: 500 }), { status: 500, headers: { 'content-type': 'application/json' } })
}
return echt(url, optionen)
}
let erg
try {
erg = await loescheAbgeschalteteFunktionen(JETZT)
} finally {
globalThis.fetch = echt
}
assert.deepEqual(erg, { funktionen: 1, buchungen: 100, gedeckelt: 0 })
assert.equal(buchungen().length, 20)
assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung nur nach leerer Seite')
assert.equal(fehlerLog().length, 1)
assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 1, buchungen: 20, gedeckelt: 0 })
assert.equal(aw.docs('termineConfig').get(F1), undefined)
})
test('loescheAbgeschalteteFunktionen: wird die Funktion unmittelbar vor dem Löschen der Einstellung wieder eingeschaltet, bleibt die Einstellung (letzte Prüfung unter der Sperre)', async () => {
await abgeschaltetMitBuchungen(2, { zellen: false })
let listen = 0
// die zweite Listenabfrage ist die leere Seite: danach, vor dem Löschen der Einstellung, schaltet das Personal ein
const leereSeite = (url, methode) => listeVonBuchungen(/featureId/)(url, methode) && (listen += 1) === 2
const erg = await mitEinwurf(leereSeite, async () => schalteEin(), () => loescheAbgeschalteteFunktionen(JETZT))
assert.deepEqual(erg, { funktionen: 1, buchungen: 2, gedeckelt: 0 })
assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt')
assert.ok(warnLog().some((zeile) => /inzwischen wieder aktiv/.test(zeile)))
assert.equal(fehlerLog().length, 0)
})
// ---------------------------------------------------------------- Tagesjob
test('taeglicherJob: Löschfristen, Warteschlange, festgefahrene Mails, verwaiste Zellen und abgeschaltete Funktionen in einem Lauf', async () => {
@@ -1485,6 +1665,40 @@ test('taeglicherJob: Löschfristen, Warteschlange, festgefahrene Mails, verwaist
assert.doesNotMatch(zeile, /\bb_|\bf_|@|Mustermann|Salon/)
})
test('taeglicherJob: abgeschaltete Funktion am Deckel ⇒ gedeckelt.abgeschaltet zählt, der Löschzähler bleibt rein, der Tagesbericht nennt den Deckel', async () => {
config.termine.loeschDeckel = 2
await abgeschaltetMitBuchungen(5, { zellen: false })
let erg = await taeglicherJob(JETZT)
assert.deepEqual(erg.abgeschaltet, { funktionen: 1, buchungen: 2 }, 'nur Löschzähler, gedeckelt steht daneben')
assert.deepEqual(erg.gedeckelt, { loeschfristen: 0, abgeschaltet: 1, zellen: 0 })
assert.equal(erg.fehler, 0)
let zeile = tagesbericht(erg)
assert.match(zeile, /^\[termine\] Tagesjob: 2 Buchungen gelöscht \(.*abgeschaltete Funktionen 2\), 1 Funktionen bereinigt, /)
assert.match(zeile, /, 0 Mails nicht zugestellt, Löschläufe am Deckel: 1, Rest folgt an den nächsten Tagen, 0 Fehler$/)
assert.doesNotMatch(zeile, /\bb_|\bf_|@|Mustermann|Salon/)
erg = await taeglicherJob(JETZT)
assert.deepEqual(erg.abgeschaltet, { funktionen: 1, buchungen: 2 })
assert.deepEqual(erg.gedeckelt, { loeschfristen: 0, abgeschaltet: 1, zellen: 0 })
erg = await taeglicherJob(JETZT)
assert.deepEqual(erg.abgeschaltet, { funktionen: 1, buchungen: 1 }, 'der letzte Tag: eine Buchung und die Einstellung')
assert.deepEqual(erg.gedeckelt, { loeschfristen: 0, abgeschaltet: 0, zellen: 0 })
assert.doesNotMatch(tagesbericht(erg), /Deckel/)
assert.equal(aw.docs('termineConfig').get(F1), undefined)
})
test('taeglicherJob: ist die Liste der abgeschalteten Funktionen nicht lesbar, zählt der Schritt nichts, gedeckelt.abgeschaltet bleibt eine Zahl und die übrigen Schritte laufen', async () => {
await abgeschaltetMitBuchungen(3, { zellen: false })
aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau kaputt', after: 1 }) // 1. GET = Funktionen der Löschfristen, 2. = abgeschaltete
const erg = await taeglicherJob(JETZT)
assert.deepEqual(erg.abgeschaltet, { funktionen: 0, buchungen: 0 })
assert.deepEqual(erg.gedeckelt, { loeschfristen: 0, abgeschaltet: 0, zellen: 0 })
assert.equal(erg.fehler, 1)
assert.equal(buchungen().length, 3)
assert.match(tagesbericht(erg), /, 0 Mails nicht zugestellt, 1 Fehler$/)
})
test('tagesbericht: keine Zeile, wenn der Job nicht gelaufen ist', () => {
assert.equal(tagesbericht({ aus: true }), null)
assert.equal(tagesbericht({ uebersprungen: true }), null)
+41
View File
@@ -1011,6 +1011,47 @@ test('loeschKandidaten: hoechstens limit (Standard 100), aelteste Bezugszeit zue
for (const limit of [0, -1, 1.5, NaN, '10']) await assert.rejects(B.loeschKandidaten('erledigt', neunzig, { limit }), TypeError, `limit ${limit}`)
})
test('ersteBuchungen: hoechstens limit (Standard 100) Buchungen einer Funktion, jeder Status, nur die Kennung (nie Gastfelder)', async () => {
const nr = (i) => String(i).padStart(3, '0')
for (let i = 0; i < 130; i += 1) seedBuchung(`b_a${nr(i)}`, { status: ['bestaetigt', 'erledigt', 'abgesagt', 'gehalten'][i % 4] })
for (let i = 0; i < 7; i += 1) seedBuchung(`b_fremd${i}`, { featureId: 'f_feature02' })
const alleA = Array.from({ length: 130 }, (_, i) => `b_a${nr(i)}`)
aw.resetListCalls()
const standard = await B.ersteBuchungen(F)
assert.equal(standard.length, 100, 'Standard 100')
assert.ok(standard.every((doc) => alleA.includes(doc.$id)), 'nur Buchungen dieser Funktion')
assert.equal(new Set(standard.map((doc) => doc.$id)).size, 100)
assert.equal((await B.ersteBuchungen(F, 10)).length, 10)
assert.equal((await B.ersteBuchungen(F, 1)).length, 1)
assert.deepEqual((await B.ersteBuchungen('f_feature02')).map((doc) => doc.$id).sort(), Array.from({ length: 7 }, (_, i) => `b_fremd${i}`))
assert.deepEqual(await B.ersteBuchungen('f_gibt_es_nicht'), [])
// nur die Kennung: keine Gastfelder, und die Abfrage sagt es selbst (select, limit, Funktionsfilter)
for (const doc of standard) {
assert.ok(doc.$id)
assert.deepEqual(Object.keys(doc).filter((key) => !key.startsWith('$')), [], `${doc.$id}: keine Felder ausser den $-Feldern`)
}
const aufrufe = aw.listCalls('termineBookings')
const abfrage = aufrufe[0].queries
assert.deepEqual(abfrage.find((q) => q.method === 'select').values, ['$id'])
assert.deepEqual(abfrage.find((q) => q.method === 'limit').values, [100])
assert.deepEqual(abfrage.find((q) => q.method === 'equal'), { method: 'equal', attribute: 'featureId', values: [F] })
for (const { queries } of aufrufe) assert.ok(queries.find((q) => q.method === 'limit').values[0] <= 100)
// wer loescht, liest neu: Geloeschtes fehlt in der naechsten Seite
for (const doc of standard) await B.loesche(doc.$id)
const rest = await B.ersteBuchungen(F)
assert.deepEqual(rest.map((doc) => doc.$id).sort(), alleA.filter((id) => !standard.some((doc) => doc.$id === id)))
assert.equal(rest.length, 30)
// ungueltige Eingaben: nie eine Abfrage ohne Funktionsfilter oder ohne Grenze
aw.resetListCalls()
for (const featureId of [undefined, null, '', 5]) await assert.rejects(B.ersteBuchungen(featureId), TypeError, `featureId ${featureId}`)
for (const limit of [0, -1, 1.5, NaN, '10', null]) await assert.rejects(B.ersteBuchungen(F, limit), TypeError, `limit ${limit}`)
assert.equal(aw.listCalls('termineBookings').length, 0, 'bei ungueltigen Eingaben geht keine Abfrage raus')
})
test('zaehleAnfragen: offene Anfragen einer Funktion (auch ueber 25)', async () => {
for (let i = 0; i < 27; i += 1) seedBuchung(`b_anf${String(i).padStart(2, '0')}`, { status: 'angefragt' })
seedBuchung('b_best', { status: 'bestaetigt' })