Terminplaner: Aufbewahrungs-Loeschung seitenweise (Grundlagen, verfallen/abgesagt/abgelehnt)

loeschKandidaten liest eine Seite (Standard 100, aelteste Bezugszeit zuerst, optional je Funktion)
ohne Gastfelder; die Frist steht in der Abfrage. arbeiteSeiten (Seite lesen, bearbeiten, neu lesen)
endet bei leerer Seite, am Deckel (config.termine.loeschDeckel, 2000 je Status und Lauf, Rest an den
Folgetagen, gezaehlt als gedeckelt) oder bei einer Seite ohne Fortschritt. verfallen, abgesagt und
abgelehnt laufen darueber, unter der Buchungssperre werden Status und Bezugszeit frisch geprueft.
taeglicherJob liefert gedeckelt getrennt vom Loeschzaehler. Neue Indizes idx_status_ends und
idx_status_updated auf termineBookings (scripts/termine-setup.mjs legt sie an).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 08:55:32 +00:00
1 parent 2dd9c0dec7
commit 5c95dfc322
8 files changed
+484 -33

No files matched your search

+2
View File
@@ -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
+25 -6
View File
@@ -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
+116 -24
View File
@@ -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 }
})
}
+3
View File
@@ -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'),
],
},
+256 -3
View File
@@ -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)
+1
View File
@@ -47,6 +47,7 @@ test('Konfiguration: Vorgaben ohne Umgebungsvariablen', async () => {
holdMinutes: 30,
zelleMin: 5,
mailsProStunde: 200,
loeschDeckel: 2000,
formMinSekunden: 3,
minAntwortMs: 300,
})
+28
View File
@@ -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 = []
+53
View File
@@ -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' })