From a1732613c9fe9693659db71897e5fc8f59ac483d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 09:24:33 +0000 Subject: [PATCH] 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: 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 --- server/services/termine/buchungen.js | 12 ++ server/services/termine/jobs.js | 137 ++++++++++------ test/termineJobs.test.js | 234 +++++++++++++++++++++++++-- test/termineZellen.test.js | 41 +++++ 4 files changed, 366 insertions(+), 58 deletions(-) diff --git a/server/services/termine/buchungen.js b/server/services/termine/buchungen.js index 46f54f3..81dc875 100644 --- a/server/services/termine/buchungen.js +++ b/server/services/termine/buchungen.js @@ -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 diff --git a/server/services/termine/jobs.js b/server/services/termine/jobs.js index 394ac47..6b9b26f 100644 --- a/server/services/termine/jobs.js +++ b/server/services/termine/jobs.js @@ -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:; 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 } }) } diff --git a/test/termineJobs.test.js b/test/termineJobs.test.js index 3f14d51..f00b34c 100644 --- a/test/termineJobs.test.js +++ b/test/termineJobs.test.js @@ -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 `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) diff --git a/test/termineZellen.test.js b/test/termineZellen.test.js index 412cdff..62dc43f 100644 --- a/test/termineZellen.test.js +++ b/test/termineZellen.test.js @@ -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' })