diff --git a/server/config.js b/server/config.js index 439a308..de4af8c 100644 --- a/server/config.js +++ b/server/config.js @@ -386,6 +386,8 @@ export const config = { holdMinutes: 30, zelleMin: 5, mailsProStunde: 200, + // Hoechstens so viele Buchungen loescht ein Schritt der Aufbewahrungs-Loeschung je Lauf (Tagesjob); der Rest folgt am naechsten Tag + loeschDeckel: 2000, // Mindestzeit zwischen Laden und Absenden des Buchungsformulars (schneller = Bot) formMinSekunden: nonNegativeNumber(process.env.TERMINE_FORM_MIN_SECONDS, 3), // Kuerzeste Antwortzeit (ms) von POST /buchungen und den Widerrufs-POSTs der oeffentlichen Schnittstelle: verwischt, ob diff --git a/server/services/termine/buchungen.js b/server/services/termine/buchungen.js index acaa7ec..46f54f3 100644 --- a/server/services/termine/buchungen.js +++ b/server/services/termine/buchungen.js @@ -31,6 +31,10 @@ const LOESCH_BEZUG = { nichterschienen: 'endsAt', } +/** Eine Seite von loeschKandidaten und die Felder, die sie liest (nie Gastfelder). */ +const LOESCH_SEITE = 100 +const LOESCH_FELDER = ['$id', 'featureId', 'status', 'endsAt', 'updatedAt', 'heldUntil'] + const iso = (wert) => new Date(wert).toISOString() const kleinGetrimmt = (email) => (typeof email === 'string' ? email.trim().toLowerCase() : '') @@ -181,16 +185,31 @@ export async function vergangeneAnfragen(now) { } /** - * Buchungen eines Status (alle Funktionen), deren Bezugszeit vor `vor` liegt. Bezugszeit je Status: verfallen = - * heldUntil, erledigt und nichterschienen = endsAt (Terminende), abgesagt und abgelehnt = updatedAt (letzte - * Aenderung, also der Zeitpunkt der Absage; endgueltige Status werden danach nicht mehr geaendert). Die Frist je - * Funktion (aufbewahrungTage) wendet der Aufrufer auf das Ergebnis an. Andere Status: TypeError. + * Buchungen eines Status, deren Bezugszeit vor `vor` liegt (die Frist steht in der Abfrage, nie nachtraeglich im Code): + * hoechstens `limit` (Standard 100, eine Seite), die mit der aeltesten Bezugszeit zuerst. Bezugszeit je Status: verfallen = + * heldUntil, erledigt und nichterschienen = endsAt (Terminende), abgesagt und abgelehnt = updatedAt (letzte Aenderung, also + * der Zeitpunkt der Absage; endgueltige Status werden danach nicht mehr geaendert). Mit featureId nur diese Funktion, sonst alle. + * Ohne Cursor: wer loescht, liest die Liste danach neu (Geloeschtes fehlt). Die Treffer tragen nur $id, featureId, status, + * endsAt, updatedAt und heldUntil, nie Gastfelder (Name, Adresse). Die Frist je Funktion (aufbewahrungTage) wendet der + * Aufrufer auf das Ergebnis an. Andere Status: TypeError. */ -export async function loeschKandidaten(status, vor) { +export async function loeschKandidaten(status, vor, { featureId = null, limit = LOESCH_SEITE } = {}) { if (!Object.hasOwn(LOESCH_BEZUG, status)) throw new TypeError(`loeschKandidaten: Status ${status} wird nicht automatisch geloescht`) - return listAllDocuments(col(), [Query.equal('status', status), Query.lessThan(LOESCH_BEZUG[status], iso(vor))]) + if (!Number.isInteger(limit) || limit < 1) throw new TypeError(`loeschKandidaten: limit muss eine ganze Zahl ab 1 sein, nicht ${limit}`) + const bezug = LOESCH_BEZUG[status] + return listDocuments(col(), [ + Query.equal('status', status), + Query.lessThan(bezug, iso(vor)), + ...(featureId === null ? [] : [Query.equal('featureId', featureId)]), + Query.orderAsc(bezug), + Query.limit(limit), + Query.select(LOESCH_FELDER), + ]) } +/** 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) + /** 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 a9ceda3..fe37971 100644 --- a/server/services/termine/jobs.js +++ b/server/services/termine/jobs.js @@ -8,6 +8,7 @@ import { erinnerungsKandidaten, hole, loesche, + loeschBezugsfeld, loeschKandidaten, vergangeneAnfragen, zuErledigen, @@ -40,6 +41,9 @@ import { gibZellenFrei, raeumeVerwaisteZellen } from './zellen.js' * Bedingung (Verfall nur, solange noch `gehalten` mit heldUntil <= jetzt usw.): ein Gast, der gerade bestaetigt, und der Job * ergeben genau einen Endzustand. Mails gehen erst nach der Aenderung und ausserhalb der Sperre in die Warteschlange. * - Zellen gibt nur zellen.js frei (gibZellenFrei / raeumeVerwaisteZellen, jeweils unter der Ressourcensperre). + * - Loeschlaeufe (Aufbewahrung) arbeiten seitenweise (arbeiteSeiten): eine Seite hoechstens 100 Buchungen ohne Gastfelder, + * Frist in der Abfrage, danach neu lesen. Ein Schritt loescht hoechstens config.termine.loeschDeckel Buchungen je Lauf; der + * Rest folgt am naechsten Tag und wird gezaehlt (`gedeckelt`), nie still uebersprungen. */ const MIN_MS = 60 * 1000 @@ -100,7 +104,7 @@ async function fuehreAus(job, now, arbeit) { } laufend.add(job) try { - return await arbeit({ job, nowMs, fehler: 0 }) + return await arbeit({ job, nowMs, fehler: 0, gedeckelt: 0 }) } finally { laufend.delete(job) ueberlappGemeldet.delete(job) @@ -108,7 +112,7 @@ async function fuehreAus(job, now, arbeit) { } /** Laufdaten fuer einen Baustein, der ohne Job-Rahmen aufgerufen wird (Tests). */ -const einzelLauf = (job, now) => ({ job, nowMs: pruefeNow(now, job), fehler: 0 }) +const einzelLauf = (job, now) => ({ job, nowMs: pruefeNow(now, job), fehler: 0, gedeckelt: 0 }) /** * Eine Buchung unter ihrer Sperre, frisch gelesen: fn(frisch) laeuft nur, wenn es sie noch gibt. @@ -126,6 +130,62 @@ async function mitFrischer(lauf, was, id, fn) { } } +/** + * Seitenweise Arbeit ohne Cursor: Seite lesen (holeSeite), jeden Eintrag bearbeiten (bearbeite), Liste neu lesen – was bearbeitet + * ist, fehlt dann. Gedacht fuer Loeschlaeufe (Aufbewahrung, abgeschaltete Funktionen, Zellen), die wegen der Zahl der Betriebe nie + * alles auf einmal lesen duerfen. Der Lauf endet + * - bei einer leeren Seite (alles erledigt), + * - am Deckel: sind `deckel` Eintraege geloescht und es kommt noch einer, zaehlt lauf.gedeckelt, eine Warnung ohne Kennungen + * steht im Log und der Rest folgt im naechsten Lauf, + * - bei einer Seite ohne einen bearbeiteten Eintrag (alle gescheitert oder schon versucht): die Fehler stehen im Log; eine Menge + * merkt die versuchten IDs, damit ein Eintrag, der sich nicht bearbeiten laesst, keinen Endlosdurchlauf ausloest. + * holeSeite() => Eintraege mit $id (hoechstens eine Seite); bearbeite(eintrag) => true (geloescht, zaehlt zum Deckel) | false (nichts + * zu tun, z. B. inzwischen anders oder weg: kein Fehler) | undefined (Fehler, schon gemeldet). Wirft holeSeite, steht der Fehler im + * Log und der Lauf endet unvollstaendig. + * => { geloescht, gedeckelt (am Deckel beendet), vollstaendig (leere Seite erreicht und kein Fehler in diesem Lauf) } + */ +async function arbeiteSeiten(lauf, { holeSeite, bearbeite, deckel, was = 'Löschung' }) { + if (!Number.isInteger(deckel) || deckel < 1) throw new TypeError(`arbeiteSeiten: deckel muss eine ganze Zahl ab 1 sein, nicht ${deckel}`) + const versucht = new Set() + let geloescht = 0 + let fehlerBeiEintrag = false + for (;;) { + let seite + try { + seite = await holeSeite() + } catch (err) { + melde(lauf, `${was}: Liste nicht gelesen`, err) + return { geloescht, gedeckelt: false, vollstaendig: false } + } + if (seite.length === 0) return { geloescht, gedeckelt: false, vollstaendig: !fehlerBeiEintrag } + + let bearbeitet = 0 + for (const eintrag of seite) { + if (versucht.has(eintrag.$id)) continue + if (geloescht >= deckel) { + lauf.gedeckelt += 1 + console.warn(`[termine-jobs] ${lauf.job}: ${was} endet am Deckel von ${deckel} je Lauf, der Rest folgt an den nächsten Tagen`) + return { geloescht, gedeckelt: true, vollstaendig: false } + } + versucht.add(eintrag.$id) + let ergebnis + try { + ergebnis = await bearbeite(eintrag) + } catch (err) { + melde(lauf, was, err) + ergebnis = undefined + } + if (ergebnis === undefined) { + fehlerBeiEintrag = true + continue + } + bearbeitet += 1 + if (ergebnis) geloescht += 1 + } + if (bearbeitet === 0) return { geloescht, gedeckelt: false, vollstaendig: false } + } +} + // ---------------------------------------------------------------- Minutenjob /** Abgelaufene Haltungen: verfallen und Zellen frei – nur, wenn die Buchung unter der Sperre noch `gehalten` und abgelaufen ist. */ @@ -275,43 +335,71 @@ async function aufbewahrungTage(lauf, featureId) { } } -/** Buchung samt Zellen loeschen, wenn sie unter der Sperre noch im erwarteten Status ist (status: null = jeder). => true | false | undefined (Fehler) */ -function loescheMitZellen(lauf, id, status) { +/** + * Buchung samt Zellen loeschen, wenn sie unter der Sperre noch im erwarteten Status ist (status: null = jeder). Mit `frist` + * ({ feld, vor } in ms) muss ausserdem die frisch gelesene Bezugszeit noch vor `vor` liegen (zweiter Boden gegen eine Liste, die + * seit dem Lesen veraltet ist). => true | false | undefined (Fehler) + */ +function loescheMitZellen(lauf, id, status, frist = null) { return mitFrischer(lauf, 'Buchung nicht gelöscht', id, async (frisch) => { if (status !== null && frisch.status !== status) return false + if (frist !== null && !(zeitMs(frisch[frist.feld]) < frist.vor)) return false await gibZellenFrei(id) await loesche(id) return true }) } +/** + * Aufbewahrungs-Loeschung (Spezifikation 11) fuer Buchungen. Durchgang (1): verfallen (48 Stunden nach heldUntil), abgesagt und + * abgelehnt (30 Tage nach der letzten Aenderung) ueber alle Funktionen, je Status seitenweise (arbeiteSeiten) bis zum Deckel + * config.termine.loeschDeckel. Die Frist steht in der Abfrage; unter der Buchungssperre wird Status und Bezugszeit noch einmal + * frisch geprueft. erledigt/nichterschienen laufen bis zum Umbau je Funktion noch ueber den bisherigen Weg (eine Liste, die Frist + * der Funktion am Kandidaten). + * => Loeschzaehler je Status und `gedeckelt` = Zahl der Schritte, die am Deckel endeten + */ async function loeschfristen(lauf, now) { const geloescht = { verfallen: 0, abgesagt: 0, abgelehnt: 0, erledigt: 0, nichterschienen: 0 } + let gedeckelt = 0 const tage = new Map() const tageFuer = (featureId) => { if (!tage.has(featureId)) tage.set(featureId, aufbewahrungTage(lauf, featureId)) return tage.get(featureId) } - // Je Status: Kandidaten ab der fruehesten moeglichen Frist; bei erledigt/nichterschienen entscheidet die Frist der Funktion - const fristen = [ - ['verfallen', VERFALLEN_BEHALTEN_MS, false], - ['abgesagt', ABGESAGT_BEHALTEN_MS, false], - ['abgelehnt', ABGESAGT_BEHALTEN_MS, false], - ['erledigt', AUFBEWAHRUNG_MIN * TAG_MS, true], - ['nichterschienen', AUFBEWAHRUNG_MIN * TAG_MS, true], - ] - for (const [status, behalten, jeFunktion] of fristen) { + + for (const [status, behalten] of [ + ['verfallen', VERFALLEN_BEHALTEN_MS], + ['abgesagt', ABGESAGT_BEHALTEN_MS], + ['abgelehnt', ABGESAGT_BEHALTEN_MS], + ]) { await schritt(lauf, `Löschfrist ${status}`, async () => { - for (const kandidat of await loeschKandidaten(status, new Date(lauf.nowMs - behalten))) { - if (jeFunktion) { - const t = await tageFuer(kandidat.featureId) - if (t === null || !(zeitMs(kandidat.endsAt) < lauf.nowMs - t * TAG_MS)) continue - } + const vor = lauf.nowMs - behalten + const frist = { feld: loeschBezugsfeld(status), vor } + const erg = await arbeiteSeiten(lauf, { + was: `Löschfrist ${status}`, + deckel: config.termine.loeschDeckel, + holeSeite: () => loeschKandidaten(status, new Date(vor)), + bearbeite: async (kandidat) => { + const weg = await loescheMitZellen(lauf, kandidat.$id, status, frist) + if (weg) geloescht[status] += 1 + return weg + }, + }) + if (erg.gedeckelt) gedeckelt += 1 + }) + } + + // Bisheriger Weg fuer erledigt/nichterschienen: Kandidaten ab der fruehesten moeglichen Frist, dann entscheidet die Frist der Funktion + for (const status of ['erledigt', 'nichterschienen']) { + await schritt(lauf, `Löschfrist ${status}`, async () => { + for (const kandidat of await loeschKandidaten(status, new Date(lauf.nowMs - AUFBEWAHRUNG_MIN * TAG_MS))) { + const t = await tageFuer(kandidat.featureId) + if (t === null || !(zeitMs(kandidat.endsAt) < lauf.nowMs - t * TAG_MS)) continue if (await loescheMitZellen(lauf, kandidat.$id, status)) geloescht[status] += 1 } }) } - return geloescht + return { ...geloescht, gedeckelt } } /** So viele Buchungen einer abgeschalteten Funktion hoechstens, bevor ihr Zustand erneut gelesen wird. */ @@ -392,10 +480,12 @@ async function loescheAbgeschaltete(lauf) { /** * Loeschfristen (Spezifikation 11) fuer Buchungen: verfallen 48 Stunden nach heldUntil, abgesagt/abgelehnt 30 Tage nach der letzten - * Aenderung, erledigt/nichterschienen aufbewahrungTage der Funktion (Vorgabe 90) nach Terminende; jeweils samt Zellen. Baustein des - * Tagesjobs, ohne Schalter- und Ueberlappungspruefung (fuer Tests). + * Aenderung, erledigt/nichterschienen aufbewahrungTage der Funktion (Vorgabe 90) nach Terminende; jeweils samt Zellen, je Schritt + * hoechstens config.termine.loeschDeckel (der Rest folgt im naechsten Lauf). Baustein des Tagesjobs, ohne Schalter- und + * Ueberlappungspruefung (fuer Tests). * @param {Date} now - * @returns {Promise<{ verfallen: number, abgesagt: number, abgelehnt: number, erledigt: number, nichterschienen: number }>} + * @returns {Promise<{ verfallen: number, abgesagt: number, abgelehnt: number, erledigt: number, nichterschienen: number, gedeckelt: number }>} + * gedeckelt = Zahl der Schritte, die am Deckel endeten */ export function wendeLoeschfristenAn(now) { return loeschfristen(einzelLauf('loeschfristen', now), now) @@ -420,11 +510,13 @@ export function loescheAbgeschalteteFunktionen(now) { export async function taeglicherJob(now) { return fuehreAus('taeglicherJob', now, async (lauf) => { const festgefahren = await schritt(lauf, 'Festgefahrene Mails', () => beendeFestgefahrene(now), 0) - const loeschung = await loeschfristen(lauf, now) + const { gedeckelt: loeschfristenGedeckelt, ...loeschung } = await loeschfristen(lauf, now) const abgeschaltet = await schritt(lauf, 'Abgeschaltete Funktionen', () => loescheAbgeschaltete(lauf), { funktionen: 0, buchungen: 0 }) const aufgeraeumt = await schritt(lauf, 'Warteschlange aufräumen', () => raeumeAuf(now), 0) const zellen = await schritt(lauf, 'Verwaiste Zellen', () => raeumeVerwaisteZellen(now), 0) - return { festgefahren, loeschfristen: loeschung, abgeschaltet, aufgeraeumt, zellen, fehler: lauf.fehler } + // 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 } + return { festgefahren, loeschfristen: loeschung, gedeckelt, abgeschaltet, aufgeraeumt, zellen, fehler: lauf.fehler } }) } diff --git a/server/services/termine/schema.js b/server/services/termine/schema.js index 40f81a0..b2c3bdf 100644 --- a/server/services/termine/schema.js +++ b/server/services/termine/schema.js @@ -81,6 +81,9 @@ export const TERMINE_COLLECTIONS = { index('idx_verify', 'verifyHash'), index('idx_manage', 'manageHash'), index('idx_status_start', 'status', 'startsAt'), + // Aufbewahrungs-Loeschung (Tagesjob): Status + Bezugszeit, verfallen laeuft ueber idx_status_held + index('idx_status_ends', 'status', 'endsAt'), + index('idx_status_updated', 'status', 'updatedAt'), ], }, diff --git a/test/termineJobs.test.js b/test/termineJobs.test.js index a562ada..3ace60b 100644 --- a/test/termineJobs.test.js +++ b/test/termineJobs.test.js @@ -49,6 +49,7 @@ const R1 = 'r_aaaaaaa1' const S1 = 's_bbbbbbb1' const ABSENDER_VORHER = config.smtp.from const MODUS_VORHER = config.termine.mode +const DECKEL_VORHER = config.termine.loeschDeckel const BASIS_VORHER = config.publicBaseUrl const iso = (wert) => new Date(wert).toISOString() @@ -114,6 +115,7 @@ beforeEach(async () => { resetKontextForTests() resetOutboxForTests() resetJobsForTests() + aw.resetListCalls() config.termine.mode = 'pilot' config.smtp.from = 'noreply@webklar.com' config.publicBaseUrl = 'https://project.webklar.com' @@ -129,6 +131,7 @@ afterEach(async () => { const text = [...log.error.mock.calls, ...log.warn.mock.calls].map((aufruf) => aufruf.arguments.map(String).join(' ')).join('\n') mock.restoreAll() config.termine.mode = MODUS_VORHER + config.termine.loeschDeckel = DECKEL_VORHER config.smtp.from = ABSENDER_VORHER config.publicBaseUrl = BASIS_VORHER assert.doesNotMatch(text, /Mustermann|max@example\.com/i, 'kein Gastname und keine Gastadresse im Log') @@ -678,7 +681,7 @@ test('wendeLoeschfristenAn: verfallen 48 Stunden nach heldUntil, abgesagt/abgele await legeBuchung('b_l_jung', { status: 'abgelehnt', updatedAt: iso(plus(JETZT, -29 * TAG)) }) const erg = await wendeLoeschfristenAn(JETZT) - assert.deepEqual(erg, { verfallen: 1, abgesagt: 1, abgelehnt: 1, erledigt: 0, nichterschienen: 0 }) + assert.deepEqual(erg, { verfallen: 1, abgesagt: 1, abgelehnt: 1, erledigt: 0, nichterschienen: 0, gedeckelt: 0 }) for (const id of ['b_v_alt', 'b_s_alt', 'b_l_alt']) { assert.equal(buchung(id), undefined, `${id} gelöscht`) assert.equal(claims(id).length, 0, `${id}: Zellen mit der Buchung gelöscht`) @@ -741,15 +744,263 @@ test('wendeLoeschfristenAn: Fehler beim Lesen der Einstellung ⇒ diese Funktion test('wendeLoeschfristenAn: wird die Buchung zwischen Auflisten und Sperre geändert, bleibt sie', async () => { const doc = await legeBuchung('b_race', { status: 'abgesagt', updatedAt: iso(plus(JETZT, -40 * TAG)) }) + let eingeworfen = false + // gezielt auf die Liste des Schritts abgesagt (die Abfrage nennt "abgesagt"); das select der Listen enthaelt jetzt auch updatedAt await mitEinwurf( - listeVonBuchungen(/updatedAt/), - async () => aw.seed('termineBookings', { ...doc, status: 'bestaetigt' }), + listeVonBuchungen(/"abgesagt"/), + async () => { + eingeworfen = true + aw.seed('termineBookings', { ...doc, status: 'bestaetigt' }) + }, () => wendeLoeschfristenAn(JETZT), ) + assert.ok(eingeworfen, 'die Änderung kam nach der Liste und vor der Sperre') assert.equal(status('b_race'), 'bestaetigt') assert.ok(claims('b_race').length > 0) }) +// Hilfen der seitenweisen Löschläufe: Bezugszeit und Frist je Status +const BEZUGSFELD = { verfallen: 'heldUntil', abgesagt: 'updatedAt', abgelehnt: 'updatedAt' } +const BEHALTEN = { verfallen: 48 * STUNDE, abgesagt: 30 * TAG, abgelehnt: 30 * TAG } +const ARTEN = ['verfallen', 'abgesagt', 'abgelehnt'] +const KUERZEL = { verfallen: 'ver', abgesagt: 'abs', abgelehnt: 'abl' } +const nr = (i) => String(i).padStart(3, '0') +/** Buchung eines Status, deren Bezugszeit `ueber` Millisekunden hinter der Löschgrenze liegt: > 0 fällig (1 = knapp), < 0 zu jung (-1 = eine Millisekunde). */ +const legeLoeschFall = (id, art, ueber, over = {}) => legeBuchung(id, { status: art, [BEZUGSFELD[art]]: iso(plus(JETZT, -BEHALTEN[art] - ueber)), ...over }) + +/** Die Listenabfragen der Aufbewahrungs-Löschung (Abfrage nach einem ihrer fünf Status) an termineBookings. */ +const loeschListen = () => + aw.listCalls('termineBookings').filter(({ queries }) => queries.some((q) => q.method === 'equal' && q.attribute === 'status' && ['verfallen', 'abgesagt', 'abgelehnt', 'erledigt', 'nichterschienen'].includes(q.values[0]))) + +/** Alle Listenabfragen der Löschläufe (`aufrufe`, Standard: alle an termineBookings): limit höchstens 100, ein select ohne Gastfelder, kein $id-Seitenlauf. => Zahl der Abfragen */ +function pruefeSeitenabfragen(aufrufe = aw.listCalls('termineBookings')) { + for (const { queries } of aufrufe) { + const limit = queries.find((q) => q.method === 'limit')?.values[0] + assert.ok(Number.isInteger(limit) && limit <= 100, `limit ${limit}`) + const select = queries.find((q) => q.method === 'select')?.values + assert.ok(select, 'select vorhanden') + assert.deepEqual(select.filter((feld) => /^gast/.test(feld)), [], 'keine Gastfelder') + assert.equal(queries.some((q) => q.method === 'orderAsc' && q.attribute === '$id'), false, 'kein $id-Seitenlauf') + } + return aufrufe.length +} +/** Zahl der Listenabfragen an termineBookings für einen Status. */ +const listenFuer = (art) => aw.listCalls('termineBookings').filter(({ queries }) => queries.some((q) => q.method === 'equal' && q.attribute === 'status' && q.values[0] === art)).length +/** + * Alle DELETE auf termineBookings, für die passt(id) gilt, scheitern wie bei einem kaputten Appwrite. => Zähler der Versuche. + * Ein Lauf, der mehr als 50 Listen liest, bekäme ab da Fehler (eine Endlosschleife soll den Test scheitern lassen, nicht aufhängen). + */ +async function mitScheiternderLoeschung(passt, fn) { + const echt = globalThis.fetch + const zaehler = { versuche: 0, listen: 0 } + const kaputt = () => new Response(JSON.stringify({ message: 'Nachbau kaputt', code: 500 }), { status: 500, headers: { 'content-type': 'application/json' } }) + globalThis.fetch = async (url, optionen) => { + const methode = optionen?.method ?? 'GET' + const treffer = /\/collections\/termineBookings\/documents\/([^/?]+)$/.exec(String(url)) + if (methode === 'DELETE' && treffer && passt(treffer[1])) { + zaehler.versuche += 1 + return kaputt() + } + if (methode === 'GET' && /\/collections\/termineBookings\/documents\?/.test(String(url)) && (zaehler.listen += 1) > 50) return kaputt() + return echt(url, optionen) + } + try { + await fn() + } finally { + globalThis.fetch = echt + } + return zaehler +} + +test('wendeLoeschfristenAn: 250 verfallen/abgesagt/abgelehnt ⇒ genau die 210 fälligen samt Zellen, seitenweise (limit ≤ 100, ohne Gastfelder)', async () => { + assert.ok(config.termine.loeschDeckel > 210, 'der Standard-Deckel liegt über den fälligen Buchungen, der Test setzt ihn nicht') + const faellig = { verfallen: [], abgesagt: [], abgelehnt: [] } + const jung = [] + for (let i = 0; i < 250; i += 1) { + const art = ARTEN[i % 3] + const id = `b_${KUERZEL[art]}${nr(i)}` + const zuJung = i % 25 < 4 // 40 von 250: eine Millisekunde zu jung + await legeLoeschFall(id, art, zuJung ? -1 : 1 + i * 1000) + ;(zuJung ? jung : faellig[art]).push(id) + } + assert.equal(jung.length, 40) + // andere Status und eine Buchung der Gegenwart bleiben unberührt + await legeLoeschFall('b_fest', 'abgesagt', 5 * TAG, { status: 'bestaetigt' }) + await legeBuchung('b_zukunft', { start: plus(JETZT, 5 * TAG) }) + const zellenProBuchung = claims('b_zukunft').length + assert.ok(zellenProBuchung > 0) + + const erg = await wendeLoeschfristenAn(JETZT) + const alleFaelligen = [...faellig.verfallen, ...faellig.abgesagt, ...faellig.abgelehnt] + assert.equal(alleFaelligen.length, 210) + assert.deepEqual(erg, { + verfallen: faellig.verfallen.length, + abgesagt: faellig.abgesagt.length, + abgelehnt: faellig.abgelehnt.length, + erledigt: 0, + nichterschienen: 0, + gedeckelt: 0, + }) + for (const id of alleFaelligen) { + assert.equal(buchung(id), undefined, `${id} gelöscht`) + assert.equal(claims(id).length, 0, `${id}: Zellen gelöscht`) + } + for (const id of jung) { + assert.ok(buchung(id), `${id}: eine Millisekunde zu jung, bleibt`) + assert.equal(claims(id).length, zellenProBuchung, `${id}: Zellen bleiben`) + } + assert.deepEqual(buchungen().map((b) => b.$id).sort(), [...jung, 'b_fest', 'b_zukunft'].sort()) + assert.ok(pruefeSeitenabfragen() >= 3, 'jede Listenabfrage ist kurz und ohne Gastfelder') + for (const art of ARTEN) assert.equal(listenFuer(art), 2, `${art}: eine Seite mit allem Fälligen, danach leer`) + assert.equal(fehlerLog().length, 0) +}) + +test('wendeLoeschfristenAn: mehr als eine Seite fälliger Buchungen eines Status ⇒ Seite für Seite (100, 100, 30), danach neu gelesen bis leer', async () => { + for (let i = 0; i < 230; i += 1) await legeLoeschFall(`b_ver${nr(i)}`, 'verfallen', 1 + i * 1000) + for (let i = 0; i < 5; i += 1) await legeLoeschFall(`b_vju${nr(i)}`, 'verfallen', -1) + + const erg = await wendeLoeschfristenAn(JETZT) + assert.equal(erg.verfallen, 230) + assert.equal(erg.gedeckelt, 0) + assert.deepEqual(buchungen().map((b) => b.$id).sort(), Array.from({ length: 5 }, (_, i) => `b_vju${nr(i)}`)) + assert.equal(aw.docs('termineClaims').size, 5 * claims('b_vju000').length) + assert.equal(listenFuer('verfallen'), 4, 'drei Seiten mit Fälligen, eine leere') + pruefeSeitenabfragen() +}) + +test('wendeLoeschfristenAn: Deckel loeschDeckel je Status – Rest an den Folgetagen, gedeckelt zählt die Schritte, Warnung ohne Kennungen', async () => { + config.termine.loeschDeckel = 7 + // i = 19 ist am längsten fällig: die ältesten gehen zuerst + for (let i = 0; i < 20; i += 1) await legeLoeschFall(`b_ver${nr(i)}`, 'verfallen', 1 + i * 1000) + await legeLoeschFall('b_ver_jung', 'verfallen', -1) + const zellenProBuchung = claims('b_ver000').length + const uebrig = () => buchungen().map((b) => b.$id).filter((id) => id !== 'b_ver_jung').sort() + const deckelWarnungen = () => warnLog().filter((zeile) => /Deckel/.test(zeile)) + const stimmt = () => { + assert.ok(buchung('b_ver_jung'), 'nie eine junge Buchung') + assert.equal(aw.docs('termineClaims').size, buchungen().length * zellenProBuchung, 'Zellen passen zum Rest') + } + + let erg = await wendeLoeschfristenAn(JETZT) + assert.deepEqual(erg, { verfallen: 7, abgesagt: 0, abgelehnt: 0, erledigt: 0, nichterschienen: 0, gedeckelt: 1 }) + assert.deepEqual(uebrig(), Array.from({ length: 13 }, (_, i) => `b_ver${nr(i)}`), 'die sieben ältesten sind weg') + assert.equal(deckelWarnungen().length, 1) + assert.doesNotMatch(deckelWarnungen()[0], /\bb_|f_p_|c_kunde|Müller|Mustermann/, 'keine ID, kein Name in der Warnung') + stimmt() + + log.warn.mock.resetCalls() + erg = await wendeLoeschfristenAn(JETZT) + assert.equal(erg.verfallen, 7) + assert.equal(erg.gedeckelt, 1) + assert.equal(uebrig().length, 6) + assert.equal(deckelWarnungen().length, 1) + stimmt() + + log.warn.mock.resetCalls() + erg = await wendeLoeschfristenAn(JETZT) + assert.equal(erg.verfallen, 6) + assert.equal(erg.gedeckelt, 0, 'die letzten sechs passen unter den Deckel') + assert.deepEqual(uebrig(), []) + assert.equal(deckelWarnungen().length, 0) + stimmt() + assert.equal(fehlerLog().length, 0) +}) + +test('wendeLoeschfristenAn: der Deckel gilt je Status; genau so viele fällige wie der Deckel ⇒ nicht gedeckelt', async () => { + config.termine.loeschDeckel = 7 + for (let i = 0; i < 10; i += 1) await legeLoeschFall(`b_ver${nr(i)}`, 'verfallen', 1 + i) + for (let i = 0; i < 10; i += 1) await legeLoeschFall(`b_abs${nr(i)}`, 'abgesagt', 1 + i) + for (let i = 0; i < 7; i += 1) await legeLoeschFall(`b_abl${nr(i)}`, 'abgelehnt', 1 + i) // genau der Deckel + + const erg = await wendeLoeschfristenAn(JETZT) + assert.deepEqual(erg, { verfallen: 7, abgesagt: 7, abgelehnt: 7, erledigt: 0, nichterschienen: 0, gedeckelt: 2 }) + assert.equal(buchungen().filter((b) => b.status === 'verfallen').length, 3) + assert.equal(buchungen().filter((b) => b.status === 'abgesagt').length, 3) + assert.equal(buchungen().filter((b) => b.status === 'abgelehnt').length, 0) + assert.equal(warnLog().filter((zeile) => /Deckel/.test(zeile)).length, 2) +}) + +test('wendeLoeschfristenAn: scheitert jedes Löschen, endet der Lauf nach einer Seite je Schritt, zählt die Fehler, lässt die anderen Schritte laufen; der nächste Lauf holt alles nach', async () => { + for (const art of ARTEN) for (let i = 0; i < 30; i += 1) await legeLoeschFall(`b_${KUERZEL[art]}${nr(i)}`, art, 1 + i * 1000) + await legeLoeschFall('b_ver_jung', 'verfallen', -1) + // andere Schritte des Tagesjobs: eine alte, versandte Mail und eine verwaiste Zelle + legeEintrag('o_gesendet', { status: 'gesendet', an: '', html: '', text: '', naechsterVersuch: iso(plus(JETZT, -MIN)) }) + await belegeZellen({ featureId: F1, ressourceId: 'r_geist', bookingId: 'b_geist', start: plus(JETZT, 2 * TAG), blockEnde: plus(JETZT, 2 * TAG + 30 * MIN), now: JETZT }) + + let erg + const zaehler = await mitScheiternderLoeschung( + () => true, + async () => { + erg = await taeglicherJob(JETZT) + }, + ) + assert.deepEqual(erg.loeschfristen, { verfallen: 0, abgesagt: 0, abgelehnt: 0, erledigt: 0, nichterschienen: 0 }) + assert.deepEqual(erg.gedeckelt, { loeschfristen: 0, abgeschaltet: 0, zellen: 0 }) + assert.equal(erg.fehler, 90, 'ein Fehler je Buchung, im Log') + assert.equal(fehlerLog().length, 90) + assert.equal(zaehler.versuche, 90, 'jede Buchung genau einmal versucht') + for (const art of ARTEN) assert.equal(listenFuer(art), 1, `${art}: eine Seite, dann Schluss`) + assert.equal(pruefeSeitenabfragen(loeschListen()), 5, 'drei Status plus die beiden unveränderten Schritte, je eine Seite') + assert.equal(erg.aufgeraeumt, 1, 'Warteschlange wurde trotzdem aufgeräumt') + assert.ok(erg.zellen >= 1, 'verwaiste Zellen wurden trotzdem geräumt') + assert.equal(claims('b_geist').length, 0) + assert.equal(buchungen().length, 91) + + // nächster Lauf, Löschen geht wieder: alles Fällige weg, die junge bleibt + log.error.mock.resetCalls() + aw.resetListCalls() + const zweiter = await taeglicherJob(JETZT) + assert.deepEqual(zweiter.loeschfristen, { verfallen: 30, abgesagt: 30, abgelehnt: 30, erledigt: 0, nichterschienen: 0 }) + assert.equal(zweiter.fehler, 0) + assert.deepEqual(buchungen().map((b) => b.$id), ['b_ver_jung']) + assert.equal(aw.docs('termineClaims').size, claims('b_ver_jung').length) +}) + +test('wendeLoeschfristenAn: einzelne Buchungen, die sich nicht löschen lassen, halten den Rest nicht auf und führen nicht in eine Endlosschleife', async () => { + // die drei ältesten stehen in jeder Liste vorn und scheitern immer + const kaputt = new Set(['b_ver027', 'b_ver028', 'b_ver029']) + for (let i = 0; i < 30; i += 1) await legeLoeschFall(`b_ver${nr(i)}`, 'verfallen', 1 + i * 1000) + + const zaehler = await mitScheiternderLoeschung((id) => kaputt.has(id), async () => { + const erg = await wendeLoeschfristenAn(JETZT) + assert.equal(erg.verfallen, 27) + }) + assert.deepEqual(buchungen().map((b) => b.$id).sort(), [...kaputt]) + assert.equal(zaehler.versuche, 3, 'jede kaputte Buchung nur einmal versucht') + assert.equal(fehlerLog().length, 3) + assert.ok(listenFuer('verfallen') <= 3, 'eine Seite mit allem, eine mit dem Rest (nichts Neues darauf ⇒ Schluss)') +}) + +test('wendeLoeschfristenAn: ändert sich die Bezugszeit zwischen Liste und Sperre (abgesagt wird erneut geändert), bleibt die Buchung', async () => { + const doc = await legeLoeschFall('b_spaet', 'abgesagt', 1000) + await legeLoeschFall('b_alt', 'abgesagt', 2000) + let eingeworfen = false + await mitEinwurf( + listeVonBuchungen(/"abgesagt"/), + async () => { + eingeworfen = true + aw.seed('termineBookings', { ...doc, updatedAt: iso(JETZT) }) // Status bleibt abgesagt, die letzte Änderung ist neu + }, + () => wendeLoeschfristenAn(JETZT), + ) + assert.ok(eingeworfen) + assert.ok(buchung('b_spaet'), 'die 30 Tage laufen ab der neuen Änderung') + assert.ok(claims('b_spaet').length > 0) + assert.equal(buchung('b_alt'), undefined) +}) + +test('taeglicherJob: gedeckelt zählt je Schritt und verfälscht den Löschzähler im Tagesbericht nicht', async () => { + config.termine.loeschDeckel = 2 + for (let i = 0; i < 5; i += 1) await legeLoeschFall(`b_ver${nr(i)}`, 'verfallen', 1 + i) + for (let i = 0; i < 5; i += 1) await legeLoeschFall(`b_abs${nr(i)}`, 'abgesagt', 1 + i) + await legeLoeschFall('b_abl000', 'abgelehnt', 1) + + const erg = await taeglicherJob(JETZT) + assert.deepEqual(erg.loeschfristen, { verfallen: 2, abgesagt: 2, abgelehnt: 1, erledigt: 0, nichterschienen: 0 }, 'nur Löschzähler') + assert.deepEqual(erg.gedeckelt, { loeschfristen: 2, abgeschaltet: 0, zellen: 0 }) + assert.match(tagesbericht(erg), /^\[termine\] Tagesjob: 5 Buchungen gelöscht \(verfallen 2, abgesagt 2, abgelehnt 1, erledigt 0, nichterschienen 0, abgeschaltete Funktionen 0\)/) +}) + test('loescheAbgeschalteteFunktionen: disabled länger als 30 Tage ⇒ Buchungen, Zellen und Einstellung weg; alles andere bleibt', async () => { const seit = (tage, zusatz = 0) => iso(plus(JETZT, -tage * TAG + zusatz)) await richteFunktionEin(F1, { state: 'disabled', disabledAt: seit(31) }) @@ -881,6 +1132,8 @@ test('taeglicherJob: Löschfristen, Warteschlange, festgefahrene Mails, verwaist assert.equal(erg.festgefahren, 1) assert.equal(erg.loeschfristen.verfallen, 1) assert.equal(erg.loeschfristen.erledigt, 1) + assert.equal('gedeckelt' in erg.loeschfristen, false, 'gedeckelt steht neben den Löschzählern, nicht darin') + assert.deepEqual(erg.gedeckelt, { loeschfristen: 0, abgeschaltet: 0, zellen: 0 }) assert.deepEqual(erg.abgeschaltet, { funktionen: 1, buchungen: 1 }) assert.equal(erg.aufgeraeumt, 1) assert.ok(erg.zellen >= 1) diff --git a/test/termineSchalter.test.js b/test/termineSchalter.test.js index 25d5ca0..bb53ad4 100644 --- a/test/termineSchalter.test.js +++ b/test/termineSchalter.test.js @@ -47,6 +47,7 @@ test('Konfiguration: Vorgaben ohne Umgebungsvariablen', async () => { holdMinutes: 30, zelleMin: 5, mailsProStunde: 200, + loeschDeckel: 2000, formMinSekunden: 3, minAntwortMs: 300, }) diff --git a/test/termineSetup.test.js b/test/termineSetup.test.js index c8b779a..51c8032 100644 --- a/test/termineSetup.test.js +++ b/test/termineSetup.test.js @@ -152,6 +152,8 @@ test('Schema: Indizes wie in der Spezifikation und nur auf vorhandenen Attribute ['idx_verify', 'verifyHash'], ['idx_manage', 'manageHash'], ['idx_status_start', 'status', 'startsAt'], + ['idx_status_ends', 'status', 'endsAt'], + ['idx_status_updated', 'status', 'updatedAt'], ]) assert.deepEqual(indexes('termineClaims'), [['idx_booking', 'bookingId'], ['idx_bis', 'bis']]) assert.deepEqual(indexes('termineOutbox'), [['idx_status_next', 'status', 'naechsterVersuch']]) @@ -230,6 +232,9 @@ test('main legt auf leerer Datenbank 4 Collections, alle Attribute und Indizes a assert.deepEqual(client.calls.collections, NAMES.map((id) => ({ id, name: id }))) assert.deepEqual([...client.calls.attrs].sort(), [...expectedAttrs].sort()) assert.deepEqual([...client.calls.indexes].sort(), [...expectedIndexes].sort()) + // die Loeschfristen (Tagesjob) lesen ueber Status + Bezugszeit: beide Indizes werden angelegt + assert.ok(client.calls.indexes.includes('termineBookings.idx_status_ends')) + assert.ok(client.calls.indexes.includes('termineBookings.idx_status_updated')) assert.equal(result.missing.length, expectedAttrs.length) assert.deepEqual(result.missingCollections, NAMES) assert.equal(result.missingIndexes.length, expectedIndexes.length) @@ -267,6 +272,29 @@ test('main: legt nur fehlende Attribute und Indizes einer vorhandenen Collection assert.equal(client.calls.collections.length, 3) }) +test('main: eine vorhandene termineBookings-Collection bekommt nur die beiden Loesch-Indizes nachgezogen (--check meldet sie)', async () => { + const vorhanden = { + termineBookings: { + attrs: keysOf('termineBookings'), + indexes: ['idx_feature_start', 'idx_status_held', 'idx_feature_email', 'idx_verify', 'idx_manage', 'idx_status_start'], + }, + } + const lines = [] + const pruefung = fakeClient(vorhanden) + const offen = await main({ check: true, client: pruefung, log: (line) => lines.push(line) }) + assert.deepEqual(offen.missingIndexes.filter((i) => i.collection === 'termineBookings').map((i) => i.key), ['idx_status_ends', 'idx_status_updated']) + assert.ok(lines.some((line) => line.includes('Index termineBookings.idx_status_ends FEHLT'))) + assert.ok(lines.some((line) => line.includes('Index termineBookings.idx_status_updated FEHLT'))) + + const client = fakeClient(vorhanden) + await main({ client, log: quiet }) + assert.deepEqual( + client.calls.indexes.filter((k) => k.startsWith('termineBookings.')), + ['termineBookings.idx_status_ends', 'termineBookings.idx_status_updated'], + ) + assert.deepEqual(client.calls.attrs.filter((k) => k.startsWith('termineBookings.')), []) +}) + test('main --check legt nichts an und meldet Fehlendes', async () => { const client = fakeClient() const lines = [] diff --git a/test/termineZellen.test.js b/test/termineZellen.test.js index 7bb9cf1..412cdff 100644 --- a/test/termineZellen.test.js +++ b/test/termineZellen.test.js @@ -958,6 +958,59 @@ test('loeschKandidaten: Bezugszeit je Status (verfallen heldUntil, erledigt Term await assert.rejects(B.loeschKandidaten('gehalten', neunzig), TypeError) }) +test('loeschKandidaten: hoechstens limit (Standard 100), aelteste Bezugszeit zuerst, nach Funktion filterbar, ohne Gastfelder', async () => { + const mit = (ms) => iso(new Date(JETZT.getTime() + ms)) + const neunzig = new Date(JETZT.getTime() - 90 * TAG) + const nr = (i) => String(i).padStart(3, '0') + // 150 alte erledigt; je groesser i, desto spaeter das Terminende; in umgekehrter Reihenfolge angelegt, jede dritte in der Funktion 2 + for (let i = 149; i >= 0; i -= 1) { + seedBuchung(`b_e${nr(i)}`, { status: 'erledigt', endsAt: mit(-91 * TAG + i * MIN), featureId: i % 3 === 0 ? 'f_feature02' : F }) + } + seedBuchung('b_jung', { status: 'erledigt', endsAt: mit(-89 * TAG) }) + const ids = (liste) => liste.map((doc) => doc.$id) + const alle = Array.from({ length: 150 }, (_, i) => `b_e${nr(i)}`) + + aw.resetListCalls() + const standard = await B.loeschKandidaten('erledigt', neunzig) + assert.deepEqual(ids(standard), alle.slice(0, 100), 'Standard 100, aelteste zuerst') + assert.deepEqual(ids(await B.loeschKandidaten('erledigt', neunzig, { limit: 10 })), alle.slice(0, 10)) + assert.deepEqual(ids(await B.loeschKandidaten('erledigt', neunzig, { limit: 1 })), [alle[0]]) + const zwei = await B.loeschKandidaten('erledigt', neunzig, { featureId: 'f_feature02' }) + assert.deepEqual(ids(zwei), alle.filter((_, i) => i % 3 === 0), 'nur die Funktion 2, aelteste zuerst') + assert.deepEqual(ids(await B.loeschKandidaten('erledigt', neunzig, { featureId: 'f_feature02', limit: 5 })), alle.filter((_, i) => i % 3 === 0).slice(0, 5)) + assert.deepEqual(await B.loeschKandidaten('erledigt', neunzig, { featureId: 'f_gibt_es_nicht' }), []) + + // keine Gastfelder: weder Name noch Adresse, nur Kennung, Funktion, Status und die drei Bezugszeiten + const erlaubt = new Set(['featureId', 'status', 'endsAt', 'updatedAt', 'heldUntil']) + for (const doc of standard) { + assert.ok(doc.$id) + assert.equal(doc.status, 'erledigt') + assert.deepEqual(Object.keys(doc).filter((key) => !key.startsWith('$') && !erlaubt.has(key)), [], `${doc.$id}: nur erlaubte Felder`) + assert.equal('gastName' in doc, false) + assert.equal('gastEmail' in doc, false) + } + // die Abfrage sagt es selbst: select ohne Gastfelder, limit, Reihenfolge nach der Bezugszeit + const abfrage = aw.listCalls('termineBookings')[0].queries + assert.deepEqual(abfrage.find((q) => q.method === 'select').values, ['$id', 'featureId', 'status', 'endsAt', 'updatedAt', 'heldUntil']) + assert.deepEqual(abfrage.find((q) => q.method === 'limit').values, [100]) + assert.deepEqual(abfrage.find((q) => q.method === 'orderAsc'), { method: 'orderAsc', attribute: 'endsAt' }) + assert.deepEqual(abfrage.find((q) => q.method === 'lessThan'), { method: 'lessThan', attribute: 'endsAt', values: [iso(neunzig)] }) + + // Reihenfolge nach der Bezugszeit des Status: verfallen heldUntil, abgesagt updatedAt (Einfuegereihenfolge ist egal) + seedBuchung('b_v2', { status: 'verfallen', heldUntil: mit(-60 * STUNDE) }) + seedBuchung('b_v1', { status: 'verfallen', heldUntil: mit(-70 * STUNDE) }) + seedBuchung('b_v3', { status: 'verfallen', heldUntil: mit(-50 * STUNDE) }) + assert.deepEqual(ids(await B.loeschKandidaten('verfallen', new Date(JETZT.getTime() - 48 * STUNDE))), ['b_v1', 'b_v2', 'b_v3']) + seedBuchung('b_a2', { status: 'abgesagt', updatedAt: mit(-40 * TAG) }) + seedBuchung('b_a1', { status: 'abgesagt', updatedAt: mit(-50 * TAG) }) + assert.deepEqual(ids(await B.loeschKandidaten('abgesagt', new Date(JETZT.getTime() - 30 * TAG))), ['b_a1', 'b_a2']) + + // wie bisher: nicht loeschbare Status und ungueltige limit + await assert.rejects(B.loeschKandidaten('bestaetigt', neunzig, { limit: 10 }), TypeError) + await assert.rejects(B.loeschKandidaten('gehalten', neunzig), TypeError) + for (const limit of [0, -1, 1.5, NaN, '10']) await assert.rejects(B.loeschKandidaten('erledigt', neunzig, { limit }), TypeError, `limit ${limit}`) +}) + 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' })