Terminplaner: Jobs lesen abgeschaltete Funktionen vor dem Löschen neu, Tagesbericht im Log

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-03 08:17:43 +00:00
1 parent 485ff3bc15
commit 43ac05aa06
3 files changed
+182 -21

No files matched your search

+98 -4
View File
@@ -33,7 +33,7 @@ const { speichereEinstellung, resetKontextForTests } = await import('../server/s
const { belegeZellen } = await import('../server/services/termine/zellen.js')
const { bestaetige } = await import('../server/services/termine/ablauf.js')
const J = await import('../server/services/termine/jobs.js')
const { minutenJob, fuenfMinutenJob, taeglicherJob, wendeLoeschfristenAn, loescheAbgeschalteteFunktionen, resetJobsForTests } = J
const { minutenJob, fuenfMinutenJob, taeglicherJob, tagesbericht, wendeLoeschfristenAn, loescheAbgeschalteteFunktionen, resetJobsForTests } = J
after(() => setMailTransportForTests(null))
const MIN = 60000
@@ -774,7 +774,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: 1, buchungen: 0 }, 'zweiter Lauf: nichts mehr zu löschen')
assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 0, buchungen: 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 () => {
@@ -789,6 +789,79 @@ test('loescheAbgeschalteteFunktionen: scheitert das Löschen einer Buchung, blei
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 })
}
/** 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 })
/** Anfragen an die Buchungsliste einer Funktion bzw. an die Funktionsliste */
const funktionsListe = (url, methode) => methode === 'GET' && /\/collections\/portalFeatures\/documents(\?|$)/.test(url)
const buchungLoeschen = (url, methode) => methode === 'DELETE' && /\/collections\/termineBookings\/documents\/b_ab/.test(url)
for (const [wann, passt] of [['zwischen Funktionsliste und Buchungsliste', funktionsListe], ['zwischen Buchungsliste und erster Löschung', listeVonBuchungen(/featureId/)]]) {
for (const [was, umschalten] of [
['wieder eingeschaltet (disabledAt bleibt alt)', schalteEin],
['frisch neu abgeschaltet', () => aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(F1), disabledAt: iso(plus(JETZT, -TAG)) })],
]) {
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.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`)
assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt')
assert.ok(warnLog().some((zeile) => /inzwischen wieder aktiv oder neu abgeschaltet/.test(zeile)), 'Abbruch steht im Log')
assert.equal(fehlerLog().length, 0, 'kein Fehler, kein Wurf')
})
}
}
test('loescheAbgeschalteteFunktionen: wird die Funktion nach 50 Buchungen wieder eingeschaltet, endet die Löschung dort', async () => {
await abgeschaltetMitBuchungen(60, { zellen: false })
const echt = globalThis.fetch
let geloescht = 0
globalThis.fetch = async (url, optionen) => {
const antwort = await echt(url, optionen)
if (buchungLoeschen(String(url), optionen?.method ?? 'GET')) {
geloescht += 1
if (geloescht === 50) schalteEin()
}
return antwort
}
let erg
try {
erg = await loescheAbgeschalteteFunktionen(JETZT)
} finally {
globalThis.fetch = echt
}
assert.equal(geloescht, 50, 'nach dem Einschalten kommt keine Löschung mehr')
assert.deepEqual(erg, { funktionen: 1, buchungen: 50 })
assert.equal(buchungen().length, 10)
assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt')
})
test('loescheAbgeschalteteFunktionen: wird die Funktion nach der letzten Buchung wieder eingeschaltet, bleibt die Einstellung', async () => {
await abgeschaltetMitBuchungen(2)
const erg = await mitEinwurf(buchungLoeschen, async () => schalteEin(), () => loescheAbgeschalteteFunktionen(JETZT))
// die erste Buchung ging noch vor dem Einschalten; die Funktion ist danach aktiv, ihre Einstellung muss bleiben
assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt')
assert.equal(erg.funktionen, 1)
assert.equal(erg.buchungen, 2, 'beide Buchungen waren in einem Block von 50, vor der nächsten Prüfung gelöscht')
})
test('loescheAbgeschalteteFunktionen: kann die Funktion vor dem Löschen nicht gelesen werden, wird nichts gelöscht (Fehler im Log)', async () => {
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.equal(buchungen().length, 2)
assert.ok(aw.docs('termineConfig').get(F1))
assert.equal(fehlerLog().length, 1)
})
// ---------------------------------------------------------------- Tagesjob
test('taeglicherJob: Löschfristen, Warteschlange, festgefahrene Mails, verwaiste Zellen und abgeschaltete Funktionen in einem Lauf', async () => {
@@ -822,6 +895,20 @@ test('taeglicherJob: Löschfristen, Warteschlange, festgefahrene Mails, verwaist
assert.equal(aw.docs('termineClaims').size, claims('b_zukunft').length, 'sonst bleibt keine Zelle übrig')
assert.equal(aw.docs('termineOutbox').get('o_gesendet'), undefined)
assert.equal(aw.docs('termineOutbox').get('o_festgefahren').status, 'fehler')
// eine Zeile fürs Log: nur Zahlen, keine IDs, keine Personendaten
const zeile = tagesbericht(erg)
assert.equal(
zeile,
`[termine] Tagesjob: 3 Buchungen gelöscht (verfallen 1, abgesagt 0, abgelehnt 0, erledigt 1, nichterschienen 0, abgeschaltete Funktionen 1), 1 Funktionen bereinigt, ${erg.zellen} Zellen, 1 Outbox, 1 Mails nicht zugestellt, 0 Fehler`,
)
assert.doesNotMatch(zeile, /\bb_|\bf_|@|Mustermann|Salon/)
})
test('tagesbericht: keine Zeile, wenn der Job nicht gelaufen ist', () => {
assert.equal(tagesbericht({ aus: true }), null)
assert.equal(tagesbericht({ uebersprungen: true }), null)
assert.equal(tagesbericht(undefined), null)
})
test('taeglicherJob: festgefahrene Mail wird fehler und erst 48 Stunden später gelöscht (nicht im selben Lauf)', async () => {
@@ -861,6 +948,8 @@ test('fehlende Collection: höchstens eine Warnung je Stunde und Job, kein Wurf'
assert.equal(warnLog().filter((zeile) => /minutenJob/.test(zeile)).length, 2, 'nach einer Stunde wieder')
})
assert.match(warnLog()[0], /collection_not_found/)
assert.match(warnLog().find((zeile) => /minutenJob/.test(zeile)), /Schritt „Verfall“/, 'die Warnung nennt den Schritt')
assert.match(warnLog().find((zeile) => /fuenfMinutenJob/.test(zeile)), /Schritt „Erinnerungen“/)
})
test('fehlende Collection der Warteschlange: der Minutenjob verfällt trotzdem Haltungen', async () => {
@@ -921,10 +1010,15 @@ test('server/index.js: die drei Jobs laufen alle 60 s, alle 5 min und täglich (
assert.match(index, /import \{[^}]*\bfuenfMinutenJob\b[^}]*\} from '\.\/services\/termine\/jobs\.js'/)
assert.match(index, /import \{[^}]*\btaeglicherJob\b[^}]*\} from '\.\/services\/termine\/jobs\.js'/)
for (const [job, takt] of [['minutenJob', '60 \\* 1000'], ['fuenfMinutenJob', '5 \\* 60 \\* 1000'], ['taeglicherJob', '24 \\* 60 \\* 60 \\* 1000']]) {
const lauf = new RegExp(`const (\\w+) = \\(\\) =>\\s*${job}\\(new Date\\(\\)\\)\\s*\\.catch\\(`)
const lauf = new RegExp(`const (\\w+) = \\(\\) =>\\s*${job}\\(new Date\\(\\)\\)([\\s\\S]*?)\\.catch\\(`)
const treffer = lauf.exec(index)
assert.ok(treffer, `${job} mit .catch eingebunden`)
assert.match(index, new RegExp(`setInterval\\(${treffer[1]}, ${takt}\\)\\.unref\\(\\)`), `${job}: Intervall`)
if (job === 'taeglicherJob') assert.match(index, new RegExp(`setTimeout\\(${treffer[1]}, 180 \\* 1000\\)\\.unref\\(\\)`), 'täglicher Job: +180 s')
if (job === 'taeglicherJob') {
assert.match(index, new RegExp(`setTimeout\\(${treffer[1]}, 180 \\* 1000\\)\\.unref\\(\\)`), 'täglicher Job: +180 s')
assert.match(treffer[2], /\.then\(\(ergebnis\) => \{\s*const zeile = tagesbericht\(ergebnis\)\s*if \(zeile\) console\.log\(zeile\)\s*\}\)\s*$/, 'eine Zusammenfassung pro Tageslauf im Log')
} else {
assert.match(treffer[2], /^\s*$/, `${job}: direkt .catch`)
}
}
})