Files
Webklar-Kundenbereich/test/termineJobs.test.js
T
ClaudeandClaude Sonnet 5.5 91b1a3bcd8 Terminplaner TP2: Entwuerfe nach 90 Tagen und bei Kundenloeschung entfernen
Neu: server/services/termine/entwuerfe.js. Ein Terminplaner im Zustand off ist ein
Entwurf (Fragebogen, Einstellung). loescheEntwuerfe(now) im Tagesjob (nach den
abgeschalteten Funktionen, nur im vollen Lauf): letzte Aenderung = juengerer Zeitpunkt
aus $updatedAt des Funktionsdokuments und termineConfig.updatedAt, aelter als 90 Tage
=> Einstellung weg, das Funktionsdokument nur, wenn es nie in Betrieb oder bezahlt war
(sonst bleibt es wegen des siteKeys; ein von E8 zurueckgesetztes Dokument nie).
Vor jedem Loeschen unter feature-order, feature-admin und termine-cfg frisch lesen
(noch off, Frist, keine Buchung). Seiten zu 100 per cursorAfter, hoechstens 2000
geprueft und 100 geloescht je Lauf, der Rest folgt an Folgetagen; Einzelfehler zaehlen
in fehler. Tagesbericht: Segment "Entwuerfe n geloescht (m offen)".
loescheEntwuerfeVonKunde: Kundenloeschung entfernt alle off-Entwuerfe vollstaendig,
Fehler werden nur protokolliert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 20:33:33 +00:00

2971 lines
161 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { startAppwriteMock } from './support/appwriteMock.js'
import { mailSink } from './support/mailSink.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
// Hintergrund-Jobs des Terminplaners (Minuten-, Fuenf-Minuten- und Tagesjob): Nachbau von Appwrite mit Schema und
// Standard-Limit 25, Mails landen in der Senke, alle Zeiten sind feste Parameter.
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
subscriptions: schemaKeys('subscriptions'),
contractEvents: schemaKeys('contractEvents'),
termineConfig: schemaKeys('termineConfig'),
termineBookings: schemaKeys('termineBookings'),
termineClaims: schemaKeys('termineClaims'),
termineOutbox: schemaKeys('termineOutbox'),
},
datetimes: TERMINE_DATETIMES,
defaultLimit: 25,
})
after(() => aw.close())
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { subscriptionDoc, wartungDoc } = await import('./support/fixtures.js')
const { Query, deleteDocument, listAllDocuments } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { normalisiereEinstellung } = await import('../server/services/termine/einstellungen.js')
const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js')
const { letzterTagesjob, resetBetriebForTests, statusDaten } = await import('../server/services/termine/betrieb.js')
const { neuesToken, verwaltungsToken, buchungsNummer } = await import('../server/services/termine/token.js')
const { speichereEinstellung, resetKontextForTests } = await import('../server/services/termine/kontext.js')
const { belegeZellen } = await import('../server/services/termine/zellen.js')
const { bestaetige } = await import('../server/services/termine/ablauf.js')
const { disable } = await import('../server/services/siteFeatures.js')
const { withLocks } = await import('../server/services/keyedLock.js')
const J = await import('../server/services/termine/jobs.js')
const { minutenJob, fuenfMinutenJob, taeglicherJob, tagesbericht, wendeLoeschfristenAn, loescheAbgeschalteteFunktionen, resetJobsForTests } = J
after(() => setMailTransportForTests(null))
const MIN = 60000
const STUNDE = 60 * MIN
const TAG = 24 * STUNDE
const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin
const KUNDE = 'c_kunde01'
const PROJEKT = 'p_projekt01'
const F1 = 'f_p_projekt01_termine'
const F2 = 'f_p_projekt02_termine'
const F3 = 'f_p_projekt03_termine'
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 GESAMT_DECKEL_VORHER = config.termine.loeschDeckelGesamt
const ZELLEN_DECKEL_VORHER = config.termine.zellenDeckel
const BASIS_VORHER = config.publicBaseUrl
const iso = (wert) => new Date(wert).toISOString()
const plus = (von, ms) => new Date(new Date(von).getTime() + ms)
const ms = (wert) => new Date(wert).getTime()
const feature = (id = F1, over = {}) => ({
$id: id,
customerId: KUNDE,
projectId: id === F1 ? PROJEKT : `p_${id}`,
featureKey: 'termine',
state: 'active',
enabled: true,
siteKey: 'abcdefghijklmnop',
...over,
})
/** Vom Kauf freigeschalteter Terminplaner (unlockedByPurchase): nur solche halten im Modus off den eingeschränkten Tagesjob am Laufen. */
const BEZAHLT = { unlockedByPurchase: true, purchaseStatus: 'paid' }
const eingabeEinstellung = (over = {}) => ({
betrieb: {
name: 'Salon Müller',
email: 'info@salon.de',
telefon: '06301 1234',
adresse: 'Hauptstr. 1, 67722 Winnweiler',
websiteUrl: 'https://salon.de',
impressumUrl: 'https://salon.de/impressum',
datenschutzUrl: 'https://salon.de/datenschutz',
},
anrede: 'sie',
bundesland: 'RP',
ressourcen: [{ id: R1, name: 'Anna', plan: { 1: [['09:00', '18:00']] } }],
leistungen: [{ id: S1, name: 'Damenschnitt', dauer: 45, pufferNach: 5, preisArt: 'fest', preisCent: 4500, buchungsart: 'sofort', ressourcen: [R1] }],
regeln: { raster: 15, vorlaufMin: 120, horizontTage: 90, absageBisStd: 24, maxOffeneJeEmail: 3, personWaehlbar: true },
gastFelder: { telefon: 'optional', nachricht: 'aus', zusatz: [] },
erinnerung: { anbieten: true, stundenVorher: 24 },
storno: { text: 'Kostenlose Absage bis 24 Stunden vor dem Termin.' },
recht: { gaesteSind: 'verbraucher', freizeit: false },
zahlung: { modus: 'vorort', vorOrtArten: ['bar', 'ec'] },
...over,
})
const einstellung = (over) => {
const erg = normalisiereEinstellung(eingabeEinstellung(over), { now: JETZT })
assert.equal(erg.fehler, undefined, JSON.stringify(erg.fehler))
return erg.einstellung
}
let sink
let log
/** Funktion samt Einstellung anlegen (portalFeatures + termineConfig). */
async function richteFunktionEin(id = F1, over = {}, einstellungOver = {}) {
aw.seed('portalFeatures', feature(id, over))
await speichereEinstellung(feature(id, over), einstellung(einstellungOver), { version: 0, now: JETZT })
}
/** Wie richteFunktionEin, aber nur die Einstellung (die Funktion selbst ist schon angelegt). */
async function speichere(id, einstellungOver) {
const doc = aw.docs('termineConfig').get(id)
await speichereEinstellung(feature(id), einstellung(einstellungOver), { version: doc ? doc.version : 0, now: JETZT })
}
beforeEach(async () => {
for (const collection of ['portalFeatures', 'subscriptions', 'contractEvents', 'termineConfig', 'termineBookings', 'termineClaims', 'termineOutbox', 'customers', 'websiteProjects']) {
for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id)
}
resetKontextForTests()
resetOutboxForTests()
resetBetriebForTests()
resetJobsForTests()
aw.resetListCalls()
config.termine.mode = 'pilot'
config.smtp.from = 'noreply@webklar.com'
config.publicBaseUrl = 'https://project.webklar.com'
sink = mailSink()
setMailTransportForTests(sink)
log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}) }
aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, subdomain: 'salon' })
aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: 'silke@example.com' })
})
afterEach(async () => {
await warteAufVersand()
// Logs enthalten nie personenbezogene Daten der Gaeste (Name, Adresse)
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.termine.loeschDeckelGesamt = GESAMT_DECKEL_VORHER
config.termine.zellenDeckel = ZELLEN_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')
})
// ---------------------------------------------------------------- Hilfen
/**
* Buchung samt Zellen anlegen. start (Date) oder ende (Date; dann beginnt sie 45 Minuten vorher); Voreinstellung: bestaetigt,
* Termin in 3 Tagen, eigene Ressource je Buchung (kein Zellenkonflikt). zellen: false = nur das Dokument.
*/
async function legeBuchung(id, { start, ende, zellen = true, ...over } = {}) {
const beginn = start ?? (ende ? plus(ende, -45 * MIN) : plus(JETZT, 3 * TAG))
const doc = {
$id: id,
featureId: F1,
customerId: KUNDE,
projectId: PROJEKT,
leistungId: S1,
leistungName: 'Damenschnitt',
ressourceId: `r_${id}`,
ressourceName: 'Anna',
startsAt: iso(beginn),
endsAt: iso(plus(beginn, 45 * MIN)),
blockEndsAt: iso(plus(beginn, 50 * MIN)),
status: 'bestaetigt',
quelle: 'online',
gastName: 'Max Mustermann',
gastEmail: 'max@example.com',
gastTelefon: '',
gastNachricht: '',
gastZusatz: '',
erinnerung: false,
buchungsart: 'sofort',
preisArt: 'keiner',
preisCent: 0,
verbraucher: false,
icsSequenz: 0,
bestaetigtAt: iso(plus(beginn, -10 * TAG)),
createdAt: iso(plus(beginn, -10 * TAG)),
updatedAt: iso(plus(beginn, -10 * TAG)),
...over,
}
aw.seed('termineBookings', doc)
if (zellen) {
await belegeZellen({ featureId: doc.featureId, ressourceId: doc.ressourceId, bookingId: id, start: new Date(doc.startsAt), blockEnde: new Date(doc.blockEndsAt), now: JETZT })
}
return doc
}
/** Bestaetigte Buchung mit Erinnerungswunsch; Vertragsschluss 3 Tage vor `jetzt`. */
const mitErinnerung = (id, abJetzt, over = {}) =>
legeBuchung(id, { start: plus(JETZT, abJetzt), erinnerung: true, bestaetigtAt: iso(plus(JETZT, -3 * TAG)), ...over })
const buchungen = () => [...aw.docs('termineBookings').values()]
const buchung = (id) => aw.docs('termineBookings').get(id)
const status = (id) => buchung(id)?.status
const claims = (bookingId) => [...aw.docs('termineClaims').values()].filter((c) => c.bookingId === bookingId)
const eintraege = () => [...aw.docs('termineOutbox').values()]
const fehlerLog = () => log.error.mock.calls.map((aufruf) => aufruf.arguments.map(String).join(' '))
const warnLog = () => log.warn.mock.calls.map((aufruf) => aufruf.arguments.map(String).join(' '))
/** Abgelegte Warteschlangen-Eintraege direkt in der Datenbank (ohne Sendeversuch). */
function legeEintrag(id, over = {}) {
aw.seed('termineOutbox', {
$id: id,
featureId: F1,
bookingId: 'b_direkt',
art: 'G05',
an: 'max@example.com',
fromName: 'Salon Müller',
replyTo: 'info@salon.de',
subject: 'Erinnerung: Ihr Termin',
html: '<p>Hallo</p>',
text: 'Hallo',
ics: '',
icsMethod: '',
status: 'wartet',
versuche: 0,
naechsterVersuch: iso(JETZT),
fehler: '',
createdAt: iso(JETZT),
...over,
})
}
/** Anzahl der Anfragen an den Appwrite-Nachbau, die fn ausloest. */
async function zaehleAnfragen(fn, muster = null) {
const echt = globalThis.fetch
let n = 0
globalThis.fetch = (url, optionen) => {
if (!muster || muster.test(String(url))) n += 1
return echt(url, optionen)
}
try {
await fn()
} finally {
globalThis.fetch = echt
}
return n
}
/**
* Nach der ersten Antwort auf eine Anfrage, auf die passt(urlText, methode) zutrifft, laeuft zuerst dazwischen() zu Ende,
* dann erst bekommt der Aufrufer die (inzwischen veraltete) Antwort: ein anderer Vorgang schiebt sich dazwischen.
*/
async function mitEinwurf(passt, dazwischen, fn) {
const echt = globalThis.fetch
let offen = true
globalThis.fetch = async (url, optionen) => {
const antwort = await echt(url, optionen)
if (offen && passt(decodeURIComponent(String(url)), optionen?.method ?? 'GET')) {
offen = false
globalThis.fetch = echt
await dazwischen()
}
return antwort
}
try {
return await fn()
} finally {
globalThis.fetch = echt
}
}
const listeVonBuchungen = (muster) => (url, methode) =>
methode === 'GET' && /\/collections\/termineBookings\/documents(\?|$)/.test(url) && muster.test(url)
const listeVonOutbox = (url, methode) => methode === 'GET' && /\/collections\/termineOutbox\/documents(\?|$)/.test(url)
/**
* Haengende Schritte: haelt je Muster (url, methode) => boolean die erste Anfrage an, auf die es zutrifft, bis freigeben() alle
* loslaesst. warteBisRuhig() wartet, bis keine andere Anfrage mehr laeuft und 50 ms lang keine neue kam (die uebrigen Schritte
* des Takts sind dann durch). ende() stellt fetch wieder her.
*/
function mitHaengern(...muster) {
const echt = globalThis.fetch
const tore = muster.map(() => {
const tor = { gehalten: false }
tor.warte = new Promise((resolve) => (tor.los = resolve))
return tor
})
let offen = 0
let anfragen = 0
globalThis.fetch = async (url, optionen) => {
anfragen += 1
const text = decodeURIComponent(String(url))
const methode = optionen?.method ?? 'GET'
const i = muster.findIndex((passt, k) => !tore[k].gehalten && passt(text, methode))
if (i >= 0) {
tore[i].gehalten = true
await tore[i].warte
return echt(url, optionen)
}
offen += 1
try {
return await echt(url, optionen)
} finally {
offen -= 1
}
}
return {
freigeben: () => tore.forEach((tor) => tor.los()),
gehalten: () => tore.every((tor) => tor.gehalten),
async warteBisRuhig() {
for (let ruhig = 0; ruhig < 5; ) {
const stand = anfragen
await new Promise((resolve) => setTimeout(resolve, 10))
ruhig = offen === 0 && stand === anfragen ? ruhig + 1 : 0
}
},
ende: () => {
globalThis.fetch = echt
},
}
}
/** Alle Lese-Anfragen an die Collection scheitern wie in Appwrite mit 404 collection_not_found. */
async function mitFehlenderCollection(collection, fn) {
const echt = globalThis.fetch
globalThis.fetch = async (url, optionen) => {
if (new RegExp(`/collections/${collection}/documents`).test(String(url)) && (optionen?.method ?? 'GET') === 'GET') {
const body = { message: 'Collection with the requested ID could not be found.', code: 404, type: 'collection_not_found' }
return new Response(JSON.stringify(body), { status: 404, headers: { 'content-type': 'application/json' } })
}
return echt(url, optionen)
}
try {
return await fn()
} finally {
globalThis.fetch = echt
}
}
// ---------------------------------------------------------------- Schalter
/** Eine Anfrage an die Funktionsliste, die der Guard des Tagesjobs im Modus off stellt: Termine, bezahlt, hoechstens ein Treffer. */
const istGuardAbfrage = (aufruf) => {
const q = aufruf.queries
return (
aufruf.collection === 'portalFeatures' &&
q.length === 3 &&
q.some((k) => k.method === 'equal' && k.attribute === 'featureKey' && k.values[0] === 'termine') &&
q.some((k) => k.method === 'equal' && k.attribute === 'unlockedByPurchase' && k.values[0] === true) &&
q.some((k) => k.method === 'limit' && k.values[0] === 1)
)
}
test('Modus off ohne bezahlten Terminplaner: Minuten- und Fünf-Minuten-Job fragen nichts, der Tagesjob stellt genau eine Frage (den Guard) und tut sonst nichts', async () => {
await richteFunktionEin() // aktiv, aber nicht vom Kauf freigeschaltet (Pilot/Kulanz)
await legeBuchung('b_abgelaufen', { status: 'gehalten', heldUntil: iso(plus(JETZT, -5 * MIN)) })
await legeLoeschFall('b_verfallen', 'verfallen', 1)
await mitErinnerung('b_erinnern', 23 * STUNDE)
legeEintrag('o_wartet')
legeEintrag('o_festgefahren', { createdAt: iso(plus(JETZT, -3 * TAG)) })
await richteFunktionEin(F2, { state: 'disabled', disabledAt: iso(plus(JETZT, -45 * TAG)) }) // fällig zur Löschung, aber nicht bezahlt
await legeBuchung('b_abgeschaltet', { featureId: F2, start: plus(JETZT, 5 * TAG) })
config.termine.mode = 'off'
const n = await zaehleAnfragen(async () => {
for (const job of [minutenJob, fuenfMinutenJob]) assert.deepEqual(await job(JETZT), { aus: true })
})
assert.equal(n, 0, 'Minuten- und Fünf-Minuten-Job: keine einzige Anfrage an Appwrite')
aw.resetListCalls()
const m = await zaehleAnfragen(async () => assert.deepEqual(await taeglicherJob(JETZT), { aus: true }))
assert.equal(m, 1, 'der Tagesjob stellt genau eine Anfrage: die Guard-Abfrage')
const abfragen = aw.listCalls()
assert.equal(abfragen.length, 1)
assert.ok(istGuardAbfrage(abfragen[0]), JSON.stringify(abfragen[0]))
assert.equal(status('b_abgelaufen'), 'gehalten')
assert.ok(buchung('b_verfallen'), 'nichts gelöscht')
assert.ok(buchung('b_abgeschaltet'))
assert.ok(aw.docs('termineConfig').get(F2))
assert.equal(buchung('b_erinnern').erinnerungGesendetAt, undefined)
assert.equal(sink.sent.length, 0)
assert.equal(eintraege().find((e) => e.$id === 'o_wartet').status, 'wartet')
assert.equal(eintraege().find((e) => e.$id === 'o_festgefahren').status, 'wartet', 'festgefahrene Mails bleiben liegen')
assert.equal(letzterTagesjob(), null, 'keine Tagesjob-Meldung')
assert.equal(fehlerLog().length + warnLog().length, 0)
})
test('Modus off: schlägt die Guard-Abfrage fehl, bleibt der Tagesjob aus (Warnung ohne Kennungen, keine Löschung, kein Wurf)', async () => {
await richteFunktionEin(F1, { state: 'disabled', disabledAt: iso(plus(JETZT, -45 * TAG)), disabledReason: 'abo_ende', aboEndeMailAt: iso(plus(JETZT, -44 * TAG)), ...BEZAHLT })
await legeBuchung('b_fest', { start: plus(JETZT, 5 * TAG) })
config.termine.mode = 'off'
aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau kaputt' })
assert.deepEqual(await taeglicherJob(JETZT), { aus: true })
assert.ok(buchung('b_fest'), 'keine Löschung')
assert.ok(aw.docs('termineConfig').get(F1))
assert.equal(warnLog().filter((zeile) => /taeglicherJob/.test(zeile) && /Modus off/.test(zeile) && /Nachbau kaputt/.test(zeile)).length, 1, 'eine Warnung')
assert.doesNotMatch(warnLog().join('\n'), /\bb_|\bf_|\bp_|\bc_|@/)
assert.equal(fehlerLog().length, 0)
assert.equal(letzterTagesjob(), null)
// am nächsten Tag lesbar: der eingeschränkte Lauf holt die Löschung nach
const erg = await taeglicherJob(plus(JETZT, TAG))
assert.equal(erg.eingeschraenkt, true)
assert.equal(buchung('b_fest'), undefined)
})
test('jeder Job verlangt eine gültige Uhrzeit', async () => {
for (const job of [minutenJob, fuenfMinutenJob, taeglicherJob]) await assert.rejects(() => job(), TypeError)
})
// ---------------------------------------------------------------- Minutenjob
test('minutenJob: abgelaufene Haltung ⇒ verfallen und Zellen frei; gültige Haltung und andere Buchungen bleiben', async () => {
await legeBuchung('b_genau', { status: 'gehalten', heldUntil: iso(JETZT) })
await legeBuchung('b_vorbei', { status: 'gehalten', heldUntil: iso(plus(JETZT, -5 * MIN)) })
await legeBuchung('b_gueltig', { status: 'gehalten', heldUntil: iso(plus(JETZT, 1)) })
await legeBuchung('b_fest', { status: 'bestaetigt' })
assert.ok(claims('b_genau').length > 0)
const erg = await minutenJob(JETZT)
assert.equal(erg.verfallen, 2)
assert.equal(erg.fehler, 0)
assert.equal(status('b_genau'), 'verfallen')
assert.equal(status('b_vorbei'), 'verfallen')
assert.equal(ms(buchung('b_vorbei').updatedAt), JETZT.getTime(), 'updatedAt = jetzt (feste Uhr)')
assert.equal(claims('b_genau').length, 0)
assert.equal(claims('b_vorbei').length, 0)
assert.equal(status('b_gueltig'), 'gehalten')
assert.ok(claims('b_gueltig').length > 0)
assert.equal(status('b_fest'), 'bestaetigt')
assert.ok(claims('b_fest').length > 0)
await warteAufVersand()
assert.equal(sink.sent.length, 0, 'ein Verfall verschickt nichts')
assert.deepEqual((await minutenJob(JETZT)).verfallen, 0, 'zweiter Lauf: nichts mehr zu tun')
})
test('minutenJob: eine Haltung, die unter der Sperre nicht mehr abgelaufen ist, verfällt nicht', async () => {
const doc = await legeBuchung('b_race', { status: 'gehalten', heldUntil: iso(plus(JETZT, -MIN)) })
const erg = await mitEinwurf(
listeVonBuchungen(/heldUntil/),
async () => aw.seed('termineBookings', { ...doc, heldUntil: iso(plus(JETZT, 10 * MIN)) }),
() => minutenJob(JETZT),
)
assert.equal(erg.verfallen, 0)
assert.equal(status('b_race'), 'gehalten')
assert.ok(claims('b_race').length > 0)
})
test('minutenJob: sendet fällige Einträge der Warteschlange', async () => {
legeEintrag('o_faellig')
legeEintrag('o_spaeter', { naechsterVersuch: iso(plus(JETZT, 10 * MIN)) })
const erg = await minutenJob(JETZT)
assert.equal(erg.gesendet, 1)
assert.equal(sink.sent.length, 1)
assert.equal(aw.docs('termineOutbox').get('o_faellig').status, 'gesendet')
assert.equal(aw.docs('termineOutbox').get('o_spaeter').status, 'wartet')
})
test('minutenJob: scheitert die Abfrage der Haltungen, läuft die Warteschlange trotzdem (Fehler im Log, kein Wurf)', async () => {
legeEintrag('o_faellig')
await legeBuchung('b_vorbei', { status: 'gehalten', heldUntil: iso(plus(JETZT, -MIN)) })
aw.failNext({ collection: 'termineBookings', method: 'GET', status: 500, message: 'Nachbau kaputt' })
const erg = await minutenJob(JETZT)
assert.equal(erg.fehler, 1)
assert.equal(erg.gesendet, 1)
assert.equal(sink.sent.length, 1)
assert.equal(fehlerLog().length, 1)
assert.match(fehlerLog()[0], /minutenJob/)
assert.equal((await minutenJob(JETZT)).verfallen, 1, 'beim nächsten Lauf geht es weiter')
})
// ---------------------------------------------------------------- Erinnerungen
test('Erinnerung: genau einmal (zwei Läufe, auch gleichzeitig), Marker gesetzt, G05 mit Verwaltungslink', async () => {
await richteFunktionEin()
await mitErinnerung('b_erinnern', 23 * STUNDE)
const [a, b] = await Promise.all([fuenfMinutenJob(JETZT), fuenfMinutenJob(JETZT)])
assert.equal([a, b].filter((r) => r.uebersprungen).length, 1, 'der gleichzeitige zweite Lauf wird übersprungen')
assert.equal((await fuenfMinutenJob(JETZT)).erinnerungen, 0)
assert.equal((await fuenfMinutenJob(plus(JETZT, 5 * MIN))).erinnerungen, 0)
await warteAufVersand()
assert.equal(sink.sent.length, 1)
const mail = sink.sent[0]
assert.equal(mail.to[0].address, 'max@example.com')
assert.match(mail.subject, /^Erinnerung: Ihr Termin am /)
assert.ok(mail.text.includes(`/termin.html#m=${verwaltungsToken('b_erinnern')}`), 'Verwaltungslink')
assert.equal(mail.bcc, undefined)
assert.equal(ms(buchung('b_erinnern').erinnerungGesendetAt), JETZT.getTime())
assert.equal(eintraege().filter((e) => e.art === 'G05').length, 1)
})
test('Erinnerung: Fenster (jetzt + 1 h, jetzt + stundenVorher], Rand genau', async () => {
await richteFunktionEin() // 24 Stunden
const faelle = {
b_drei: [3 * STUNDE, true],
b_vier: [24 * STUNDE, true],
b_zuweit: [24 * STUNDE + 1, false],
b_ueberm: [2 * TAG, false],
b_minus: [STUNDE + 1, true],
b_stunde: [STUNDE, false],
b_naeher: [59 * MIN, false],
b_vorbei: [-MIN, false],
}
for (const [id, [abstand]] of Object.entries(faelle)) await mitErinnerung(id, abstand)
await fuenfMinutenJob(JETZT)
await warteAufVersand()
for (const [id, [, erwartet]] of Object.entries(faelle)) {
assert.equal(Boolean(buchung(id).erinnerungGesendetAt), erwartet, id)
}
assert.equal(sink.sent.length, Object.values(faelle).filter(([, erwartet]) => erwartet).length)
})
test('Erinnerung: nicht bei einer Buchung, die erst innerhalb von stundenVorher vor Beginn geschlossen wurde', async () => {
await richteFunktionEin() // 24 Stunden
const start = plus(JETZT, 23 * STUNDE)
await mitErinnerung('b_kurzfristig', 23 * STUNDE, { bestaetigtAt: iso(plus(start, -10 * STUNDE)) })
await mitErinnerung('b_genau', 23 * STUNDE, { bestaetigtAt: iso(plus(start, -24 * STUNDE)) })
await mitErinnerung('b_davor', 23 * STUNDE, { bestaetigtAt: iso(plus(start, -24 * STUNDE - 1)) })
// angenommene Anfrage: der Vertrag beginnt mit angenommenAt, nicht mit der Bestätigung per E-Mail
await mitErinnerung('b_anfrage_frueh', 23 * STUNDE, { buchungsart: 'anfrage', bestaetigtAt: null, angenommenAt: iso(plus(JETZT, -2 * TAG)) })
await mitErinnerung('b_anfrage_spaet', 23 * STUNDE, { buchungsart: 'anfrage', bestaetigtAt: null, angenommenAt: iso(plus(JETZT, -MIN)) })
await mitErinnerung('b_ohne_vertrag', 23 * STUNDE, { bestaetigtAt: null })
await fuenfMinutenJob(JETZT)
await warteAufVersand()
const gesendet = buchungen().filter((b) => b.erinnerungGesendetAt).map((b) => b.$id).sort()
assert.deepEqual(gesendet, ['b_anfrage_frueh', 'b_davor'])
assert.equal(sink.sent.length, 2)
})
test('Erinnerung: wird nach einem Ausfall des Jobs nachgeholt (20 Minuten zu spät)', async () => {
await richteFunktionEin() // 24 Stunden
await mitErinnerung('b_ausfall', 24 * STUNDE)
assert.equal((await fuenfMinutenJob(plus(JETZT, -5 * MIN))).erinnerungen, 0, 'noch nicht fällig')
// der Job lief von JETZT bis JETZT + 20 min nicht
assert.equal((await fuenfMinutenJob(plus(JETZT, 20 * MIN))).erinnerungen, 1)
await warteAufVersand()
assert.equal(sink.sent.length, 1)
assert.equal(ms(buchung('b_ausfall').erinnerungGesendetAt), plus(JETZT, 20 * MIN).getTime())
})
test('Erinnerung: nur bestätigte Buchungen mit Wunsch, ohne Marker und mit Adresse', async () => {
await richteFunktionEin()
await mitErinnerung('b_ohne_wunsch', 5 * STUNDE, { erinnerung: false })
await mitErinnerung('b_schon', 5 * STUNDE, { erinnerungGesendetAt: iso(plus(JETZT, -TAG)) })
await mitErinnerung('b_angefragt', 5 * STUNDE, { status: 'angefragt' })
await mitErinnerung('b_gehalten', 5 * STUNDE, { status: 'gehalten', heldUntil: iso(plus(JETZT, 10 * MIN)) })
await mitErinnerung('b_abgesagt', 5 * STUNDE, { status: 'abgesagt' })
await mitErinnerung('b_ohne_adresse', 5 * STUNDE, { gastEmail: '' })
await mitErinnerung('b_ok', 5 * STUNDE)
await fuenfMinutenJob(JETZT)
await warteAufVersand()
assert.equal(sink.sent.length, 1)
assert.equal(ms(buchung('b_schon').erinnerungGesendetAt), plus(JETZT, -TAG).getTime(), 'alter Marker bleibt')
assert.equal(buchung('b_ohne_wunsch').erinnerungGesendetAt, undefined)
assert.equal(buchung('b_ohne_adresse').erinnerungGesendetAt, undefined)
assert.ok(buchung('b_ok').erinnerungGesendetAt)
})
test('Erinnerung: stundenVorher gilt je Funktion, der Kontext wird je Lauf nur einmal geladen', async () => {
await richteFunktionEin(F1) // 24 Stunden
await richteFunktionEin(F2, {}, { erinnerung: { anbieten: true, stundenVorher: 12 } })
await mitErinnerung('b_a1', 20 * STUNDE)
await mitErinnerung('b_a2', 21 * STUNDE)
await mitErinnerung('b_a3', 22 * STUNDE)
await mitErinnerung('b_f2_nah', 10 * STUNDE, { featureId: F2 })
await mitErinnerung('b_f2_fern', 20 * STUNDE, { featureId: F2 })
let erg
const ladungen = await zaehleAnfragen(async () => {
erg = await fuenfMinutenJob(JETZT)
}, new RegExp(`/collections/portalFeatures/documents/${F1}$`))
await warteAufVersand()
assert.equal(ladungen, 1, 'drei Buchungen derselben Funktion, ein Lesen')
assert.equal(erg.erinnerungen, 4)
assert.ok(buchung('b_f2_nah').erinnerungGesendetAt)
assert.equal(buchung('b_f2_fern').erinnerungGesendetAt, undefined, '20 Stunden sind mehr als die 12 dieser Funktion')
assert.equal(sink.sent.length, 4)
})
test('Erinnerung: eine Buchung ohne lesbare Funktion oder Einstellung wird übersprungen, ohne Marker und ohne Fehler', async () => {
aw.seed('portalFeatures', feature(F1)) // Einstellung nie gespeichert
await mitErinnerung('b_ohne_einstellung', 5 * STUNDE)
await mitErinnerung('b_ohne_funktion', 5 * STUNDE, { featureId: 'f_weg' })
const erg = await fuenfMinutenJob(JETZT)
assert.equal(erg.erinnerungen, 0)
assert.equal(erg.fehler, 0)
assert.equal(buchung('b_ohne_einstellung').erinnerungGesendetAt, undefined)
assert.equal(buchung('b_ohne_funktion').erinnerungGesendetAt, undefined)
assert.equal(sink.sent.length, 0)
})
test('Erinnerung: auch für eine abgeschaltete Funktion (die bestehenden Termine gelten weiter)', async () => {
await richteFunktionEin(F1, { state: 'disabled', disabledAt: iso(plus(JETZT, -2 * TAG)) })
await mitErinnerung('b_abgeschaltet', 5 * STUNDE)
await fuenfMinutenJob(JETZT)
await warteAufVersand()
assert.equal(sink.sent.length, 1)
})
test('Erinnerung: Marker zuerst – scheitert der Marker, geht keine Mail raus und der nächste Lauf holt es nach', async () => {
await richteFunktionEin()
await mitErinnerung('b_marker', 5 * STUNDE)
aw.failNext({ collection: 'termineBookings', method: 'PATCH', status: 500, message: 'Nachbau kaputt' })
const erster = await fuenfMinutenJob(JETZT)
await warteAufVersand()
assert.equal(erster.erinnerungen, 0)
assert.equal(erster.fehler, 1)
assert.equal(sink.sent.length, 0)
assert.equal(buchung('b_marker').erinnerungGesendetAt, undefined)
assert.ok(fehlerLog().some((zeile) => zeile.includes(buchungsNummer('b_marker'))), 'Buchungsnummer im Log')
assert.equal((await fuenfMinutenJob(plus(JETZT, 5 * MIN))).erinnerungen, 1)
await warteAufVersand()
assert.equal(sink.sent.length, 1)
})
test('Erinnerung: scheitert das Einreihen nach dem Marker, wird nichts doppelt gesendet (höchstens einmal)', async () => {
await richteFunktionEin()
await mitErinnerung('b_einreihen', 5 * STUNDE)
aw.failNext({ collection: 'termineOutbox', method: 'POST', status: 500, message: 'Nachbau kaputt' })
const erster = await fuenfMinutenJob(JETZT)
assert.equal(erster.erinnerungen, 0)
assert.equal(erster.fehler, 1)
assert.ok(buchung('b_einreihen').erinnerungGesendetAt, 'Marker steht')
assert.ok(fehlerLog().some((zeile) => zeile.includes(buchungsNummer('b_einreihen'))))
await fuenfMinutenJob(plus(JETZT, 5 * MIN))
await warteAufVersand()
assert.equal(sink.sent.length, 0)
assert.equal(eintraege().length, 0)
})
test('Erinnerung: wird die Buchung zwischen Auflisten und Sperre abgesagt, geht keine Erinnerung raus', async () => {
await richteFunktionEin()
const doc = await mitErinnerung('b_race', 5 * STUNDE)
await mitEinwurf(
listeVonBuchungen(/erinnerung/),
async () => aw.seed('termineBookings', { ...doc, status: 'abgesagt' }),
() => fuenfMinutenJob(JETZT),
)
await warteAufVersand()
assert.equal(status('b_race'), 'abgesagt')
assert.equal(buchung('b_race').erinnerungGesendetAt, undefined)
assert.equal(sink.sent.length, 0)
})
// ---------------------------------------------------------------- Erledigt und vergangene Anfragen
test('fuenfMinutenJob: bestätigte Buchungen (auch Sperren) 1 Stunde nach Ende ⇒ erledigt, sonst nichts', async () => {
await legeBuchung('b_alt', { ende: plus(JETZT, -STUNDE - MIN) })
await legeBuchung('b_sperre', { ende: plus(JETZT, -3 * STUNDE), quelle: 'sperre', gastName: '', gastEmail: '' })
await legeBuchung('b_knapp', { ende: plus(JETZT, -59 * MIN) })
await legeBuchung('b_genau', { ende: plus(JETZT, -STUNDE) })
await legeBuchung('b_zukunft', { ende: plus(JETZT, TAG) })
await legeBuchung('b_angefragt', { ende: plus(JETZT, -3 * STUNDE), status: 'angefragt', startsAt: iso(plus(JETZT, -4 * STUNDE)) })
const erg = await fuenfMinutenJob(JETZT)
assert.equal(erg.erledigt, 2)
assert.equal(status('b_alt'), 'erledigt')
assert.equal(status('b_sperre'), 'erledigt')
assert.equal(ms(buchung('b_alt').updatedAt), JETZT.getTime())
for (const id of ['b_knapp', 'b_genau', 'b_zukunft']) assert.equal(status(id), 'bestaetigt', id)
assert.equal((await fuenfMinutenJob(JETZT)).erledigt, 0)
await warteAufVersand()
assert.equal(sink.sent.length, 0)
})
test('fuenfMinutenJob: markiert der Betrieb den Termin zwischen Auflisten und Sperre als nicht erschienen, bleibt es dabei', async () => {
const doc = await legeBuchung('b_race', { ende: plus(JETZT, -2 * STUNDE) })
await mitEinwurf(
listeVonBuchungen(/endsAt/),
async () => aw.seed('termineBookings', { ...doc, status: 'nichterschienen' }),
() => fuenfMinutenJob(JETZT),
)
assert.equal(status('b_race'), 'nichterschienen')
})
test('fuenfMinutenJob: Anfrage mit Beginn in der Vergangenheit ⇒ abgelehnt vom System, Zellen frei, keine Mail', async () => {
await legeBuchung('b_vergangen', { start: plus(JETZT, -MIN), status: 'angefragt', buchungsart: 'anfrage', bestaetigtAt: null })
await legeBuchung('b_genau', { start: JETZT, status: 'angefragt', buchungsart: 'anfrage', bestaetigtAt: null })
await legeBuchung('b_offen', { start: plus(JETZT, MIN), status: 'angefragt', buchungsart: 'anfrage', bestaetigtAt: null })
assert.ok(claims('b_vergangen').length > 0)
const erg = await fuenfMinutenJob(JETZT)
assert.equal(erg.abgelehnt, 1)
const b = buchung('b_vergangen')
assert.equal(b.status, 'abgelehnt')
assert.equal(b.abgesagtVon, 'system')
assert.equal(ms(b.updatedAt), JETZT.getTime())
assert.equal(claims('b_vergangen').length, 0)
assert.equal(status('b_genau'), 'angefragt', 'Beginn genau jetzt ist noch nicht vergangen')
assert.equal(status('b_offen'), 'angefragt')
assert.ok(claims('b_offen').length > 0)
await warteAufVersand()
assert.equal(sink.sent.length, 0)
assert.equal(eintraege().length, 0, 'auch kein Eintrag in der Warteschlange')
})
test('fuenfMinutenJob: wird die Anfrage zwischen Auflisten und Sperre angenommen, bleibt sie bestätigt', async () => {
const doc = await legeBuchung('b_race', { start: plus(JETZT, -MIN), status: 'angefragt', buchungsart: 'anfrage', bestaetigtAt: null })
await mitEinwurf(
listeVonBuchungen(/angefragt/),
async () => aw.seed('termineBookings', { ...doc, status: 'bestaetigt', angenommenAt: iso(JETZT) }),
() => fuenfMinutenJob(JETZT),
)
assert.equal(status('b_race'), 'bestaetigt')
assert.ok(claims('b_race').length > 0)
})
// ---------------------------------------------------------------- Verfall und Bestätigung gleichzeitig
/** Haltung mit echtem Bestaetigungstoken; heldUntil in 30 Minuten ab JETZT. */
async function haltung(id = 'b_halt') {
const { token, hash } = neuesToken()
await legeBuchung(id, { status: 'gehalten', heldUntil: iso(plus(JETZT, 30 * MIN)), verifyHash: hash, bestaetigtAt: null })
return { id, token, heldUntil: plus(JETZT, 30 * MIN) }
}
test('verfall_und_bestaetigung_gleichzeitig: Job zuerst (Bestätigung kommt mit veralteter Sicht) ⇒ verfallen, keine G02', async () => {
await richteFunktionEin()
const { id, token, heldUntil } = await haltung()
// bestaetige hat die Buchung gefunden (gehalten) – bevor es die Sperre bekommt, läuft der Minutenjob zu Ende
const ergebnis = await mitEinwurf(
listeVonBuchungen(/verifyHash/),
async () => assert.equal((await minutenJob(heldUntil)).verfallen, 1),
() => bestaetige(token, plus(heldUntil, -MIN)).then((r) => ({ r }), (err) => ({ err })),
)
await warteAufVersand()
assert.equal(ergebnis.err?.status, 410)
assert.equal(ergebnis.err?.code, 'abgelaufen')
assert.equal(status(id), 'verfallen')
assert.equal(claims(id).length, 0)
assert.equal(sink.sent.length, 0, 'keine G02 und keine K20 nach verfallen')
assert.equal(eintraege().length, 0)
})
test('verfall_und_bestaetigung_gleichzeitig: Bestätigung zuerst (Job mit veralteter Liste) ⇒ bestätigt bleibt bestätigt, genau eine G02', async () => {
await richteFunktionEin()
const { id, token, heldUntil } = await haltung()
const lauf = await mitEinwurf(
listeVonBuchungen(/heldUntil/),
async () => {
const erg = await bestaetige(token, plus(heldUntil, -MIN))
assert.equal(erg.status, 'bestaetigt')
},
() => minutenJob(heldUntil),
)
await warteAufVersand()
assert.equal(lauf.verfallen, 0, 'der Job sah die Buchung in der Liste, aber unter der Sperre ist sie bestätigt')
assert.equal(status(id), 'bestaetigt')
assert.ok(claims(id).length > 0, 'Zellen bleiben')
assert.equal(sink.sent.filter((m) => /Ihr Termin bei/.test(m.subject)).length, 1, 'genau eine G02')
})
test('verfall_und_bestaetigung_gleichzeitig: parallel gestartet ⇒ immer genau ein Endzustand, G02 nur bei bestätigt', async () => {
await richteFunktionEin()
for (const versatz of [0, 1, 2, 5, 10, 20]) {
const { id, token, heldUntil } = await haltung(`b_par${versatz}`)
const [job, klick] = await Promise.allSettled([
(async () => {
for (let i = 0; i < versatz; i++) await new Promise((resolve) => setImmediate(resolve))
return minutenJob(heldUntil)
})(),
bestaetige(token, plus(heldUntil, -MIN)),
])
await warteAufVersand()
assert.equal(job.status, 'fulfilled')
const end = status(id)
assert.ok(['bestaetigt', 'verfallen'].includes(end), `${id}: ${end}`)
assert.equal(klick.status === 'fulfilled', end === 'bestaetigt', `${id}: Antwort passt zum Endzustand`)
assert.equal(claims(id).length > 0, end === 'bestaetigt', `${id}: Zellen nur bei bestätigt`)
const mails = sink.sent.filter((m) => /Ihr Termin bei/.test(m.subject)).length
assert.equal(mails, end === 'bestaetigt' ? 1 : 0, `${id}: G02`)
sink.sent.length = 0
for (const eintrag of eintraege()) await deleteDocument('termineOutbox', eintrag.$id)
resetJobsForTests()
}
})
// ---------------------------------------------------------------- Löschfristen
test('wendeLoeschfristenAn: verfallen 48 Stunden nach heldUntil, abgesagt/abgelehnt 30 Tage nach der letzten Änderung', async () => {
await legeBuchung('b_v_alt', { status: 'verfallen', heldUntil: iso(plus(JETZT, -48 * STUNDE - 1)), start: plus(JETZT, TAG) })
await legeBuchung('b_v_jung', { status: 'verfallen', heldUntil: iso(plus(JETZT, -48 * STUNDE + 1)), start: plus(JETZT, TAG) })
await legeBuchung('b_s_alt', { status: 'abgesagt', updatedAt: iso(plus(JETZT, -30 * TAG - 1)) })
await legeBuchung('b_s_jung', { status: 'abgesagt', updatedAt: iso(plus(JETZT, -30 * TAG + 1)) })
await legeBuchung('b_l_alt', { status: 'abgelehnt', updatedAt: iso(plus(JETZT, -31 * TAG)) })
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, 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`)
}
for (const id of ['b_v_jung', 'b_s_jung', 'b_l_jung']) {
assert.ok(buchung(id), `${id} bleibt`)
assert.ok(claims(id).length > 0, `${id}: Zellen bleiben (nur der Tagesjob räumt verwaiste Zellen)`)
}
})
test('wendeLoeschfristenAn: erledigt/nichterschienen nach aufbewahrungTage der Funktion, ohne lesbare Einstellung 90', async () => {
await richteFunktionEin(F1, {}, { aufbewahrungTage: 30 })
await richteFunktionEin(F3, {}, { aufbewahrungTage: 365 })
// F2: Funktion ohne gespeicherte Einstellung ⇒ 90; F4: unlesbare Einstellung ⇒ 90 (beide Funktionen gibt es als portalFeatures-Dokument,
// die Aufbewahrung läuft je Funktion)
aw.seed('portalFeatures', feature(F2))
aw.seed('portalFeatures', feature('f_p_projekt04_termine'))
aw.seed('termineConfig', { $id: 'f_p_projekt04_termine', customerId: KUNDE, projectId: 'p4', einstellung: '{kaputt', version: 1, updatedAt: iso(JETZT), updatedBy: '' })
const faelle = [
// [id, Funktion, Status, Tage seit Ende, gelöscht?]
['b_30_alt', F1, 'erledigt', 30, true],
['b_30_jung', F1, 'erledigt', 29, false],
['b_30_nicht_alt', F1, 'nichterschienen', 31, true],
['b_30_nicht_jung', F1, 'nichterschienen', 14, false],
['b_90_alt', F2, 'erledigt', 91, true],
['b_90_jung', F2, 'erledigt', 89, false],
['b_90_nicht', F2, 'nichterschienen', 91, true],
['b_kaputt_alt', 'f_p_projekt04_termine', 'erledigt', 91, true],
['b_kaputt_jung', 'f_p_projekt04_termine', 'erledigt', 89, false],
['b_365_alt', F3, 'erledigt', 366, true],
['b_365_jung', F3, 'erledigt', 200, false],
]
// Das Ende liegt eine Millisekunde hinter (gelöscht) bzw. vor (bleibt) der Frist
const ende = (tage, weg) => plus(JETZT, -tage * TAG + (weg ? -1 : 1))
for (const [id, featureId, st, tage, weg] of faelle) await legeBuchung(id, { featureId, status: st, ende: ende(tage, weg) })
// andere Status werden nie nach Frist gelöscht
await legeBuchung('b_fest', { status: 'bestaetigt', ende: ende(400, true) })
await legeBuchung('b_anfrage', { status: 'angefragt', ende: ende(400, true) })
await legeBuchung('b_halt', { status: 'gehalten', heldUntil: iso(plus(JETZT, MIN)), ende: ende(400, true) })
const erg = await wendeLoeschfristenAn(JETZT)
assert.equal(erg.erledigt + erg.nichterschienen, faelle.filter((f) => f[4]).length)
for (const [id, , , , weg] of faelle) {
assert.equal(buchung(id) === undefined, weg, id)
assert.equal(claims(id).length === 0, weg, `${id}: Zellen`)
}
for (const id of ['b_fest', 'b_anfrage', 'b_halt']) assert.ok(buchung(id), id)
})
test('wendeLoeschfristenAn: Fehler beim Lesen der Einstellung ⇒ diese Funktion bleibt bis zum nächsten Lauf unberührt', async () => {
await richteFunktionEin(F1, {}, { aufbewahrungTage: 365 })
await legeBuchung('b_zu_frueh', { status: 'erledigt', ende: plus(JETZT, -100 * TAG) }) // 365 Tage Frist, nicht 90
await legeBuchung('b_v_alt', { status: 'verfallen', heldUntil: iso(plus(JETZT, -3 * TAG)) })
aw.failNext({ collection: 'termineConfig', method: 'GET', status: 500, message: 'Nachbau kaputt' })
const erg = await wendeLoeschfristenAn(JETZT)
assert.ok(buchung('b_zu_frueh'), 'nie früher löschen als eingestellt, nur weil die Einstellung nicht lesbar war')
assert.equal(buchung('b_v_alt'), undefined, 'Status ohne Einstellungswert laufen weiter')
assert.equal(erg.verfallen, 1)
assert.ok(fehlerLog().length >= 1)
assert.equal((await wendeLoeschfristenAn(JETZT)).erledigt, 0, 'auch beim nächsten Lauf gilt die Einstellung (365)')
assert.ok(buchung('b_zu_frueh'))
})
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(/"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 (Buchung fehlt, Ende mehr als einen Tag vorbei: nur solche findet der Zellen-Sweep)
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, -3 * TAG), blockEnde: plus(JETZT, -3 * 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.equal(erg.geloescht, 5, 'geloescht = Summe aller Löschzähler, gedeckelt zählt nicht mit')
assert.match(tagesbericht(erg), /^\[termine\] Tagesjob: 5 Buchungen gelöscht \(verfallen 2, abgesagt 2, abgelehnt 1, erledigt 0, nichterschienen 0, abgeschaltete Funktionen 0\)/)
})
// ---------------------------------------------------------------- Aufbewahrung je Funktion, Gesamtdeckel, Sicherheitsnetz (A3b)
/** erledigt/nichterschienen einer Funktion: `ueber` Millisekunden hinter der Frist von `tage` Tagen (> 0 fällig, -1 = eine Millisekunde zu jung). */
const legeAufbewahrungsFall = (id, featureId, art, tage, ueber, over = {}) => legeBuchung(id, { featureId, status: art, ende: plus(JETZT, -tage * TAG - ueber), ...over })
const deckelWarnungen = () => warnLog().filter((zeile) => /deckel/i.test(zeile))
/** Ids der Buchungen, die es noch gibt (sortiert). */
const uebrigeIds = () => buchungen().map((b) => b.$id).sort()
test('wendeLoeschfristenAn: viele Betriebe – 60 Funktionen mit 14/90/365 Tagen ⇒ genau die 180 fälligen samt Zellen, jede Abfrage kurz', async () => {
assert.ok(config.termine.loeschDeckel >= 2000 && config.termine.loeschDeckelGesamt >= 20000, 'die Standard-Deckel liegen über den fälligen Buchungen, der Test setzt sie nicht')
const faellig = []
const jung = []
for (let j = 0; j < 60; j += 1) {
const featureId = `f_betrieb${nr(j)}`
const tage = [14, 90, 365][j % 3]
await richteFunktionEin(featureId, {}, { aufbewahrungTage: tage })
for (let k = 0; k < 6; k += 1) {
const id = `b_${nr(j)}_${k}`
const zuJung = k >= 3 // drei fällig, drei eine Millisekunde zu jung; Status gemischt
await legeAufbewahrungsFall(id, featureId, k % 2 === 0 ? 'erledigt' : 'nichterschienen', tage, zuJung ? -1 : 1)
;(zuJung ? jung : faellig).push(id)
}
}
assert.equal(faellig.length, 180)
const zellenProBuchung = claims(jung[0]).length
assert.ok(zellenProBuchung > 0)
const erg = await wendeLoeschfristenAn(JETZT)
assert.deepEqual(erg, { verfallen: 0, abgesagt: 0, abgelehnt: 0, erledigt: 120, nichterschienen: 60, gedeckelt: 0 })
for (const id of faellig) {
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(uebrigeIds(), [...jung].sort())
assert.ok(pruefeSeitenabfragen() >= 120, 'jede Funktion und jeder Status liest eine eigene kurze Seite (limit ≤ 100, ohne Gastfelder, kein $id-Seitenlauf)')
// die Funktionsliste liest nur die IDs
const funktionsListen = aw.listCalls('portalFeatures')
assert.ok(funktionsListen.length >= 1)
for (const { queries } of funktionsListen) assert.deepEqual(queries.find((q) => q.method === 'select')?.values, ['$id'])
assert.equal(fehlerLog().length, 0)
assert.equal(deckelWarnungen().length, 0)
})
test('wendeLoeschfristenAn: Deckel je Funktion – loeschDeckel 7 bei 20 Fälligen ⇒ 7 / 7 / 6, gedeckelt 1 / 1 / 0, Warnung ohne Kennungen', async () => {
config.termine.loeschDeckel = 7
await richteFunktionEin(F1, {}, { aufbewahrungTage: 14 })
for (let i = 0; i < 20; i += 1) await legeAufbewahrungsFall(`b_erl${nr(i)}`, F1, 'erledigt', 14, 1 + i * 1000) // i = 19 ist am längsten fällig
await legeAufbewahrungsFall('b_jung', F1, 'erledigt', 14, -1)
const zellenProBuchung = claims('b_erl000').length
const stimmt = () => {
assert.ok(buchung('b_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: 0, abgesagt: 0, abgelehnt: 0, erledigt: 7, nichterschienen: 0, gedeckelt: 1 })
assert.deepEqual(uebrigeIds(), ['b_jung', ...Array.from({ length: 13 }, (_, i) => `b_erl${nr(i)}`)].sort(), 'die sieben ältesten sind weg')
assert.equal(deckelWarnungen().length, 1)
assert.doesNotMatch(deckelWarnungen()[0], /\bb_|\bf_|c_kunde|Müller|Mustermann/, 'keine ID, kein Name in der Warnung')
stimmt()
log.warn.mock.resetCalls()
erg = await wendeLoeschfristenAn(JETZT)
assert.equal(erg.erledigt, 7)
assert.equal(erg.gedeckelt, 1)
assert.equal(buchungen().length, 1 + 6)
assert.equal(deckelWarnungen().length, 1)
stimmt()
log.warn.mock.resetCalls()
erg = await wendeLoeschfristenAn(JETZT)
assert.equal(erg.erledigt, 6)
assert.equal(erg.gedeckelt, 0, 'die letzten sechs passen unter den Deckel')
assert.deepEqual(uebrigeIds(), ['b_jung'])
assert.equal(deckelWarnungen().length, 0)
stimmt()
assert.equal(fehlerLog().length, 0)
})
test('wendeLoeschfristenAn: der Deckel einer Funktion gilt für erledigt und nichterschienen zusammen und zählt höchstens einmal', async () => {
config.termine.loeschDeckel = 7
await richteFunktionEin(F1, {}, { aufbewahrungTage: 14 })
for (let i = 0; i < 5; i += 1) await legeAufbewahrungsFall(`b_erl${nr(i)}`, F1, 'erledigt', 14, 1 + i)
for (let i = 0; i < 5; i += 1) await legeAufbewahrungsFall(`b_nic${nr(i)}`, F1, 'nichterschienen', 14, 1 + i)
let erg = await wendeLoeschfristenAn(JETZT)
assert.equal(erg.erledigt, 5)
assert.equal(erg.nichterschienen, 2, 'zusammen höchstens sieben')
assert.equal(erg.gedeckelt, 1)
assert.equal(buchungen().length, 3)
erg = await wendeLoeschfristenAn(JETZT)
assert.deepEqual([erg.erledigt, erg.nichterschienen, erg.gedeckelt], [0, 3, 0])
assert.equal(buchungen().length, 0)
// der erste Status füllt den Deckel genau und der zweite hat noch Fälliges: gedeckelt 1 (nicht 0, nicht 2)
for (let i = 0; i < 7; i += 1) await legeAufbewahrungsFall(`b_erl${nr(i)}`, F1, 'erledigt', 14, 1 + i)
for (let i = 0; i < 3; i += 1) await legeAufbewahrungsFall(`b_nic${nr(i)}`, F1, 'nichterschienen', 14, 1 + i)
erg = await wendeLoeschfristenAn(JETZT)
assert.deepEqual([erg.erledigt, erg.nichterschienen, erg.gedeckelt], [7, 0, 1])
assert.deepEqual(uebrigeIds(), ['b_nic000', 'b_nic001', 'b_nic002'])
// ... und der erste Status am Deckel zählt genau einmal, der zweite wird in dem Lauf nicht mehr angefasst
for (let i = 0; i < 9; i += 1) await legeAufbewahrungsFall(`b_erl${nr(i)}`, F1, 'erledigt', 14, 1 + i)
erg = await wendeLoeschfristenAn(JETZT)
assert.deepEqual([erg.erledigt, erg.nichterschienen, erg.gedeckelt], [7, 0, 1])
// ... und füllt der erste Status den Deckel genau und der zweite hat nichts Fälliges, ist der Lauf nicht gedeckelt
for (const b of buchungen()) await deleteDocument('termineBookings', b.$id)
for (let i = 0; i < 7; i += 1) await legeAufbewahrungsFall(`b_erx${nr(i)}`, F1, 'erledigt', 14, 1 + i)
await legeAufbewahrungsFall('b_nic_jung', F1, 'nichterschienen', 14, -1)
erg = await wendeLoeschfristenAn(JETZT)
assert.deepEqual([erg.erledigt, erg.nichterschienen, erg.gedeckelt], [7, 0, 0])
assert.deepEqual(uebrigeIds(), ['b_nic_jung'])
})
test('wendeLoeschfristenAn: der Deckel gilt je Funktion – A mit 50 Fälligen und B mit 2, loeschDeckel 5 ⇒ A 5, B 2 im ersten Lauf', async () => {
config.termine.loeschDeckel = 5
const A = 'f_aaaaaaaa'
const B = 'f_bbbbbbbb'
await richteFunktionEin(A, {}, { aufbewahrungTage: 14 })
await richteFunktionEin(B, {}, { aufbewahrungTage: 14 })
for (let i = 0; i < 50; i += 1) await legeAufbewahrungsFall(`b_a${nr(i)}`, A, 'erledigt', 14, 1 + i)
for (let i = 0; i < 2; i += 1) await legeAufbewahrungsFall(`b_b${nr(i)}`, B, 'nichterschienen', 14, 1 + i)
await legeAufbewahrungsFall('b_a_jung', A, 'erledigt', 14, -1)
await legeAufbewahrungsFall('b_b_jung', B, 'erledigt', 14, -1)
const erg = await wendeLoeschfristenAn(JETZT)
assert.equal(erg.erledigt, 5)
assert.equal(erg.nichterschienen, 2)
assert.equal(erg.gedeckelt, 1, 'nur A endet am Deckel')
assert.equal(buchungen().filter((b) => b.featureId === A).length, 45 + 1)
assert.equal(buchungen().filter((b) => b.featureId === B).length, 1, 'B ist fertig, nur die junge bleibt')
assert.ok(buchung('b_a_jung') && buchung('b_b_jung'), 'nie eine junge Buchung')
assert.equal(deckelWarnungen().length, 1)
})
test('wendeLoeschfristenAn: Gesamtdeckel je Lauf – loeschDeckel 5, loeschDeckelGesamt 6 ⇒ insgesamt 6, der Rest folgt im nächsten Lauf', async () => {
config.termine.loeschDeckel = 5
config.termine.loeschDeckelGesamt = 6
const A = 'f_aaaaaaaa'
const B = 'f_bbbbbbbb'
await richteFunktionEin(A, {}, { aufbewahrungTage: 14 })
await richteFunktionEin(B, {}, { aufbewahrungTage: 14 })
for (let i = 0; i < 50; i += 1) await legeAufbewahrungsFall(`b_a${nr(i)}`, A, 'erledigt', 14, 1 + i)
for (let i = 0; i < 2; i += 1) await legeAufbewahrungsFall(`b_b${nr(i)}`, B, 'erledigt', 14, 1 + i)
await legeAufbewahrungsFall('b_a_jung', A, 'erledigt', 14, -1)
await legeAufbewahrungsFall('b_b_jung', B, 'erledigt', 14, -1)
const imA = () => buchungen().filter((b) => b.featureId === A && b.$id !== 'b_a_jung').length
const imB = () => buchungen().filter((b) => b.featureId === B && b.$id !== 'b_b_jung').length
let erg = await wendeLoeschfristenAn(JETZT)
assert.equal(erg.erledigt + erg.nichterschienen, 6, 'insgesamt sechs')
assert.deepEqual([imA(), imB()], [45, 1], 'A bis zu seinem Deckel, B nur noch der Rest des Gesamtdeckels')
assert.equal(erg.gedeckelt, 2, 'A am Deckel der Funktion, B am Gesamtdeckel')
const warnungen = deckelWarnungen()
assert.equal(warnungen.length, 2)
assert.ok(warnungen.some((zeile) => /Gesamtdeckel/.test(zeile)))
for (const zeile of warnungen) assert.doesNotMatch(zeile, /\bb_|\bf_|c_kunde|Müller|Mustermann/, 'keine ID, kein Name')
log.warn.mock.resetCalls()
erg = await wendeLoeschfristenAn(JETZT)
assert.equal(erg.erledigt, 6, 'der Rest folgt im nächsten Lauf')
assert.deepEqual([imA(), imB()], [40, 0])
assert.equal(erg.gedeckelt, 1, 'B passt jetzt unter den Gesamtdeckel')
assert.ok(buchung('b_a_jung') && buchung('b_b_jung'), 'in keinem Lauf eine junge Buchung')
// mit dem Standard für die Funktion bremst nur der Gesamtdeckel, danach wird die nächste Funktion in dem Lauf nicht mehr angefasst
config.termine.loeschDeckel = DECKEL_VORHER
erg = await wendeLoeschfristenAn(JETZT)
assert.equal(erg.erledigt, 6)
assert.deepEqual([imA(), imB()], [34, 0])
assert.equal(erg.gedeckelt, 1)
})
test('wendeLoeschfristenAn: genau so viele Fällige wie der Gesamtdeckel ⇒ nicht gedeckelt; der Gesamtdeckel zählt alle Schritte des Laufs', async () => {
config.termine.loeschDeckelGesamt = 6
await richteFunktionEin('f_aaaaaaaa', {}, { aufbewahrungTage: 14 })
await richteFunktionEin('f_bbbbbbbb', {}, { aufbewahrungTage: 14 })
for (let i = 0; i < 3; i += 1) await legeAufbewahrungsFall(`b_a${nr(i)}`, 'f_aaaaaaaa', 'erledigt', 14, 1 + i)
for (let i = 0; i < 3; i += 1) await legeAufbewahrungsFall(`b_b${nr(i)}`, 'f_bbbbbbbb', 'erledigt', 14, 1 + i)
await legeAufbewahrungsFall('b_c_jung', 'f_bbbbbbbb', 'erledigt', 14, -1)
let erg = await wendeLoeschfristenAn(JETZT)
assert.equal(erg.erledigt, 6)
assert.equal(erg.gedeckelt, 0, 'der Gesamtdeckel ist genau erreicht, es bleibt nichts Fälliges')
assert.equal(deckelWarnungen().length, 0)
assert.deepEqual(uebrigeIds(), ['b_c_jung'])
// verfallen (Durchgang 1) zählt mit: 4 + 4 Fällige, Gesamtdeckel 6 ⇒ verfallen 4, erledigt 2
for (let i = 0; i < 4; i += 1) await legeLoeschFall(`b_ver${nr(i)}`, 'verfallen', 1 + i)
for (let i = 0; i < 4; i += 1) await legeAufbewahrungsFall(`b_e${nr(i)}`, 'f_aaaaaaaa', 'erledigt', 14, 1 + i)
erg = await wendeLoeschfristenAn(JETZT)
assert.deepEqual(erg, { verfallen: 4, abgesagt: 0, abgelehnt: 0, erledigt: 2, nichterschienen: 0, gedeckelt: 1 })
erg = await wendeLoeschfristenAn(JETZT)
assert.deepEqual([erg.erledigt, erg.gedeckelt], [2, 0])
assert.deepEqual(uebrigeIds(), ['b_c_jung'])
})
test('wendeLoeschfristenAn: ist der Gesamtdeckel genau aufgebraucht und die nächste Funktion hat noch Fälliges, wird das gezählt, nie still übersprungen', async () => {
config.termine.loeschDeckelGesamt = 6
await richteFunktionEin('f_aaaaaaaa', {}, { aufbewahrungTage: 14 })
await richteFunktionEin('f_bbbbbbbb', {}, { aufbewahrungTage: 14 })
for (let i = 0; i < 6; i += 1) await legeAufbewahrungsFall(`b_a${nr(i)}`, 'f_aaaaaaaa', 'erledigt', 14, 1 + i) // füllt den Gesamtdeckel genau
for (let i = 0; i < 2; i += 1) await legeAufbewahrungsFall(`b_b${nr(i)}`, 'f_bbbbbbbb', 'nichterschienen', 14, 1 + i)
let erg = await wendeLoeschfristenAn(JETZT)
assert.deepEqual([erg.erledigt, erg.nichterschienen, erg.gedeckelt], [6, 0, 1])
assert.deepEqual(uebrigeIds(), ['b_b000', 'b_b001'])
assert.equal(deckelWarnungen().length, 1)
assert.match(deckelWarnungen()[0], /Gesamtdeckel/)
erg = await wendeLoeschfristenAn(JETZT)
assert.deepEqual([erg.erledigt, erg.nichterschienen, erg.gedeckelt], [0, 2, 0])
assert.deepEqual(uebrigeIds(), [])
})
test('wendeLoeschfristenAn: zweiter Boden – ändert sich das Ende zwischen Liste und Sperre, bleibt die Buchung', async () => {
await richteFunktionEin(F1, {}, { aufbewahrungTage: 14 })
const doc = await legeAufbewahrungsFall('b_spaet', F1, 'erledigt', 14, 1000)
await legeAufbewahrungsFall('b_alt', F1, 'erledigt', 14, 2000)
let eingeworfen = false
await mitEinwurf(
listeVonBuchungen(/"erledigt"/),
async () => {
eingeworfen = true
aw.seed('termineBookings', { ...doc, endsAt: iso(plus(JETZT, -TAG)) }) // der Termin wurde verschoben: Ende liegt jetzt innerhalb der Frist
},
() => wendeLoeschfristenAn(JETZT),
)
assert.ok(eingeworfen)
assert.ok(buchung('b_spaet'), 'die Frist gilt ab dem frisch gelesenen Ende')
assert.ok(claims('b_spaet').length > 0)
assert.equal(buchung('b_alt'), undefined)
})
test('wendeLoeschfristenAn: Sicherheitsnetz – erledigt/nichterschienen ohne Funktionsdokument verschwinden nach 365 Tagen, nie früher', async () => {
await legeAufbewahrungsFall('b_waise_366', 'f_ohne_funktion', 'erledigt', 366, 0)
await legeAufbewahrungsFall('b_waise_nic', 'f_ohne_funktion', 'nichterschienen', 366, 0)
await legeAufbewahrungsFall('b_waise_200', 'f_ohne_funktion', 'erledigt', 200, 0) // dokumentierte Grenze: ohne Funktion bleibt sie bis 365 Tage
await legeAufbewahrungsFall('b_waise_grenze_alt', 'f_ohne_funktion', 'erledigt', 365, 1)
await legeAufbewahrungsFall('b_waise_grenze_jung', 'f_ohne_funktion', 'erledigt', 365, -1)
await legeAufbewahrungsFall('b_waise_fest', 'f_ohne_funktion', 'bestaetigt', 400, 1) // andere Status nie
const erg = await wendeLoeschfristenAn(JETZT)
assert.deepEqual(erg, { verfallen: 0, abgesagt: 0, abgelehnt: 0, erledigt: 2, nichterschienen: 1, gedeckelt: 0 })
assert.deepEqual(uebrigeIds(), ['b_waise_200', 'b_waise_fest', 'b_waise_grenze_jung'])
for (const id of ['b_waise_366', 'b_waise_nic', 'b_waise_grenze_alt']) assert.equal(claims(id).length, 0, `${id}: Zellen mit der Buchung weg`)
pruefeSeitenabfragen()
})
test('wendeLoeschfristenAn: Sicherheitsnetz – Funktion mit unlesbarer Einstellung: 100 Tage bleiben, 366 Tage sind weg', async () => {
await richteFunktionEin(F1, {}, { aufbewahrungTage: 365 })
await legeAufbewahrungsFall('b_f1_100', F1, 'erledigt', 100, 1)
await legeAufbewahrungsFall('b_f1_366', F1, 'erledigt', 366, 0)
aw.failNext({ collection: 'termineConfig', method: 'GET', status: 500, message: 'Nachbau kaputt' })
let erg = await wendeLoeschfristenAn(JETZT)
assert.equal(erg.erledigt, 1)
assert.deepEqual(uebrigeIds(), ['b_f1_100'], 'nie früher, nur weil die Einstellung nicht lesbar war')
assert.ok(fehlerLog().length >= 1)
erg = await wendeLoeschfristenAn(JETZT) // Einstellung wieder lesbar: 365 Tage
assert.equal(erg.erledigt, 0)
assert.ok(buchung('b_f1_100'))
})
test('wendeLoeschfristenAn: ist die Liste der Funktionen nicht lesbar, löscht das Sicherheitsnetz trotzdem erst nach 365 Tagen', async () => {
await richteFunktionEin(F1, {}, { aufbewahrungTage: 14 })
await legeAufbewahrungsFall('b_f1_100', F1, 'erledigt', 100, 1) // nach der Einstellung (14) fällig
await legeAufbewahrungsFall('b_f1_366', F1, 'erledigt', 366, 0)
aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau kaputt' })
let erg = await wendeLoeschfristenAn(JETZT)
assert.equal(erg.erledigt, 1)
assert.deepEqual(uebrigeIds(), ['b_f1_100'], 'ohne gelesene Funktion läuft nur das Netz von 365 Tagen')
assert.ok(fehlerLog().length >= 1)
erg = await wendeLoeschfristenAn(JETZT)
assert.equal(erg.erledigt, 1, 'am nächsten Tag gilt die Einstellung der Funktion wieder')
assert.deepEqual(uebrigeIds(), [])
})
test('taeglicherJob: gedeckelt zählt je Schritt; der Tagesbericht nennt „Löschläufe am Deckel“ nur dann, direkt nach „Mails nicht zugestellt“', async () => {
config.termine.loeschDeckel = 2
await richteFunktionEin(F1, {}, { aufbewahrungTage: 14 })
for (let i = 0; i < 5; i += 1) await legeAufbewahrungsFall(`b_erl${nr(i)}`, F1, 'erledigt', 14, 1 + i)
for (let i = 0; i < 3; i += 1) await legeLoeschFall(`b_ver${nr(i)}`, 'verfallen', 1 + i)
let erg = await taeglicherJob(JETZT)
assert.deepEqual(erg.loeschfristen, { verfallen: 2, abgesagt: 0, abgelehnt: 0, erledigt: 2, nichterschienen: 0 })
assert.deepEqual(erg.gedeckelt, { loeschfristen: 2, abgeschaltet: 0, zellen: 0 })
let zeile = tagesbericht(erg)
assert.match(zeile, /^\[termine\] Tagesjob: 4 Buchungen gelöscht \(/)
assert.match(zeile, /, 0 Mails nicht zugestellt, Löschläufe am Deckel: 2, Rest folgt an den nächsten Tagen, Entwürfe 0 gelöscht \(0 offen\), Abo-Abgleich 0 geprüft \/ 0 abgeschaltet \/ 0 wieder ein \/ 0 Mails \/ 1 ausgenommen, 0 Fehler, 1 aktive Betriebe$/)
assert.doesNotMatch(zeile, /\bb_|\bf_|@|Mustermann|Salon/)
config.termine.loeschDeckel = DECKEL_VORHER
erg = await taeglicherJob(JETZT)
assert.deepEqual(erg.gedeckelt, { loeschfristen: 0, abgeschaltet: 0, zellen: 0 })
zeile = tagesbericht(erg)
assert.doesNotMatch(zeile, /Deckel/, 'ohne Deckel bleibt die Zeile unverändert')
assert.match(zeile, /, 0 Mails nicht zugestellt, Entwürfe 0 gelöscht \(0 offen\), Abo-Abgleich 0 geprüft \/ 0 abgeschaltet \/ 0 wieder ein \/ 0 Mails \/ 1 ausgenommen, 0 Fehler, 1 aktive Betriebe$/)
})
test('tagesbericht: das Segment zählt die Schritte aller Bereiche und fehlt bei gedeckelt 0', () => {
const basis = {
festgefahren: 3,
loeschfristen: { verfallen: 1, abgesagt: 2, abgelehnt: 3, erledigt: 4, nichterschienen: 5 },
abgeschaltet: { funktionen: 1, buchungen: 6 },
aufgeraeumt: 7,
zellen: 8,
fehler: 9,
aktiveBetriebe: 12,
}
const ohne = tagesbericht({ ...basis, gedeckelt: { loeschfristen: 0, abgeschaltet: 0, zellen: 0 } })
assert.equal(
ohne,
'[termine] Tagesjob: 21 Buchungen gelöscht (verfallen 1, abgesagt 2, abgelehnt 3, erledigt 4, nichterschienen 5, abgeschaltete Funktionen 6), 1 Funktionen bereinigt, 8 Zellen, 7 Outbox, 3 Mails nicht zugestellt, 9 Fehler, 12 aktive Betriebe',
)
const mit = tagesbericht({ ...basis, gedeckelt: { loeschfristen: 2, abgeschaltet: 1, zellen: 1 } })
assert.equal(mit, ohne.replace(', 9 Fehler', ', Löschläufe am Deckel: 4, Rest folgt an den nächsten Tagen, 9 Fehler'))
})
test('loescheAbgeschalteteFunktionen: disabled länger als 30 Tage ⇒ Buchungen, Zellen und Einstellung weg, das Funktionsdokument danach auf off; alles andere bleibt', async () => {
const seit = (tage, zusatz = 0) => iso(plus(JETZT, -tage * TAG + zusatz))
await richteFunktionEin(F1, { state: 'disabled', disabledAt: seit(31) })
await richteFunktionEin(F2, { state: 'disabled', disabledAt: seit(30, 1) }) // noch knapp unter 30 Tagen
await richteFunktionEin(F3, { state: 'active', disabledAt: seit(90) }) // wieder eingeschaltet: alter Marker zählt nicht
await richteFunktionEin('f_p_projekt04_termine', { state: 'disabled', disabledAt: seit(90), featureKey: 'news' }) // andere Funktion
await richteFunktionEin('f_p_projekt05_termine', { state: 'disabled' }) // kein disabledAt
const funktionen = [F1, F2, F3, 'f_p_projekt04_termine', 'f_p_projekt05_termine']
const andere = funktionen.slice(1)
const vorher = Object.fromEntries(andere.map((id) => [id, structuredClone(aw.docs('portalFeatures').get(id))]))
for (const [i, featureId] of funktionen.entries()) {
await legeBuchung(`b_fest_${i}`, { featureId, status: 'bestaetigt' })
await legeBuchung(`b_alt_${i}`, { featureId, status: 'erledigt', ende: plus(JETZT, -2 * TAG) })
}
const abgeschaltet = ['b_fest_0', 'b_alt_0']
const erg = await loescheAbgeschalteteFunktionen(JETZT)
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`)
}
assert.equal(aw.docs('termineConfig').get(F1), undefined, 'Einstellung gelöscht')
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`)
// das Funktionsdokument selbst bleibt, steht danach aber auf off (der Entwurf einer Neubestellung); der siteKey und damit das eingebettete Snippet bleiben gültig
const doc = aw.docs('portalFeatures').get(F1)
assert.ok(doc, 'das Funktionsdokument selbst bleibt')
assert.equal(doc.state, 'off')
assert.equal(doc.enabled, false)
assert.equal(doc.unlockedByPurchase, false)
assert.equal(doc.purchaseStatus, 'zurueckgesetzt')
assert.equal(doc.zahlungMeldung, '')
assert.equal(doc.ticketId, '')
assert.equal(doc.activatedAt, null)
assert.equal(doc.disabledAt, null)
assert.equal(doc.disabledReason, null)
assert.equal(doc.aboEndeMailAt, null)
assert.equal(doc.adminNote, 'Daten gelöscht (Löschfrist abgelaufen)')
assert.equal(doc.siteKey, 'abcdefghijklmnop', 'siteKey unverändert')
for (const id of andere) assert.deepEqual(aw.docs('portalFeatures').get(id), vorher[id], `${id}: Dokument unverändert`)
// zweiter Lauf: nichts mehr zu löschen, die Funktion zählt nicht noch einmal; die Liste der abgeschalteten Terminplaner (dieselbe Abfrage wie im Job) führt F1 nicht mehr
assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 0, buchungen: 0, gedeckelt: 0 })
const abgeschalteteTermine = await listAllDocuments(config.collections.portalFeatures, [Query.equal('featureKey', 'termine'), Query.equal('state', 'disabled')])
assert.ok(abgeschalteteTermine.length > 0, 'die Abfrage liefert die übrigen abgeschalteten Terminplaner (F2, f_p_projekt05_termine)')
assert.ok(!abgeschalteteTermine.some((d) => d.$id === F1), 'F1 steht nicht mehr in der Liste der abgeschalteten Terminplaner')
assert.equal(aw.docs('portalFeatures').get(F1).state, 'off')
assert.equal(fehlerLog().length, 0)
})
test('loescheAbgeschalteteFunktionen: scheitert das Löschen einer Buchung, bleibt die Einstellung bis zum nächsten Lauf', async () => {
await richteFunktionEin(F1, { state: 'disabled', disabledAt: iso(plus(JETZT, -40 * TAG)) })
await legeBuchung('b_eins', { start: plus(JETZT, TAG) })
aw.failNext({ collection: 'termineBookings', method: 'DELETE', status: 500, message: 'Nachbau kaputt' })
await loescheAbgeschalteteFunktionen(JETZT)
assert.ok(buchung('b_eins'))
assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt, solange Buchungen übrig sind')
assert.equal(aw.docs('portalFeatures').get(F1).state, 'disabled', 'das Dokument bleibt disabled, solange Buchungen übrig sind')
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)
assert.equal(aw.docs('portalFeatures').get(F1).state, 'off', 'der nächste Lauf löscht und setzt zurück')
assert.equal(aw.docs('portalFeatures').get(F1).purchaseStatus, 'zurueckgesetzt')
})
test('loescheAbgeschalteteFunktionen: ein erneutes disable() (Doppelklick im WOMS, Kundenlöschung) verschiebt die 30 Tage nicht', async () => {
const seit = plus(JETZT, -31 * TAG)
await richteFunktionEin(F1, { state: 'disabled', disabledAt: iso(seit), disabledReason: 'abo_ende' })
await legeBuchung('b_fest', { start: plus(JETZT, TAG) })
await disable(F1, { by: 'Team' })
await disable(F1, { reason: 'kunde_geloescht', adminNote: 'Kunde gelöscht' })
const doc = aw.docs('portalFeatures').get(F1)
assert.equal(Date.parse(doc.disabledAt), seit.getTime(), 'disabledAt (Beginn der Frist) bleibt')
assert.equal(doc.disabledReason, 'abo_ende')
assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 1, buchungen: 1, gedeckelt: 0 }, 'die Löschung läuft weiter')
assert.equal(buchung('b_fest'), undefined)
assert.equal(aw.docs('termineConfig').get(F1), undefined, 'Einstellung gelöscht')
})
/** Eine abgeschaltete Funktion (seit 40 Tagen, Standard F1) mit n Buchungen `<praefix>000…`; mit zellen bekommt jede ihre Zellen. */
async function abgeschaltetMitBuchungen(n, { zellen = true, featureId = F1, praefix = 'b_ab', funktion = {} } = {}) {
await richteFunktionEin(featureId, { state: 'disabled', disabledAt: iso(plus(JETZT, -40 * TAG)), ...funktion })
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 })
/** 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, 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`)
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, gedeckelt: 0 })
assert.equal(buchungen().length, 10)
assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt')
assert.equal(aw.docs('portalFeatures').get(F1).state, 'active', 'die wieder eingeschaltete Funktion wird nicht zurückgesetzt')
})
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')
const doc = aw.docs('portalFeatures').get(F1)
assert.equal(doc.state, 'active', 'die wieder eingeschaltete Funktion wird nicht zurückgesetzt')
assert.notEqual(doc.purchaseStatus, 'zurueckgesetzt')
})
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, 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, { zurueckgesetzt = false } = {}) => {
assert.equal(buchungen().length, uebrig, 'Rest')
assert.equal(aw.docs('termineClaims').size, uebrig * zellenProBuchung, 'Zellen passen zum Rest')
if (zurueckgesetzt) return assert.deepEqual({ state: funktion().state, disabledAt: funktion().disabledAt }, { state: 'off', disabledAt: null }, 'erst der letzte Lauf setzt das Dokument zurück')
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, { zurueckgesetzt: true })
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)
})
// Löschung nach 30 Tagen (E8): frische Abo-Prüfung vor jedem unumkehrbaren Schritt, danach das Funktionsdokument auf off
/** Laufendes (oder mit `over` beendetes) Wartungs-Abo von KUNDE. */
const wartung = (stand = 'active', over = {}) => aw.seed('subscriptions', subscriptionDoc('sub_w', { ...wartungDoc, customerId: KUNDE, status: stand, ...over }))
/** Alle Anfragen an die Abo-Collections (Spiegel und Ereignisse), die der Abo-Baustein stellt. */
const ABO_ANFRAGEN = /\/collections\/(subscriptions|contractEvents)\//
/** Die Anfrage, die die Einstellung (termineConfig) löscht. */
const einstellungLoeschen = (url, methode) => methode === 'DELETE' && /\/collections\/termineConfig\/documents\//.test(url)
/** Terminplaner seit 31 Tagen abgeschaltet, mit allem, was der Kauf und die Einrichtung am Dokument hinterlassen haben. */
const eingerichtet = (over = {}) => ({
...BEZAHLT,
disabledAt: iso(plus(JETZT, -31 * TAG)),
activatedAt: iso(plus(JETZT, -200 * TAG)),
ticketId: 't_einrichtung',
zahlungMeldung: 'gemeldet',
aboEndeMailAt: iso(plus(JETZT, -31 * TAG)),
adminNote: 'Abgeschaltet: Wartungs-Abo beendet',
stripeSessionId: 'cs_test_alt',
invoiceUrl: 'https://invoice.stripe.example/alt',
...over,
})
test('Löschung nach 30 Tagen, Grund abo_ende: läuft das Wartungs-Abo wieder, wird nichts gelöscht (Warnung im Log, Dokument bleibt disabled)', async () => {
await abgeschaltetMitBuchungen(3, { funktion: eingerichtet({ disabledReason: 'abo_ende' }) })
wartung('active')
const erg = await loescheAbgeschalteteFunktionen(JETZT)
assert.deepEqual(erg, { funktionen: 0, buchungen: 0, gedeckelt: 0 })
assert.equal(buchungen().length, 3, 'Buchungen bleiben')
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')
const doc = aw.docs('portalFeatures').get(F1)
assert.equal(doc.state, 'disabled')
assert.equal(doc.disabledReason, 'abo_ende')
assert.ok(warnLog().some((zeile) => /Abo läuft wieder, Löschung abgebrochen/.test(zeile)), 'Warnung im Log')
assert.doesNotMatch(warnLog().join('\n'), /\bb_|f_p_|c_kunde|Müller/, 'keine Kennung im Log')
assert.equal(fehlerLog().length, 0, 'kein Fehler, kein Wurf')
})
test('Löschung nach 30 Tagen, Grund abo_ende: endet das Abo wirklich (beendet), wird gelöscht', async () => {
await abgeschaltetMitBuchungen(3, { funktion: eingerichtet({ disabledReason: 'abo_ende' }) })
wartung('canceled', { endedAt: iso(plus(JETZT, -31 * TAG)) })
assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 1, buchungen: 3, gedeckelt: 0 })
assert.equal(buchungen().length, 0)
assert.equal(aw.docs('termineConfig').get(F1), undefined)
assert.equal(aw.docs('portalFeatures').get(F1).state, 'off')
assert.equal(fehlerLog().length, 0)
})
for (const [name, grund] of [
['team', { disabledReason: 'team' }],
['kunde_geloescht', { disabledReason: 'kunde_geloescht' }],
['abo_ende_vor_einrichtung', { disabledReason: 'abo_ende_vor_einrichtung' }],
['Altbestand ohne Grund', {}],
]) {
test(`Löschung nach 30 Tagen, ${name}: gelöscht und zurückgesetzt, ohne eine einzige Abfrage auf das Abo (auch wenn es läuft)`, async () => {
await abgeschaltetMitBuchungen(3, { funktion: eingerichtet(grund) })
wartung('active')
let erg
const aboAnfragen = await zaehleAnfragen(async () => {
erg = await loescheAbgeschalteteFunktionen(JETZT)
}, ABO_ANFRAGEN)
assert.equal(aboAnfragen, 0, 'keine Abfrage auf subscriptions oder contractEvents')
assert.deepEqual(erg, { funktionen: 1, buchungen: 3, gedeckelt: 0 })
assert.equal(buchungen().length, 0)
assert.equal(aw.docs('termineClaims').size, 0)
assert.equal(aw.docs('termineConfig').get(F1), undefined)
const doc = aw.docs('portalFeatures').get(F1)
assert.equal(doc.state, 'off', 'das Zurücksetzen gilt für jeden Grund')
assert.equal(doc.purchaseStatus, 'zurueckgesetzt')
assert.equal(fehlerLog().length, 0)
})
}
/** Offener Zahlungsfall am abgeschalteten Dokument (siteFeatures.bucheSpaeteZahlung): bezahlt, Fall vermerkt, noch nicht ans Team gemeldet. */
const offenerFall = (over = {}) => eingerichtet({ disabledReason: 'abo_ende', zahlungMeldung: 'abo_ende', ...over })
const OFFENER_FALL_MARKER = ['unlockedByPurchase', 'purchaseStatus', 'ticketId', 'zahlungMeldung', 'disabledAt', 'disabledReason', 'aboEndeMailAt', 'activatedAt', 'adminNote']
test('Löschung nach 30 Tagen, R108: offener Zahlungsfall (zahlungMeldung abo_ende) ⇒ Buchungen und Einstellung weg, das Dokument bleibt disabled mit allen Markern; Warnung ohne Kennungen', async () => {
await abgeschaltetMitBuchungen(3, { funktion: offenerFall() })
const vorher = structuredClone(aw.docs('portalFeatures').get(F1))
const erg = await loescheAbgeschalteteFunktionen(JETZT)
assert.deepEqual(erg, { funktionen: 1, buchungen: 3, gedeckelt: 0 }, 'gelöscht wird wie bisher')
assert.equal(buchungen().length, 0)
assert.equal(aw.docs('termineClaims').size, 0)
assert.equal(aw.docs('termineConfig').get(F1), undefined, 'Einstellung gelöscht')
const doc = aw.docs('portalFeatures').get(F1)
assert.equal(doc.state, 'disabled', 'nicht zurückgesetzt')
assert.equal(doc.unlockedByPurchase, true)
assert.equal(doc.zahlungMeldung, 'abo_ende')
assert.equal(doc.ticketId, 't_einrichtung')
for (const feld of OFFENER_FALL_MARKER) assert.deepEqual(doc[feld], vorher[feld], `${feld} unberührt`)
assert.deepEqual({ ...doc, $updatedAt: undefined }, { ...vorher, $updatedAt: undefined }, 'das Dokument ist unverändert')
const warnungen = warnLog().filter((zeile) => /offenen Zahlungsfall/.test(zeile))
assert.equal(warnungen.length, 1, 'Warnung im Log')
assert.doesNotMatch(warnLog().join('\n'), /\bb_|f_p_|p_projekt|c_kunde|Müller|@/, 'keine Kennung, keine Personendaten im Log')
assert.equal(fehlerLog().length, 0, 'kein Wurf, kein Fehlereintrag')
// der nächste Tag: nichts mehr zu löschen, das Dokument bleibt sichtbar (weiter in der Liste), wieder nur die Warnung
assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 0, buchungen: 0, gedeckelt: 0 })
assert.equal(aw.docs('portalFeatures').get(F1).state, 'disabled')
assert.equal(warnLog().filter((zeile) => /offenen Zahlungsfall/.test(zeile)).length, 2)
const abgeschalteteTermine = await listAllDocuments(config.collections.portalFeatures, [Query.equal('featureKey', 'termine'), Query.equal('state', 'disabled')])
assert.ok(abgeschalteteTermine.some((d) => d.$id === F1), 'F1 steht weiter in der Liste der abgeschalteten Terminplaner')
// R108: ist der Fall gemeldet, setzt der nächste Lauf wie bisher auf off zurück (ein reines Zurücksetzen zählt nicht)
aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(F1), zahlungMeldung: 'gemeldet' })
log.warn.mock.resetCalls()
assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 0, buchungen: 0, gedeckelt: 0 })
const zurueck = aw.docs('portalFeatures').get(F1)
assert.equal(zurueck.state, 'off')
assert.equal(zurueck.enabled, false)
assert.equal(zurueck.unlockedByPurchase, false)
assert.equal(zurueck.purchaseStatus, 'zurueckgesetzt')
assert.equal(zurueck.zahlungMeldung, '')
assert.equal(zurueck.ticketId, '')
assert.equal(zurueck.disabledAt, null)
assert.equal(zurueck.disabledReason, null)
assert.equal(zurueck.siteKey, 'abcdefghijklmnop')
assert.equal(warnLog().length, 0)
assert.equal(fehlerLog().length, 0)
const danach = await listAllDocuments(config.collections.portalFeatures, [Query.equal('featureKey', 'termine'), Query.equal('state', 'disabled')])
assert.ok(!danach.some((d) => d.$id === F1), 'danach nicht mehr gelistet')
})
test('Löschung nach 30 Tagen, R108: der übersprungene Reset zählt im Tagesjob als Fehler, der nächste Lauf nach der Meldung nicht mehr', async () => {
await abgeschaltetMitBuchungen(2, { zellen: false, funktion: offenerFall() })
let erg = await taeglicherJob(JETZT)
assert.deepEqual(erg.abgeschaltet, { funktionen: 1, buchungen: 2 })
assert.equal(erg.fehler, 1, 'der offene Fall zählt im Lauf')
assert.equal(aw.docs('portalFeatures').get(F1).state, 'disabled')
assert.equal(aw.docs('portalFeatures').get(F1).zahlungMeldung, 'abo_ende')
aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(F1), zahlungMeldung: 'gemeldet' })
erg = await taeglicherJob(plus(JETZT, TAG))
assert.equal(erg.fehler, 0)
assert.equal(aw.docs('portalFeatures').get(F1).state, 'off')
})
for (const meldung of ['', 'gemeldet']) {
test(`Löschung nach 30 Tagen, R108: zahlungMeldung "${meldung}" ist kein offener Fall ⇒ zurückgesetzt wie bisher`, async () => {
await abgeschaltetMitBuchungen(1, { zellen: false, funktion: eingerichtet({ disabledReason: 'abo_ende', zahlungMeldung: meldung }) })
assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 1, buchungen: 1, gedeckelt: 0 })
assert.equal(aw.docs('portalFeatures').get(F1).state, 'off')
assert.equal(warnLog().length, 0)
assert.equal(fehlerLog().length, 0)
})
}
test('Löschung nach 30 Tagen: das zurückgesetzte Dokument behält siteKey sowie Bestell- und Rechnungsfelder, alles Übrige ist leer', async () => {
await abgeschaltetMitBuchungen(1, { zellen: false, funktion: eingerichtet({ disabledReason: 'abo_ende' }) })
const vorher = structuredClone(aw.docs('portalFeatures').get(F1))
await loescheAbgeschalteteFunktionen(JETZT)
const doc = aw.docs('portalFeatures').get(F1)
assert.deepEqual(
{ ...doc, $updatedAt: undefined },
{
...vorher,
$updatedAt: undefined,
state: 'off',
enabled: false,
unlockedByPurchase: false,
purchaseStatus: 'zurueckgesetzt',
zahlungMeldung: '',
ticketId: '',
activatedAt: null,
disabledAt: null,
disabledReason: null,
aboEndeMailAt: null,
adminNote: 'Daten gelöscht (Löschfrist abgelaufen)',
},
'genau diese Felder ändern sich; siteKey, stripeSessionId, invoiceUrl und alles andere bleiben',
)
assert.equal(doc.siteKey, 'abcdefghijklmnop')
assert.equal(doc.stripeSessionId, 'cs_test_alt')
})
test('Löschung nach 30 Tagen, Grund abo_ende: kommt das Abo erst nach der 50. Löschung zurück, endet die Löschung dort und die Einstellung bleibt', async () => {
await abgeschaltetMitBuchungen(60, { zellen: false, funktion: { disabledReason: 'abo_ende' } })
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) wartung('active')
}
return antwort
}
let erg
try {
erg = await loescheAbgeschalteteFunktionen(JETZT)
} finally {
globalThis.fetch = echt
}
assert.equal(geloescht, 50, 'nach der Rückkehr des Abos kommt keine Löschung mehr')
assert.deepEqual(erg, { funktionen: 1, buchungen: 50, gedeckelt: 0 })
assert.equal(buchungen().length, 10)
assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt')
assert.equal(aw.docs('portalFeatures').get(F1).state, 'disabled')
assert.ok(warnLog().some((zeile) => /Abo läuft wieder, Löschung abgebrochen/.test(zeile)))
assert.equal(fehlerLog().length, 0)
})
test('Löschung nach 30 Tagen, Grund abo_ende: kommt das Abo zurück, nachdem die letzte Buchung weg ist, bleibt die Einstellung (Prüfung vor dem Löschen der Einstellung)', async () => {
await abgeschaltetMitBuchungen(2, { zellen: false, funktion: { disabledReason: 'abo_ende' } })
let listen = 0
const leereSeite = (url, methode) => listeVonBuchungen(/featureId/)(url, methode) && (listen += 1) === 2
const erg = await mitEinwurf(leereSeite, async () => wartung('active'), () => loescheAbgeschalteteFunktionen(JETZT))
assert.deepEqual(erg, { funktionen: 1, buchungen: 2, gedeckelt: 0 })
assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt')
assert.equal(aw.docs('portalFeatures').get(F1).state, 'disabled', 'kein Zurücksetzen, solange die Einstellung da ist')
assert.ok(warnLog().some((zeile) => /Abo läuft wieder, Löschung abgebrochen/.test(zeile)))
assert.equal(fehlerLog().length, 0)
})
test('Löschung nach 30 Tagen, Grund abo_ende: Lesefehler auf subscriptions ⇒ nichts gelöscht, fehler gezählt; der nächste Lauf löscht', async () => {
await abgeschaltetMitBuchungen(3, { funktion: abgeschaltetBezahlt(40) })
// 1. Lesezugriff auf subscriptions = Abo-Abgleich (Vorauswahl), 2. = Prüfung der Löschung
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, message: 'Nachbau kaputt', after: 1 })
let erg = await taeglicherJob(JETZT)
assert.deepEqual(erg.aboAbgleich, { ...ABO_NULL, geprueft: 1 })
assert.deepEqual(erg.abgeschaltet, { funktionen: 0, buchungen: 0 })
assert.equal(erg.fehler, 1, 'der Lesefehler wird gezählt')
assert.equal(fehlerLog().filter((zeile) => /Abo vor dem Löschen nicht gelesen/.test(zeile)).length, 1)
assert.equal(buchungen().length, 3, 'im Zweifel wird nichts gelöscht')
assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt')
assert.equal(aw.docs('portalFeatures').get(F1).state, 'disabled')
erg = await taeglicherJob(JETZT)
assert.equal(erg.fehler, 0)
assert.deepEqual(erg.abgeschaltet, { funktionen: 1, buchungen: 3 })
assert.equal(aw.docs('termineConfig').get(F1), undefined)
assert.equal(aw.docs('portalFeatures').get(F1).state, 'off')
})
test('Zurücksetzen scheitert (PATCH auf portalFeatures) ⇒ Einstellung ist weg, Dokument noch disabled; der nächste Lauf setzt zurück, auch wenn das Abo inzwischen läuft', async () => {
await abgeschaltetMitBuchungen(2, { zellen: false, funktion: eingerichtet({ disabledReason: 'abo_ende' }) })
aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500, message: 'Nachbau kaputt' })
assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 1, buchungen: 2, gedeckelt: 0 })
assert.equal(aw.docs('termineConfig').get(F1), undefined, 'Einstellung ist weg')
assert.equal(aw.docs('portalFeatures').get(F1).state, 'disabled', 'das Zurücksetzen ist gescheitert')
assert.equal(fehlerLog().length, 1)
wartung('active') // jetzt ist nichts Unumkehrbares mehr offen: das Abo spielt keine Rolle
log.error.mock.resetCalls()
let erg
const aboAnfragen = await zaehleAnfragen(async () => {
erg = await loescheAbgeschalteteFunktionen(JETZT)
}, ABO_ANFRAGEN)
assert.deepEqual(erg, { funktionen: 0, buchungen: 0, gedeckelt: 0 }, 'ein reines Zurücksetzen zählt nicht')
assert.equal(aboAnfragen, 0, 'keine Abo-Prüfung')
const doc = aw.docs('portalFeatures').get(F1)
assert.equal(doc.state, 'off')
assert.equal(doc.purchaseStatus, 'zurueckgesetzt')
assert.equal(fehlerLog().length, 0)
})
test('Zurücksetzen: war die Einstellung schon weg (404) und gibt es keine Buchungen, setzt der Lauf nur das Dokument zurück – ohne Abo-Prüfung, auch bei laufendem Abo', async () => {
await abgeschaltetMitBuchungen(0, { funktion: eingerichtet({ disabledReason: 'abo_ende' }) })
await deleteDocument('termineConfig', F1)
wartung('active')
let erg
const aboAnfragen = await zaehleAnfragen(async () => {
erg = await loescheAbgeschalteteFunktionen(JETZT)
}, ABO_ANFRAGEN)
assert.deepEqual(erg, { funktionen: 0, buchungen: 0, gedeckelt: 0 })
assert.equal(aboAnfragen, 0, 'nichts Unumkehrbares mehr offen: keine Abo-Abfrage')
assert.equal(aw.docs('portalFeatures').get(F1).state, 'off')
assert.equal(aw.docs('portalFeatures').get(F1).purchaseStatus, 'zurueckgesetzt')
assert.equal(fehlerLog().length, 0)
})
test('Zurücksetzen: läuft unter der Sperre feature-admin:<projectId> – solange das Team sie hält, bleibt das Dokument disabled', async () => {
await abgeschaltetMitBuchungen(1, { zellen: false, funktion: eingerichtet({ disabledReason: 'team' }) })
let lauf
await withLocks([`feature-admin:${PROJEKT}`], async () => {
lauf = loescheAbgeschalteteFunktionen(JETZT)
for (let i = 0; i < 200 && aw.docs('termineConfig').get(F1); i += 1) await new Promise((fertig) => setTimeout(fertig, 10)) // bis die Einstellung weg ist
assert.equal(aw.docs('termineConfig').get(F1), undefined, 'Buchungen und Einstellung sind gelöscht')
await new Promise((fertig) => setTimeout(fertig, 100))
assert.equal(aw.docs('portalFeatures').get(F1).state, 'disabled', 'das Zurücksetzen wartet auf die Sperre')
})
assert.deepEqual(await lauf, { funktionen: 1, buchungen: 1, gedeckelt: 0 })
assert.equal(aw.docs('portalFeatures').get(F1).state, 'off')
})
for (const [was, einwurf] of [
['vom Team aktiviert', () => schalteEin()],
['frisch neu abgeschaltet (disabledAt jünger als 30 Tage)', () => aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(F1), disabledAt: iso(plus(JETZT, -TAG)) })],
]) {
test(`Zurücksetzen: wird das Dokument zwischen dem Löschen der Einstellung und dem Zurücksetzen ${was}, bleibt es so (frisch gelesen, nicht überschrieben)`, async () => {
await abgeschaltetMitBuchungen(1, { zellen: false, funktion: eingerichtet({ disabledReason: 'abo_ende' }) })
const erg = await mitEinwurf(einstellungLoeschen, async () => einwurf(), () => loescheAbgeschalteteFunktionen(JETZT))
assert.deepEqual(erg, { funktionen: 1, buchungen: 1, gedeckelt: 0 })
assert.equal(aw.docs('termineConfig').get(F1), undefined, 'die Einstellung war schon gelöscht')
const doc = aw.docs('portalFeatures').get(F1)
assert.notEqual(doc.state, 'off', 'nicht zurückgesetzt')
assert.equal(doc.unlockedByPurchase, true)
assert.equal(doc.purchaseStatus, 'paid')
assert.equal(doc.ticketId, 't_einrichtung')
assert.equal(doc.adminNote, 'Abgeschaltet: Wartungs-Abo beendet')
assert.equal(fehlerLog().length, 0)
})
}
test('Zurücksetzen: nach dem Zurücksetzen steht das Dokument auf off und ist wieder bestellbar (markOrdered: Test in Block B)', async () => {
await abgeschaltetMitBuchungen(1, { zellen: false, funktion: eingerichtet({ disabledReason: 'team' }) })
await loescheAbgeschalteteFunktionen(JETZT)
const { state, enabled } = aw.docs('portalFeatures').get(F1)
assert.deepEqual({ state, enabled }, { state: 'off', enabled: false })
})
// ---------------------------------------------------------------- Tagesjob
test('taeglicherJob: Löschfristen, Warteschlange, festgefahrene Mails, verwaiste Zellen und abgeschaltete Funktionen in einem Lauf', async () => {
await richteFunktionEin(F1)
await richteFunktionEin(F2, { state: 'disabled', disabledAt: iso(plus(JETZT, -45 * TAG)) })
await legeEntwurf(F3, 91) // Entwurf: Terminplaner im Zustand off, seit 91 Tagen unverändert
await legeBuchung('b_verfallen', { status: 'verfallen', heldUntil: iso(plus(JETZT, -3 * TAG)) })
await legeBuchung('b_erledigt', { status: 'erledigt', ende: plus(JETZT, -100 * TAG) })
await legeBuchung('b_zukunft', { start: plus(JETZT, 5 * TAG) })
await legeBuchung('b_abgeschaltet', { featureId: F2, start: plus(JETZT, 5 * TAG) })
// verwaiste Zelle: Buchung fehlt, Ende mehr als einen Tag vorbei (nur solche findet der Zellen-Sweep)
await belegeZellen({ featureId: F1, ressourceId: 'r_geist', bookingId: 'b_geist', start: plus(JETZT, -3 * TAG), blockEnde: plus(JETZT, -3 * TAG + 30 * MIN), now: JETZT })
legeEintrag('o_gesendet', { status: 'gesendet', an: '', html: '', text: '', naechsterVersuch: iso(plus(JETZT, -MIN)) })
legeEintrag('o_festgefahren', { createdAt: iso(plus(JETZT, -3 * TAG)) })
const erg = await taeglicherJob(JETZT)
assert.equal(erg.fehler, 0)
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.geloescht, 3, 'verfallen 1 + erledigt 1 + abgeschaltete Funktion 1')
assert.equal(erg.aktiveBetriebe, 1, 'F1 ist aktiv, F2 abgeschaltet')
assert.deepEqual(erg.aboAbgleich, { geprueft: 0, abgeschaltet: 0, wiederEin: 0, mails: 0, ausgenommen: 1, fehler: 0 }, 'F1 ist aktiv ohne Kauf (ausgenommen), F2 abgeschaltet ohne Grund (bleibt unberührt)')
// geprüft werden auch F2 (die Löschung hat es eben auf off zurückgesetzt: purchaseStatus zurueckgesetzt, bleibt) und F3 (Entwurf, weg)
assert.deepEqual(erg.entwuerfe, { geprueft: 2, geloescht: 1, uebrig: 0, fehler: 0 }, 'der Entwurf F3 ist nach 90 Tagen weg')
assert.equal(aw.docs('portalFeatures').get(F2).purchaseStatus, 'zurueckgesetzt', 'das zurückgesetzte Dokument F2 bleibt samt siteKey')
assert.equal(aw.docs('portalFeatures').get(F3), undefined)
assert.equal(aw.docs('termineConfig').get(F3), undefined)
assert.equal(erg.aufgeraeumt, 1)
assert.ok(erg.zellen >= 1)
assert.equal(buchung('b_verfallen'), undefined)
assert.equal(buchung('b_erledigt'), undefined)
assert.equal(buchung('b_abgeschaltet'), undefined)
assert.equal(aw.docs('termineConfig').get(F2), undefined)
assert.ok(buchung('b_zukunft'))
assert.ok(claims('b_zukunft').length > 0, 'Zellen einer gültigen Buchung bleiben')
assert.equal(claims('b_geist').length, 0, 'verwaiste Zelle geräumt')
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, Entwürfe 1 gelöscht (0 offen), Abo-Abgleich 0 geprüft / 0 abgeschaltet / 0 wieder ein / 0 Mails / 1 ausgenommen, 0 Fehler, 1 aktive Betriebe`,
)
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, Entwürfe 0 gelöscht \(0 offen\), Abo-Abgleich 0 geprüft \/ 0 abgeschaltet \/ 0 wieder ein \/ 0 Mails \/ 0 ausgenommen, 0 Fehler, \d+ aktive Betriebe$/)
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)
})
/** Alte, verwaiste Zellen (Buchung fehlt, Ende mehr als einen Tag vorbei), n Stück, die erste ist die älteste. */
function legeAlteZellen(n, praefix = 'c_alt') {
for (let i = 0; i < n; i += 1) {
const bis = plus(JETZT, -3 * TAG - (n - i) * 1000)
aw.seed('termineClaims', {
$id: `${praefix}${String(i).padStart(3, '0')}`,
featureId: F1,
ressourceId: `r_geist${i % 3}`,
bookingId: `b_geist${i}`,
zelle: iso(plus(bis, -5 * MIN)),
bis: iso(bis),
})
}
}
test('taeglicherJob: Zellen-Sweep am Deckel ⇒ gedeckelt.zellen zählt, zellen bleibt die Zahl der gelöschten Zellen, der Rest folgt an den nächsten Tagen', async () => {
legeAlteZellen(600)
config.termine.zellenDeckel = 250
let erg = await taeglicherJob(JETZT)
assert.equal(erg.zellen, 250)
assert.deepEqual(erg.gedeckelt, { loeschfristen: 0, abgeschaltet: 0, zellen: 1 })
assert.equal(erg.fehler, 0)
assert.equal(aw.docs('termineClaims').size, 350)
assert.equal(warnLog().filter((zeile) => /Deckel/.test(zeile)).length, 1)
assert.doesNotMatch(warnLog().join('\n'), /\bb_|\bf_|\br_|\bc_/, 'die Warnung nennt keine Kennungen')
const zeile = tagesbericht(erg)
assert.match(zeile, /, 250 Zellen, /)
assert.match(zeile, /Löschläufe am Deckel: 1, Rest folgt an den nächsten Tagen, Entwürfe 0 gelöscht \(0 offen\), Abo-Abgleich 0 geprüft \/ 0 abgeschaltet \/ 0 wieder ein \/ 0 Mails \/ 0 ausgenommen, 0 Fehler, 0 aktive Betriebe$/)
erg = await taeglicherJob(JETZT)
assert.equal(erg.zellen, 250)
assert.deepEqual(erg.gedeckelt, { loeschfristen: 0, abgeschaltet: 0, zellen: 1 })
assert.equal(aw.docs('termineClaims').size, 100)
log.warn.mock.resetCalls()
erg = await taeglicherJob(JETZT)
assert.equal(erg.zellen, 100)
assert.deepEqual(erg.gedeckelt, { loeschfristen: 0, abgeschaltet: 0, zellen: 0 })
assert.equal(aw.docs('termineClaims').size, 0)
assert.equal(warnLog().filter((zeile) => /Deckel/.test(zeile)).length, 0)
assert.doesNotMatch(tagesbericht(erg), /Deckel/)
})
test('taeglicherJob: Zellen-Sweep mit Standard-Deckel ⇒ zellen ist die Zahl, gedeckelt.zellen 0; nicht lesbare Zellen zählen als Fehler, die Zahlen bleiben Zahlen', async () => {
legeAlteZellen(130)
let erg = await taeglicherJob(JETZT)
assert.equal(erg.zellen, 130)
assert.deepEqual(erg.gedeckelt, { loeschfristen: 0, abgeschaltet: 0, zellen: 0 })
assert.equal(erg.fehler, 0)
legeAlteZellen(5)
aw.failNext({ collection: 'termineClaims', method: 'GET', status: 500, message: 'Nachbau kaputt' }) // die erste Seite des Sweeps
erg = await taeglicherJob(JETZT)
assert.equal(erg.zellen, 0)
assert.deepEqual(erg.gedeckelt, { loeschfristen: 0, abgeschaltet: 0, zellen: 0 })
assert.equal(erg.fehler, 1)
assert.equal(aw.docs('termineClaims').size, 5)
erg = await taeglicherJob(JETZT)
assert.equal(erg.zellen, 5)
assert.equal(erg.fehler, 0)
})
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: 2 }) // 1. GET = Funktionen der Löschfristen, 2. = Abo-Abgleich, 3. = 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, Entwürfe 0 gelöscht \(0 offen\), Abo-Abgleich 0 geprüft \/ 0 abgeschaltet \/ 0 wieder ein \/ 0 Mails \/ 0 ausgenommen, 1 Fehler, \d+ aktive Betriebe$/)
})
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('tagesbericht: „n aktive Betriebe“ steht ganz am Ende, hinter den Fehlern; ohne Zahl (null oder fehlt) fehlt das Segment', () => {
const basis = {
festgefahren: 0,
loeschfristen: { verfallen: 0, abgesagt: 0, abgelehnt: 0, erledigt: 0, nichterschienen: 0 },
gedeckelt: { loeschfristen: 1, abgeschaltet: 0, zellen: 0 },
abgeschaltet: { funktionen: 0, buchungen: 0 },
aufgeraeumt: 0,
zellen: 0,
fehler: 2,
}
assert.match(tagesbericht({ ...basis, aktiveBetriebe: 0 }), /, Löschläufe am Deckel: 1, Rest folgt an den nächsten Tagen, 2 Fehler, 0 aktive Betriebe$/)
assert.match(tagesbericht({ ...basis, aktiveBetriebe: 31 }), /, 2 Fehler, 31 aktive Betriebe$/)
for (const unbekannt of [null, undefined, 'viele', -1]) {
assert.match(tagesbericht({ ...basis, aktiveBetriebe: unbekannt }), /, 2 Fehler$/, String(unbekannt))
}
})
test('taeglicherJob: aktiveBetriebe zählt nur aktive Termine-Dokumente (kein abgeschalteter Betrieb, keine andere Funktion); die Zeile nennt die Zahl ohne Kennungen', async () => {
await richteFunktionEin(F1)
await richteFunktionEin(F2)
await richteFunktionEin(F3, { state: 'disabled', disabledAt: iso(plus(JETZT, -2 * TAG)) }) // erst 2 Tage abgeschaltet: bleibt, zählt nicht
aw.seed('portalFeatures', feature('f_p_projekt04_termine', { featureKey: 'news' }))
aw.seed('portalFeatures', feature('f_p_projekt05_termine', { state: 'ordered' }))
const erg = await taeglicherJob(JETZT)
assert.equal(erg.aktiveBetriebe, 2)
assert.equal(erg.fehler, 0)
const zeile = tagesbericht(erg)
assert.match(zeile, /, 0 Fehler, 2 aktive Betriebe$/)
assert.doesNotMatch(zeile, /\bb_|\bf_|\bp_|\bc_|@|Mustermann|Salon/)
})
test('taeglicherJob: ist die Zahl der aktiven Betriebe nicht lesbar, ist aktiveBetriebe null, der Fehler gezählt, der Rest des Laufs fertig und die Zeile ohne Segment', async () => {
await legeLoeschFall('b_ver000', 'verfallen', 1)
aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau kaputt', after: 4 }) // 1. GET = Funktionen der Löschfristen, 2. = Abo-Abgleich, 3. = abgeschaltete, 4. = Entwürfe, 5. = Zählung
const erg = await taeglicherJob(JETZT)
assert.equal(erg.aktiveBetriebe, null)
assert.equal(erg.fehler, 1)
assert.equal(erg.loeschfristen.verfallen, 1, 'die Löschung lief vorher')
assert.equal(erg.geloescht, 1)
assert.doesNotMatch(tagesbericht(erg), /aktive Betriebe/)
assert.ok(fehlerLog().some((zeile) => /Aktive Betriebe/.test(zeile) && /Nachbau kaputt/.test(zeile)), 'der Fehler steht im Log')
})
// ---------------------------------------------------------------- Tagesjob-Meldung ans Team (meldeTagesjob)
const team20 = () => sink.sent.filter((mail) => /^\[Terminplaner\]/.test(mail.subject))
test('taeglicherJob meldet am Ende: Fehler im Lauf ⇒ Team-Mail „Tagesjob mit Fehlern“, letzterTagesjob trägt nur Zählungen; ein sauberer Lauf meldet nichts', async () => {
assert.equal(letzterTagesjob(), null)
await legeLoeschFall('b_ver000', 'verfallen', 1)
aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau kaputt', after: 4 }) // die Zählung der aktiven Betriebe (5. GET: nach Löschfristen, Abo-Abgleich, abgeschaltete Funktionen, Entwürfe)
const erg = await taeglicherJob(JETZT)
assert.equal(erg.fehler, 1)
assert.equal(team20().length, 1)
assert.equal(team20()[0].subject, '[Terminplaner] Tagesjob mit Fehlern')
assert.match(team20()[0].text, /1 Fehler\b/)
assert.doesNotMatch(team20()[0].text, /Nachbau kaputt|\bb_|\bf_|@/, 'weder Fehlertext noch Kennungen noch Adressen in der Mail')
assert.deepEqual(letzterTagesjob(), { at: JETZT.toISOString(), fehler: 1, gedeckelt: 0, geloescht: 1 })
const spaeter = plus(JETZT, TAG)
const sauber = await taeglicherJob(spaeter)
assert.equal(sauber.fehler, 0)
assert.equal(team20().length, 1, 'ein sauberer Lauf schickt nichts')
assert.deepEqual(letzterTagesjob(), { at: spaeter.toISOString(), fehler: 0, gedeckelt: 0, geloescht: 0 })
})
test('taeglicherJob: Löschdeckel an 3 Tagesläufen in Folge ⇒ Team-Mail „Löschläufe am Deckel“; ein Lauf ohne Deckel beginnt die Folge neu', async () => {
config.termine.loeschDeckel = 2
for (let i = 0; i < 12; i += 1) await legeLoeschFall(`b_ver${nr(i)}`, 'verfallen', 1 + i)
assert.deepEqual((await taeglicherJob(plus(JETZT, 0 * TAG))).gedeckelt, { loeschfristen: 1, abgeschaltet: 0, zellen: 0 })
await taeglicherJob(plus(JETZT, 1 * TAG))
assert.equal(team20().length, 0, 'nach zwei Tagen noch nichts')
await taeglicherJob(plus(JETZT, 2 * TAG))
assert.equal(team20().length, 1)
assert.equal(team20()[0].subject, '[Terminplaner] Löschläufe am Deckel')
assert.match(team20()[0].text, /3 Tagesjobs in Folge/)
assert.equal(letzterTagesjob().gedeckelt, 1)
config.termine.loeschDeckel = DECKEL_VORHER
await taeglicherJob(plus(JETZT, 3 * TAG)) // räumt den Rest ohne Deckel
assert.equal(letzterTagesjob().gedeckelt, 0)
assert.equal(buchungen().length, 0)
config.termine.loeschDeckel = 2
for (let i = 0; i < 6; i += 1) await legeLoeschFall(`b_neu${nr(i)}`, 'verfallen', 1 + i)
await taeglicherJob(plus(JETZT, 4 * TAG))
await taeglicherJob(plus(JETZT, 5 * TAG))
assert.equal(team20().length, 1, 'zwei neue Deckeltage sind keine Folge von drei')
})
test('taeglicherJob: ab 20 aktiven Betrieben ⇒ Team-Mail „Viele aktive Betriebe“, bei 19 nicht', async () => {
for (let i = 1; i <= 19; i += 1) aw.seed('portalFeatures', feature(`f_p_betrieb${nr(i)}_termine`))
const erg19 = await taeglicherJob(JETZT)
assert.equal(erg19.aktiveBetriebe, 19)
assert.equal(team20().length, 0)
aw.seed('portalFeatures', feature('f_p_betrieb020_termine'))
const erg20 = await taeglicherJob(plus(JETZT, TAG))
assert.equal(erg20.aktiveBetriebe, 20)
assert.equal(team20().length, 1)
assert.equal(team20()[0].subject, '[Terminplaner] Viele aktive Betriebe')
assert.match(team20()[0].text, /20 aktive Betriebe/)
assert.match(tagesbericht(erg20), /, 0 Fehler, 20 aktive Betriebe$/)
})
test('taeglicherJob: bricht der Job unerwartet ab, erfährt das Team es (Vorfall tagesjob), der Fehler geht weiter an den Aufrufer und die Überlappungssperre ist frei', async () => {
Object.defineProperty(config.termine, 'loeschDeckel', {
configurable: true,
enumerable: true,
get() {
throw new Error('Unerwartet kaputt')
},
})
try {
await assert.rejects(() => taeglicherJob(JETZT), /Unerwartet kaputt/)
} finally {
Object.defineProperty(config.termine, 'loeschDeckel', { configurable: true, enumerable: true, writable: true, value: DECKEL_VORHER })
}
assert.equal(team20().length, 1)
assert.equal(team20()[0].subject, '[Terminplaner] Tagesjob mit Fehlern')
assert.match(team20()[0].text, /abgebrochen/)
assert.doesNotMatch(team20()[0].text, /Unerwartet kaputt/, 'der Fehlertext steht im Log des Aufrufers, nicht in der Mail')
assert.deepEqual(letzterTagesjob(), { at: JETZT.toISOString(), fehler: 1, gedeckelt: 0, geloescht: 0 })
assert.equal((await taeglicherJob(plus(JETZT, MIN))).uebersprungen, undefined, 'danach läuft der Job wieder')
})
test('taeglicherJob im Modus off und bei Überlappung: keine Tagesjob-Meldung, letzterTagesjob bleibt leer', async () => {
config.termine.mode = 'off'
assert.deepEqual(await taeglicherJob(JETZT), { aus: true })
assert.equal(letzterTagesjob(), null)
assert.equal(team20().length, 0)
})
test('taeglicherJob: festgefahrene Mail wird fehler und erst 48 Stunden später gelöscht (nicht im selben Lauf)', async () => {
legeEintrag('o_alt', { createdAt: iso(plus(JETZT, -49 * STUNDE)), naechsterVersuch: iso(plus(JETZT, -48 * STUNDE)) })
legeEintrag('o_neu', { createdAt: iso(plus(JETZT, -47 * STUNDE)), naechsterVersuch: iso(plus(JETZT, 10 * MIN)) })
await taeglicherJob(JETZT)
assert.equal(aw.docs('termineOutbox').get('o_alt').status, 'fehler')
assert.equal(ms(aw.docs('termineOutbox').get('o_alt').naechsterVersuch), JETZT.getTime() + 48 * STUNDE)
assert.equal(aw.docs('termineOutbox').get('o_neu').status, 'wartet')
await taeglicherJob(plus(JETZT, 47 * STUNDE))
assert.ok(aw.docs('termineOutbox').get('o_alt'), 'noch nicht')
const erg = await taeglicherJob(plus(JETZT, 48 * STUNDE))
assert.equal(erg.aufgeraeumt, 1)
assert.equal(aw.docs('termineOutbox').get('o_alt'), undefined)
})
// ---------------------------------------------------------------- Tagesjob: Abo-Abgleich (E7)
const K22_BETREFF = /^Dein Terminplaner wurde beendet/
const K23_BETREFF = /^Dein Terminplaner läuft wieder/
const kundenMails = (betreff) => sink.sent.filter((mail) => betreff.test(mail.subject))
/** Bezahlter, seit `tage` Tagen wegen des Abo-Endes abgeschalteter Terminplaner; K22 ging schon (Marker), der Abgleich holt nichts nach. */
const abgeschaltetBezahlt = (tage, over = {}) => ({
state: 'disabled',
enabled: false,
disabledAt: iso(plus(JETZT, -tage * TAG)),
disabledReason: 'abo_ende',
aboEndeMailAt: iso(plus(JETZT, -tage * TAG)),
...BEZAHLT,
...over,
})
const ABO_NULL = { geprueft: 0, abgeschaltet: 0, wiederEin: 0, mails: 0, ausgenommen: 0, fehler: 0 }
/** Der Abgleich liest die Funktionsliste mit notEqual state off; die Löschfristen, die Löschung und der Guard tun das nicht. */
const abgleichListe = (url, methode) => funktionsListe(url, methode) && /notEqual/.test(url)
test('taeglicherJob: der Abo-Abgleich steht im Ergebnis mit seinen Zählern; ein bezahlter Terminplaner ohne Abo wird abgeschaltet, ein Pilot-Dokument ausgenommen', async () => {
await richteFunktionEin(F1, BEZAHLT)
await richteFunktionEin(F2) // aktiv, nicht vom Kauf freigeschaltet
const erg = await taeglicherJob(JETZT)
assert.deepEqual(erg.aboAbgleich, { geprueft: 1, abgeschaltet: 1, wiederEin: 0, mails: 1, ausgenommen: 1, fehler: 0 })
assert.equal(erg.fehler, 0)
assert.equal(erg.eingeschraenkt, undefined, 'im Modus pilot läuft der volle Tagesjob')
assert.equal(aw.docs('portalFeatures').get(F1).state, 'disabled')
assert.equal(aw.docs('portalFeatures').get(F1).disabledReason, 'abo_ende')
assert.equal(aw.docs('portalFeatures').get(F2).state, 'active', 'das Pilot-Dokument bleibt')
assert.equal(kundenMails(K22_BETREFF).length, 1)
assert.match(tagesbericht(erg), /, 0 Mails nicht zugestellt, Entwürfe 0 gelöscht \(0 offen\), Abo-Abgleich 1 geprüft \/ 1 abgeschaltet \/ 0 wieder ein \/ 1 Mails \/ 1 ausgenommen, 0 Fehler, 1 aktive Betriebe$/)
})
test('taeglicherJob: der Abgleich läuft vor der Löschung – ein Terminplaner, dessen Abo heute zurückkehrt, wird nicht gelöscht', async () => {
await richteFunktionEin(F1, abgeschaltetBezahlt(40))
await legeBuchung('b_fest', { start: plus(JETZT, 5 * TAG) })
wartung('active')
const erg = await taeglicherJob(JETZT)
assert.deepEqual(erg.aboAbgleich, { ...ABO_NULL, geprueft: 1, wiederEin: 1, mails: 1 })
assert.deepEqual(erg.abgeschaltet, { funktionen: 0, buchungen: 0 }, 'nichts gelöscht')
assert.equal(erg.fehler, 0)
const doc = aw.docs('portalFeatures').get(F1)
assert.equal(doc.state, 'active')
assert.equal(doc.disabledAt, null)
assert.ok(buchung('b_fest'))
assert.ok(claims('b_fest').length > 0)
assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt')
assert.equal(kundenMails(K23_BETREFF).length, 1)
})
test('taeglicherJob: Kontrolle zur Reihenfolge – dieselbe Lage ohne laufendes Abo wird nach 30 Tagen gelöscht', async () => {
await richteFunktionEin(F1, abgeschaltetBezahlt(40))
await legeBuchung('b_fest', { start: plus(JETZT, 5 * TAG) })
wartung('canceled', { endedAt: iso(plus(JETZT, -40 * TAG)) })
const erg = await taeglicherJob(JETZT)
assert.deepEqual(erg.aboAbgleich, { ...ABO_NULL, geprueft: 1 })
assert.deepEqual(erg.abgeschaltet, { funktionen: 1, buchungen: 1 })
assert.equal(buchung('b_fest'), undefined)
assert.equal(aw.docs('termineConfig').get(F1), undefined)
assert.equal(kundenMails(K23_BETREFF).length, 0)
})
test('taeglicherJob: ist die Funktionsliste des Abgleichs nicht lesbar, läuft die Löschung weiter; der Fehler zählt einmal und geht ans Team', 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. = Abo-Abgleich
const erg = await taeglicherJob(JETZT)
assert.deepEqual(erg.aboAbgleich, { ...ABO_NULL, fehler: 1 })
assert.equal(erg.fehler, 1, 'der Fehler des Abgleichs steht in fehler (einmal)')
assert.deepEqual(erg.abgeschaltet, { funktionen: 1, buchungen: 3 }, 'die Löschung lief')
assert.equal(buchungen().length, 0)
assert.ok(fehlerLog().some((zeile) => /\[termine-abo\] Funktionen nicht lesbar/.test(zeile) && /Nachbau kaputt/.test(zeile)))
assert.equal(team20().length, 1)
assert.equal(team20()[0].subject, '[Terminplaner] Tagesjob mit Fehlern')
assert.match(tagesbericht(erg), /, 0 Mails nicht zugestellt, Entwürfe 0 gelöscht \(0 offen\), Abo-Abgleich 0 geprüft \/ 0 abgeschaltet \/ 0 wieder ein \/ 0 Mails \/ 0 ausgenommen, 1 Fehler, \d+ aktive Betriebe$/)
})
test('tagesbericht: die vollständige Zeile gleicht gegen das Literal (Abo-Abgleich vor den Fehlern, aktive Betriebe zuletzt), ohne Kennungen und Adressen', async () => {
const erg = await taeglicherJob(JETZT)
const zeile = tagesbericht(erg)
assert.equal(
zeile,
'[termine] Tagesjob: 0 Buchungen gelöscht (verfallen 0, abgesagt 0, abgelehnt 0, erledigt 0, nichterschienen 0, abgeschaltete Funktionen 0), 0 Funktionen bereinigt, 0 Zellen, 0 Outbox, 0 Mails nicht zugestellt, Entwürfe 0 gelöscht (0 offen), Abo-Abgleich 0 geprüft / 0 abgeschaltet / 0 wieder ein / 0 Mails / 0 ausgenommen, 0 Fehler, 0 aktive Betriebe',
)
assert.doesNotMatch(zeile, /\bb_|\bf_|@/)
})
test('tagesbericht: mit Löschdeckel steht dessen Segment zwischen „Mails nicht zugestellt“ und „Abo-Abgleich“; die Zahlen des Abgleichs erscheinen unverändert', () => {
const basis = {
festgefahren: 3,
loeschfristen: { verfallen: 1, abgesagt: 2, abgelehnt: 3, erledigt: 4, nichterschienen: 5 },
abgeschaltet: { funktionen: 1, buchungen: 6 },
aufgeraeumt: 7,
zellen: 8,
aboAbgleich: { geprueft: 5, abgeschaltet: 1, wiederEin: 2, mails: 3, ausgenommen: 4, fehler: 0 },
entwuerfe: { geprueft: 12, geloescht: 3, uebrig: 2, fehler: 0 },
fehler: 9,
aktiveBetriebe: 12,
}
const ohne = tagesbericht({ ...basis, gedeckelt: { loeschfristen: 0, abgeschaltet: 0, zellen: 0 } })
assert.equal(
ohne,
'[termine] Tagesjob: 21 Buchungen gelöscht (verfallen 1, abgesagt 2, abgelehnt 3, erledigt 4, nichterschienen 5, abgeschaltete Funktionen 6), 1 Funktionen bereinigt, 8 Zellen, 7 Outbox, 3 Mails nicht zugestellt, Entwürfe 3 gelöscht (2 offen), Abo-Abgleich 5 geprüft / 1 abgeschaltet / 2 wieder ein / 3 Mails / 4 ausgenommen, 9 Fehler, 12 aktive Betriebe',
)
const mit = tagesbericht({ ...basis, gedeckelt: { loeschfristen: 2, abgeschaltet: 1, zellen: 1 } })
assert.equal(
mit,
'[termine] Tagesjob: 21 Buchungen gelöscht (verfallen 1, abgesagt 2, abgelehnt 3, erledigt 4, nichterschienen 5, abgeschaltete Funktionen 6), 1 Funktionen bereinigt, 8 Zellen, 7 Outbox, 3 Mails nicht zugestellt, Löschläufe am Deckel: 4, Rest folgt an den nächsten Tagen, Entwürfe 3 gelöscht (2 offen), Abo-Abgleich 5 geprüft / 1 abgeschaltet / 2 wieder ein / 3 Mails / 4 ausgenommen, 9 Fehler, 12 aktive Betriebe',
)
assert.doesNotMatch(mit, /\bb_|\bf_|@/)
// ohne Zähler der Entwürfe (ein Ergebnis aus einem früheren Lauf) fehlt das Segment, die übrige Zeile bleibt gleich
assert.equal(tagesbericht({ ...basis, entwuerfe: undefined, gedeckelt: { loeschfristen: 0, abgeschaltet: 0, zellen: 0 } }), ohne.replace('Entwürfe 3 gelöscht (2 offen), ', ''))
// unbrauchbare Zähler zählen als 0
assert.match(tagesbericht({ ...basis, entwuerfe: { geloescht: 'x', uebrig: -1 }, gedeckelt: { loeschfristen: 0, abgeschaltet: 0, zellen: 0 } }), /Mails nicht zugestellt, Entwürfe 0 gelöscht \(0 offen\), Abo-Abgleich/)
})
// ---------------------------------------------------------------- Tagesjob: Entwürfe (C6)
/** Entwurf: Terminplaner im Zustand off und seine Einstellung, beide seit `tage` Tagen unverändert. */
async function legeEntwurf(id, tage, over = {}) {
const seit = iso(plus(JETZT, -tage * TAG))
aw.seed('portalFeatures', { ...feature(id, { state: 'off', enabled: false, siteKey: 'entwurfsitekey23', ...over }), $updatedAt: seit })
aw.seed('termineConfig', { $id: id, customerId: KUNDE, projectId: id === F1 ? PROJEKT : `p_${id}`, einstellung: '{}', version: 1, updatedAt: seit, updatedBy: 'kunde' })
}
test('taeglicherJob: Entwürfe nach 90 Tagen gelöscht (nach den abgeschalteten Funktionen), jüngere bleiben; Ergebnis und Berichtszeile tragen nur Zahlen', async () => {
await richteFunktionEin(F1)
await legeEntwurf(F2, 91)
await legeEntwurf(F3, 89)
const erg = await taeglicherJob(JETZT)
assert.deepEqual(erg.entwuerfe, { geprueft: 2, geloescht: 1, uebrig: 0, fehler: 0 })
assert.equal(erg.fehler, 0)
assert.equal(aw.docs('portalFeatures').get(F2), undefined)
assert.equal(aw.docs('termineConfig').get(F2), undefined)
assert.ok(aw.docs('portalFeatures').get(F3))
assert.ok(aw.docs('termineConfig').get(F3))
assert.ok(aw.docs('portalFeatures').get(F1), 'die aktive Funktion bleibt')
assert.equal(erg.geloescht, 0, 'geloescht zählt Buchungen, keine Entwürfe')
const zeile = tagesbericht(erg)
assert.match(zeile, /, 0 Mails nicht zugestellt, Entwürfe 1 gelöscht \(0 offen\), Abo-Abgleich /)
assert.doesNotMatch(zeile, /\bb_|\bf_|\bp_|\bc_|@|Mustermann|Salon|Müller|entwurfsitekey/)
})
test('taeglicherJob: Entwürfe am Deckel – 100 je Lauf, der Rest zählt als offen und folgt am nächsten Tag', async () => {
for (let i = 0; i < 130; i += 1) await legeEntwurf(`f_p_entwurf${String(100 + i)}_termine`, 120)
let erg = await taeglicherJob(JETZT)
assert.deepEqual(erg.entwuerfe, { geprueft: 130, geloescht: 100, uebrig: 30, fehler: 0 })
assert.match(tagesbericht(erg), /Entwürfe 100 gelöscht \(30 offen\), Abo-Abgleich /)
erg = await taeglicherJob(plus(JETZT, TAG))
assert.deepEqual(erg.entwuerfe, { geprueft: 30, geloescht: 30, uebrig: 0, fehler: 0 })
assert.equal(aw.docs('portalFeatures').size, 0)
assert.equal(aw.docs('termineConfig').size, 0)
})
test('taeglicherJob: Fehler beim Löschen eines Entwurfs zählen in fehler und in der Berichtszeile, die übrigen Schritte laufen weiter', async () => {
await legeEntwurf(F2, 120)
await legeBuchung('b_verfallen', { status: 'verfallen', heldUntil: iso(plus(JETZT, -3 * TAG)) })
aw.failNext({ collection: 'termineConfig', method: 'DELETE', status: 500, message: 'Nachbau kaputt' })
const erg = await taeglicherJob(JETZT)
assert.deepEqual(erg.entwuerfe, { geprueft: 1, geloescht: 0, uebrig: 0, fehler: 1 })
assert.equal(erg.fehler, 1, 'der Fehler steht im Ergebnis des Tagesjobs')
assert.equal(erg.loeschfristen.verfallen, 1, 'die Löschfristen liefen')
assert.ok(aw.docs('portalFeatures').get(F2), 'der Entwurf bleibt und folgt am nächsten Tag')
assert.match(tagesbericht(erg), /Entwürfe 0 gelöscht \(0 offen\), Abo-Abgleich .*, 1 Fehler, /)
assert.ok(fehlerLog().some((zeile) => /\[termine-entwuerfe\]/.test(zeile) && /Nachbau kaputt/.test(zeile)))
assert.equal(team20().length, 1, 'das Team erfährt von den Fehlern')
const folgetag = await taeglicherJob(plus(JETZT, TAG))
assert.deepEqual(folgetag.entwuerfe, { geprueft: 1, geloescht: 1, uebrig: 0, fehler: 0 })
assert.equal(aw.docs('portalFeatures').get(F2), undefined)
})
test('Ist die Liste der Entwürfe nicht lesbar, zählt der Schritt einen Fehler und die übrigen Schritte laufen', async () => {
await legeEntwurf(F2, 120)
await legeBuchung('b_verfallen', { status: 'verfallen', heldUntil: iso(plus(JETZT, -3 * TAG)) })
// GET auf portalFeatures: 1. Funktionen der Löschfristen, 2. Abo-Abgleich, 3. abgeschaltete Funktionen, 4. Entwürfe
aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau kaputt', after: 3 })
const erg = await taeglicherJob(JETZT)
assert.deepEqual(erg.entwuerfe, { geprueft: 0, geloescht: 0, uebrig: 0, fehler: 1 })
assert.equal(erg.fehler, 1)
assert.equal(erg.loeschfristen.verfallen, 1)
assert.ok(aw.docs('portalFeatures').get(F2))
})
// ---------------------------------------------------------------- Tagesjob bei TERMINE_ENABLED=off mit bezahlten Terminplanern (eingeschränkter Lauf)
test('Modus off, bezahlter Terminplaner abgeschaltet seit 31 Tagen: Buchungen, Zellen und Einstellung weg; das Ergebnis trägt alle Felder, die Meldung und Status lesen', async () => {
await richteFunktionEin(F1, abgeschaltetBezahlt(31))
await legeBuchung('b_fest', { start: plus(JETZT, 5 * TAG) })
assert.ok(claims('b_fest').length > 0)
config.termine.mode = 'off'
const erg = await taeglicherJob(JETZT)
assert.deepEqual(erg, {
eingeschraenkt: true,
aboAbgleich: { ...ABO_NULL, geprueft: 1 },
abgeschaltet: { funktionen: 1, buchungen: 1 },
gedeckelt: { loeschfristen: 0, abgeschaltet: 0, zellen: 0 },
geloescht: 1,
aktiveBetriebe: null,
fehler: 0,
})
assert.equal(buchung('b_fest'), undefined)
assert.equal(claims('b_fest').length, 0, 'Zellen frei')
assert.equal(aw.docs('termineConfig').get(F1), undefined, 'Einstellung gelöscht')
assert.equal(aw.docs('portalFeatures').get(F1).state, 'off', 'das Dokument steht danach auf off: der Guard findet keinen bezahlten Terminplaner mehr')
assert.equal(aw.docs('portalFeatures').get(F1).unlockedByPurchase, false)
// die Tagesjob-Meldung und die Statusroute lesen daraus Zahlen, nie undefined
assert.deepEqual(letzterTagesjob(), { at: JETZT.toISOString(), fehler: 0, gedeckelt: 0, geloescht: 1 })
const statusErg = await statusDaten(JETZT)
assert.deepEqual(statusErg.letzterTagesjob, { at: JETZT.toISOString(), fehler: 0, gedeckelt: 0, geloescht: 1 })
assert.ok(Object.values(statusErg.letzterTagesjob).every((wert) => wert !== undefined && !Number.isNaN(wert)))
const zeile = tagesbericht(erg)
assert.equal(
zeile,
'[termine] Tagesjob (Modus off, nur Abo-Abgleich und Löschung): 1 Buchungen gelöscht (abgeschaltete Funktionen 1), 1 Funktionen bereinigt, Abo-Abgleich 1 geprüft / 0 abgeschaltet / 0 wieder ein / 0 Mails / 0 ausgenommen, 0 Fehler',
)
assert.doesNotMatch(zeile, /\bb_|\bf_|\bp_|\bc_|@|aktive Betriebe/)
})
test('Modus off: der Entwurfs-Schritt gehört zum stillstehenden Teil – ein alter Entwurf bleibt, das Ergebnis trägt kein entwuerfe, die Zeile kein Entwürfe-Segment', async () => {
await richteFunktionEin(F1, abgeschaltetBezahlt(31)) // ein bezahlter Terminplaner hält den eingeschränkten Lauf am Laufen
await legeEntwurf(F2, 400)
config.termine.mode = 'off'
const erg = await taeglicherJob(JETZT)
assert.equal(erg.eingeschraenkt, true)
assert.equal('entwuerfe' in erg, false)
assert.ok(aw.docs('portalFeatures').get(F2), 'der Entwurf bleibt')
assert.ok(aw.docs('termineConfig').get(F2))
assert.doesNotMatch(tagesbericht(erg), /Entwürfe/)
// dieselbe Lage im Modus pilot: der Entwurf geht
config.termine.mode = 'pilot'
const pilot = await taeglicherJob(plus(JETZT, TAG))
assert.deepEqual(pilot.entwuerfe, { geprueft: 2, geloescht: 1, uebrig: 0, fehler: 0 }, 'F1 (vom eingeschränkten Lauf zurückgesetzt, bleibt) und F2 (Entwurf, weg)')
assert.equal(aw.docs('portalFeatures').get(F1).purchaseStatus, 'zurueckgesetzt')
assert.equal(aw.docs('portalFeatures').get(F2), undefined)
})
test('Modus off: am Löschdeckel zählt gedeckelt.abgeschaltet, letzterTagesjob und die Zeile nennen den Deckel (vor dem Abo-Abgleich), der Rest folgt am nächsten Tag', async () => {
config.termine.loeschDeckel = 2
await abgeschaltetMitBuchungen(5, { zellen: false })
aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(F1), ...BEZAHLT, disabledReason: 'abo_ende', aboEndeMailAt: iso(plus(JETZT, -39 * TAG)) })
config.termine.mode = 'off'
let erg = await taeglicherJob(JETZT)
assert.deepEqual(erg.abgeschaltet, { funktionen: 1, buchungen: 2 })
assert.deepEqual(erg.gedeckelt, { loeschfristen: 0, abgeschaltet: 1, zellen: 0 })
assert.equal(erg.geloescht, 2)
assert.deepEqual(letzterTagesjob(), { at: JETZT.toISOString(), fehler: 0, gedeckelt: 1, geloescht: 2 })
assert.equal(
tagesbericht(erg),
'[termine] Tagesjob (Modus off, nur Abo-Abgleich und Löschung): 2 Buchungen gelöscht (abgeschaltete Funktionen 2), 1 Funktionen bereinigt, Löschläufe am Deckel: 1, Rest folgt an den nächsten Tagen, Abo-Abgleich 1 geprüft / 0 abgeschaltet / 0 wieder ein / 0 Mails / 0 ausgenommen, 0 Fehler',
)
erg = await taeglicherJob(plus(JETZT, TAG))
assert.deepEqual(erg.abgeschaltet, { funktionen: 1, buchungen: 2 })
erg = await taeglicherJob(plus(JETZT, 2 * TAG))
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.equal(aw.docs('termineConfig').get(F1), undefined)
assert.equal(buchungen().length, 0)
})
test('Modus off, bezahlter aktiver Terminplaner ohne Abo: abgeschaltet, K22 geht raus; am Folgetag nichts mehr', async () => {
await richteFunktionEin(F1, BEZAHLT)
config.termine.mode = 'off'
const erg = await taeglicherJob(JETZT)
assert.equal(erg.eingeschraenkt, true)
assert.deepEqual(erg.aboAbgleich, { geprueft: 1, abgeschaltet: 1, wiederEin: 0, mails: 1, ausgenommen: 0, fehler: 0 })
assert.deepEqual(erg.abgeschaltet, { funktionen: 0, buchungen: 0 })
const doc = aw.docs('portalFeatures').get(F1)
assert.equal(doc.state, 'disabled')
assert.equal(doc.disabledReason, 'abo_ende')
assert.equal(kundenMails(K22_BETREFF).length, 1)
assert.deepEqual(kundenMails(K22_BETREFF)[0].to.map((a) => a.address), ['silke@example.com'])
const zweiter = await taeglicherJob(plus(JETZT, TAG))
assert.equal(zweiter.eingeschraenkt, true, 'das abgeschaltete bezahlte Dokument hält den Lauf am Laufen (bis zur Löschung)')
assert.deepEqual(zweiter.aboAbgleich, { ...ABO_NULL, geprueft: 1 })
assert.equal(kundenMails(K22_BETREFF).length, 1, 'keine zweite Mail')
})
test('Modus off: der eingeschränkte Lauf tut nur Abgleich und Löschung – keine Löschfristen der Buchungen, keine Outbox, keine Zellen', async () => {
await richteFunktionEin(F1, BEZAHLT)
wartung('active')
await richteFunktionEin(F2) // aktiv, nicht bezahlt: ausgenommen
await legeLoeschFall('b_verfallen', 'verfallen', 1, { featureId: F2 })
await legeLoeschFall('b_abgesagt', 'abgesagt', 1, { featureId: F2 })
await belegeZellen({ featureId: F1, ressourceId: 'r_geist', bookingId: 'b_geist', start: plus(JETZT, -3 * TAG), blockEnde: plus(JETZT, -3 * TAG + 30 * MIN), now: JETZT })
legeEintrag('o_festgefahren', { createdAt: iso(plus(JETZT, -3 * TAG)) })
legeEintrag('o_gesendet', { status: 'gesendet', an: '', html: '', text: '', naechsterVersuch: iso(plus(JETZT, -MIN)) })
config.termine.mode = 'off'
const erg = await taeglicherJob(JETZT)
assert.deepEqual(erg, {
eingeschraenkt: true,
aboAbgleich: { ...ABO_NULL, geprueft: 1, ausgenommen: 1 },
abgeschaltet: { funktionen: 0, buchungen: 0 },
gedeckelt: { loeschfristen: 0, abgeschaltet: 0, zellen: 0 },
geloescht: 0,
aktiveBetriebe: null,
fehler: 0,
})
assert.ok(buchung('b_verfallen'), 'die verfallene Buchung eines anderen Betriebs bleibt')
assert.ok(buchung('b_abgesagt'))
assert.ok(claims('b_geist').length > 0, 'kein Zellen-Sweep')
assert.equal(aw.docs('termineOutbox').get('o_festgefahren').status, 'wartet', 'festgefahrene Mails bleiben liegen')
assert.ok(aw.docs('termineOutbox').get('o_gesendet'), 'kein Aufräumen der Warteschlange')
assert.equal(aw.docs('portalFeatures').get(F1).state, 'active', 'das Abo läuft')
assert.equal(sink.sent.length, 0)
})
test('Modus off: ein Fehler im Abgleich hält die Löschung nicht auf, zählt einmal, steht in letzterTagesjob und geht als Vorfall ans Team', async () => {
await abgeschaltetMitBuchungen(2, { zellen: false })
aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(F1), ...BEZAHLT, disabledReason: 'abo_ende', aboEndeMailAt: iso(plus(JETZT, -39 * TAG)) })
config.termine.mode = 'off'
aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau kaputt', after: 1 }) // 1. GET = Guard, 2. = Abo-Abgleich
const erg = await taeglicherJob(JETZT)
assert.equal(erg.eingeschraenkt, true)
assert.deepEqual(erg.aboAbgleich, { ...ABO_NULL, fehler: 1 })
assert.equal(erg.fehler, 1)
assert.deepEqual(erg.abgeschaltet, { funktionen: 1, buchungen: 2 }, 'die Löschung lief')
assert.deepEqual(letzterTagesjob(), { at: JETZT.toISOString(), fehler: 1, gedeckelt: 0, geloescht: 2 })
assert.equal(team20().length, 1)
assert.equal(team20()[0].subject, '[Terminplaner] Tagesjob mit Fehlern')
})
test('Modus off: der eingeschränkte Lauf nutzt dieselbe Überlappungssperre wie der volle Tagesjob (zweiter paralleler Aufruf ⇒ übersprungen)', async () => {
await richteFunktionEin(F1, abgeschaltetBezahlt(40))
await legeBuchung('b_fest', { start: plus(JETZT, 5 * TAG) })
config.termine.mode = 'off'
const haenger = mitHaengern(abgleichListe)
try {
const langsam = taeglicherJob(JETZT)
await haenger.warteBisRuhig()
assert.ok(haenger.gehalten(), 'der Abgleich hängt')
assert.deepEqual(await taeglicherJob(plus(JETZT, MIN)), { uebersprungen: true })
assert.equal(warnLog().filter((zeile) => /taeglicherJob/.test(zeile) && /nicht fertig/.test(zeile)).length, 1)
assert.ok(buchung('b_fest'), 'der zweite Lauf hat nichts getan')
haenger.freigeben()
const erg = await langsam
assert.equal(erg.eingeschraenkt, true)
assert.deepEqual(erg.abgeschaltet, { funktionen: 1, buchungen: 1 })
} finally {
haenger.freigeben()
haenger.ende()
}
assert.equal((await taeglicherJob(plus(JETZT, 2 * MIN))).uebersprungen, undefined, 'danach ist die Sperre frei')
})
test('Überlappung: ein hängender Tagesjob hält Verfall und Mailversand des Minutenjobs nicht auf', async () => {
await richteFunktionEin(F1, BEZAHLT)
wartung('active')
await legeBuchung('b_vorbei', { status: 'gehalten', heldUntil: iso(plus(JETZT, -MIN)) })
legeEintrag('o_erst')
const haenger = mitHaengern(abgleichListe)
try {
const langsam = taeglicherJob(JETZT)
await haenger.warteBisRuhig()
assert.ok(haenger.gehalten(), 'der Tagesjob hängt im Abgleich')
const minute = await minutenJob(plus(JETZT, MIN))
assert.deepEqual(minute, { verfallen: 1, faellig: 1, gesendet: 1, fehler: 0 }, 'weder übersprungen noch Teile übersprungen')
assert.equal(status('b_vorbei'), 'verfallen')
assert.equal(aw.docs('termineOutbox').get('o_erst').status, 'gesendet')
assert.equal(warnLog().filter((zeile) => /nicht fertig/.test(zeile)).length, 0)
haenger.freigeben()
assert.equal((await langsam).fehler, 0)
} finally {
haenger.freigeben()
haenger.ende()
}
})
test('Überlappung: hängt der Verfall des Minutenjobs, läuft der Tagesjob trotzdem (und die Warteschlange des Minutenjobs ebenso)', async () => {
await richteFunktionEin(F1, BEZAHLT)
wartung('active')
await legeBuchung('b_vorbei', { status: 'gehalten', heldUntil: iso(plus(JETZT, -MIN)) })
const haenger = mitHaengern(listeVonBuchungen(/heldUntil/))
try {
const langsam = minutenJob(JETZT)
await haenger.warteBisRuhig()
assert.ok(haenger.gehalten(), 'der Verfall hängt')
const tag = await taeglicherJob(plus(JETZT, MIN))
assert.equal(tag.uebersprungen, undefined)
assert.deepEqual(tag.aboAbgleich, { ...ABO_NULL, geprueft: 1 })
assert.equal(tag.fehler, 0)
assert.equal(warnLog().filter((zeile) => /taeglicherJob/.test(zeile)).length, 0)
haenger.freigeben()
assert.equal((await langsam).verfallen, 1)
} finally {
haenger.freigeben()
haenger.ende()
}
})
// ---------------------------------------------------------------- Fehlende Collection, Überlappung
test('fehlende Collection: höchstens eine Warnung je Stunde und Job, kein Wurf', async () => {
await richteFunktionEin()
await mitFehlenderCollection('termineBookings', async () => {
for (let i = 0; i < 3; i++) {
assert.equal((await minutenJob(plus(JETZT, i * MIN))).verfallen, 0)
assert.ok((await fuenfMinutenJob(plus(JETZT, i * MIN))).fehler >= 1)
assert.ok((await taeglicherJob(plus(JETZT, i * MIN))).fehler >= 1)
}
assert.equal(warnLog().filter((zeile) => /minutenJob/.test(zeile)).length, 1)
assert.equal(warnLog().filter((zeile) => /fuenfMinutenJob/.test(zeile)).length, 1, 'ein Job meldet nicht je Schritt')
assert.equal(warnLog().filter((zeile) => /taeglicherJob/.test(zeile)).length, 1)
assert.equal(fehlerLog().length, 0, 'keine Fehlerzeilen für fehlende Collections')
await minutenJob(plus(JETZT, 59 * MIN))
assert.equal(warnLog().filter((zeile) => /minutenJob/.test(zeile)).length, 1, 'noch keine Stunde')
await minutenJob(plus(JETZT, 61 * MIN))
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 () => {
await legeBuchung('b_vorbei', { status: 'gehalten', heldUntil: iso(plus(JETZT, -MIN)) })
const erg = await mitFehlenderCollection('termineOutbox', () => minutenJob(JETZT))
assert.equal(erg.verfallen, 1)
assert.equal(erg.fehler, 1)
assert.equal(warnLog().length, 1)
})
test('überlappende Läufe: hängen beide Teile des Minutenjobs, werden die nächsten Takte übersprungen (je Teil einmal im Log), danach geht es normal weiter', async () => {
await legeBuchung('b_vorbei', { status: 'gehalten', heldUntil: iso(plus(JETZT, -MIN)) })
const haenger = mitHaengern(listeVonBuchungen(/heldUntil/), listeVonOutbox)
try {
const langsam = minutenJob(JETZT)
await haenger.warteBisRuhig()
assert.ok(haenger.gehalten(), 'Verfall und Mailversand hängen')
assert.deepEqual(await minutenJob(plus(JETZT, MIN)), { uebersprungen: true })
assert.deepEqual(await minutenJob(plus(JETZT, 2 * MIN)), { uebersprungen: true })
assert.equal(warnLog().filter((zeile) => /minutenJob \(Verfall\)/.test(zeile)).length, 1, 'Verfall nur einmal gemeldet')
assert.equal(warnLog().filter((zeile) => /minutenJob \(Mailversand\)/.test(zeile)).length, 1, 'Mailversand nur einmal gemeldet')
assert.equal(warnLog().filter((zeile) => /minutenJob/.test(zeile)).length, 2, 'sonst keine Warnung')
// andere Jobs sind davon nicht betroffen
assert.equal((await fuenfMinutenJob(JETZT)).uebersprungen, undefined)
haenger.freigeben()
assert.equal((await langsam).verfallen, 1)
} finally {
haenger.freigeben()
haenger.ende()
}
assert.deepEqual(await minutenJob(plus(JETZT, 3 * MIN)), { verfallen: 0, faellig: 0, gesendet: 0, fehler: 0 }, 'danach laufen beide Teile wieder')
})
test('überlappende Läufe: hängt der Verfall, versendet der nächste Takt trotzdem die Warteschlange (Überlappung einmal im Log)', async () => {
await legeBuchung('b_vorbei', { status: 'gehalten', heldUntil: iso(plus(JETZT, -MIN)) })
legeEintrag('o_erst')
const haenger = mitHaengern(listeVonBuchungen(/heldUntil/))
try {
const langsam = minutenJob(JETZT)
await haenger.warteBisRuhig()
assert.ok(haenger.gehalten(), 'der Verfall hängt')
// beide Teile starten gemeinsam: die Warteschlange ist abgearbeitet, obwohl der Verfall des ersten Takts noch hängt
assert.equal(aw.docs('termineOutbox').get('o_erst').status, 'gesendet')
assert.equal(status('b_vorbei'), 'gehalten')
legeEintrag('o_zweit')
const zweiter = await minutenJob(plus(JETZT, MIN))
assert.deepEqual(zweiter, { verfallen: 0, faellig: 1, gesendet: 1, fehler: 0, verfallUebersprungen: true })
assert.equal(aw.docs('termineOutbox').get('o_zweit').status, 'gesendet')
assert.equal(sink.sent.length, 2)
const dritter = await minutenJob(plus(JETZT, 2 * MIN))
assert.equal(dritter.verfallUebersprungen, true)
assert.equal(dritter.uebersprungen, undefined, 'nur ein Teil übersprungen: der Takt gilt nicht als übersprungen')
assert.equal(warnLog().filter((zeile) => /minutenJob \(Verfall\)/.test(zeile)).length, 1, 'die Überlappung steht einmal im Log')
assert.equal(warnLog().filter((zeile) => /minutenJob/.test(zeile)).length, 1, 'der Mailversand wurde nicht übersprungen')
haenger.freigeben()
assert.deepEqual(await langsam, { verfallen: 1, faellig: 1, gesendet: 1, fehler: 0 })
} finally {
haenger.freigeben()
haenger.ende()
}
assert.equal(status('b_vorbei'), 'verfallen')
assert.deepEqual(await minutenJob(plus(JETZT, 3 * MIN)), { verfallen: 0, faellig: 0, gesendet: 0, fehler: 0 })
})
test('überlappende Läufe: hängt der Mailversand, verfällt im nächsten Takt eine abgelaufene Haltung trotzdem (Überlappung einmal im Log)', async () => {
const haenger = mitHaengern(listeVonOutbox)
try {
const langsam = minutenJob(JETZT)
await haenger.warteBisRuhig()
assert.ok(haenger.gehalten(), 'der Mailversand hängt')
await legeBuchung('b_vorbei', { status: 'gehalten', heldUntil: iso(plus(JETZT, MIN)) })
assert.ok(claims('b_vorbei').length > 0)
const zweiter = await minutenJob(plus(JETZT, 2 * MIN))
assert.deepEqual(zweiter, { verfallen: 1, faellig: 0, gesendet: 0, fehler: 0, mailUebersprungen: true })
assert.equal(status('b_vorbei'), 'verfallen')
assert.equal(claims('b_vorbei').length, 0, 'Zellen frei')
const dritter = await minutenJob(plus(JETZT, 3 * MIN))
assert.equal(dritter.mailUebersprungen, true)
assert.equal(dritter.uebersprungen, undefined)
assert.equal(warnLog().filter((zeile) => /minutenJob \(Mailversand\)/.test(zeile)).length, 1, 'die Überlappung steht einmal im Log')
assert.equal(warnLog().filter((zeile) => /minutenJob/.test(zeile)).length, 1, 'der Verfall wurde nicht übersprungen')
haenger.freigeben()
assert.deepEqual(await langsam, { verfallen: 0, faellig: 0, gesendet: 0, fehler: 0 })
} finally {
haenger.freigeben()
haenger.ende()
}
assert.deepEqual(await minutenJob(plus(JETZT, 4 * MIN)), { verfallen: 0, faellig: 0, gesendet: 0, fehler: 0 })
})
test('ein Job, der mit einem Fehler endet, gibt die Überlappungssperren frei', async () => {
const echt = globalThis.fetch
globalThis.fetch = async () => {
throw new TypeError('fetch failed')
}
try {
const erg = await minutenJob(JETZT)
assert.equal(erg.fehler, 2, 'Verfall und Warteschlange scheitern (je Teil ein Fehler), der Job wirft nicht')
assert.deepEqual(
fehlerLog()
.map((zeile) => /^\[termine-jobs\] minutenJob: (Verfall|Warteschlange):/.exec(zeile)?.[1])
.filter(Boolean)
.sort(),
['Verfall', 'Warteschlange'],
'das Fehlerlog behält minutenJob als Jobnamen',
)
} finally {
globalThis.fetch = echt
}
assert.deepEqual(await minutenJob(JETZT), { verfallen: 0, faellig: 0, gesendet: 0, fehler: 0 }, 'beide Sperren sind frei')
assert.equal(warnLog().filter((zeile) => /nicht fertig/.test(zeile)).length, 0)
})
// ---------------------------------------------------------------- Einbindung
test('server/index.js: die drei Jobs laufen alle 60 s, alle 5 min und täglich (+180 s), mit unref und eigener Fehlerbehandlung', () => {
const index = readFileSync(new URL('../server/index.js', import.meta.url), 'utf8')
assert.match(index, /import \{[^}]*\bminutenJob\b[^}]*\} from '\.\/services\/termine\/jobs\.js'/)
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\\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')
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`)
}
}
})