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' })