fuehreAus nimmt { teil } und sperrt je job:teil; minutenJob startet Verfall
und Mailversand (Warteschlange plus Betriebspruefung) gemeinsam mit
Promise.all. Haengt ein Teil, laeuft der andere im naechsten Takt weiter
(verfallUebersprungen bzw. mailUebersprungen); uebersprungen gilt nur, wenn
beide Teile uebersprungen werden. Die Ueberlappungswarnung nennt den Teil,
das Fehlerlog behaelt minutenJob.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2152 lines
112 KiB
JavaScript
2152 lines
112 KiB
JavaScript
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'),
|
||
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 { deleteDocument } = 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 } = 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 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,
|
||
})
|
||
|
||
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', '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
|
||
|
||
test('Modus off: kein Job fragt Appwrite oder sendet, auch nicht bei fälligen Daten', async () => {
|
||
await richteFunktionEin()
|
||
await legeBuchung('b_abgelaufen', { status: 'gehalten', heldUntil: iso(plus(JETZT, -5 * MIN)) })
|
||
await mitErinnerung('b_erinnern', 23 * STUNDE)
|
||
legeEintrag('o_wartet')
|
||
config.termine.mode = 'off'
|
||
|
||
const n = await zaehleAnfragen(async () => {
|
||
for (const job of [minutenJob, fuenfMinutenJob, taeglicherJob]) assert.deepEqual(await job(JETZT), { aus: true })
|
||
})
|
||
assert.equal(n, 0, 'keine einzige Anfrage an Appwrite')
|
||
assert.equal(status('b_abgelaufen'), 'gehalten')
|
||
assert.equal(buchung('b_erinnern').erinnerungGesendetAt, undefined)
|
||
assert.equal(sink.sent.length, 0)
|
||
assert.equal(eintraege()[0].status, 'wartet')
|
||
})
|
||
|
||
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, 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, 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; 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']
|
||
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`)
|
||
assert.ok(aw.docs('portalFeatures').get(F1), 'das Funktionsdokument selbst bleibt')
|
||
assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 0, buchungen: 0, gedeckelt: 0 }, 'zweiter Lauf: nichts mehr zu löschen, die Funktion zählt nicht noch einmal')
|
||
})
|
||
|
||
test('loescheAbgeschalteteFunktionen: scheitert das Löschen einer Buchung, bleibt die Einstellung bis zum nächsten Lauf', async () => {
|
||
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.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 1, buchungen: 1, gedeckelt: 0 })
|
||
assert.equal(buchung('b_eins'), undefined)
|
||
assert.equal(aw.docs('termineConfig').get(F1), undefined)
|
||
})
|
||
|
||
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' } = {}) {
|
||
await richteFunktionEin(featureId, { state: 'disabled', disabledAt: iso(plus(JETZT, -40 * TAG)) })
|
||
for (let i = 0; i < n; i++) await legeBuchung(`${praefix}${String(i).padStart(3, '0')}`, { featureId, start: plus(JETZT, TAG + i * 2 * STUNDE), zellen })
|
||
}
|
||
/** Das Personal schaltet die Funktion wieder ein: siteFeatures.activate sperrt nicht und lässt disabledAt stehen. */
|
||
const schalteEin = () => aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(F1), state: 'active', enabled: true })
|
||
/** 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')
|
||
})
|
||
|
||
test('loescheAbgeschalteteFunktionen: wird die Funktion nach der letzten Buchung wieder eingeschaltet, bleibt die Einstellung', async () => {
|
||
await abgeschaltetMitBuchungen(2)
|
||
const erg = await mitEinwurf(buchungLoeschen, async () => schalteEin(), () => loescheAbgeschalteteFunktionen(JETZT))
|
||
// die erste Buchung ging noch vor dem Einschalten; die Funktion ist danach aktiv, ihre Einstellung muss bleiben
|
||
assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt')
|
||
assert.equal(erg.funktionen, 1)
|
||
assert.equal(erg.buchungen, 2, 'beide Buchungen waren in einem Block von 50, vor der nächsten Prüfung gelöscht')
|
||
})
|
||
|
||
test('loescheAbgeschalteteFunktionen: kann die Funktion vor dem Löschen nicht gelesen werden, wird nichts gelöscht (Fehler im Log)', async () => {
|
||
await abgeschaltetMitBuchungen(2)
|
||
aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau kaputt', after: 1 }) // 1. GET = Liste, 2. = Neulesen
|
||
const erg = await loescheAbgeschalteteFunktionen(JETZT)
|
||
assert.deepEqual(erg, { funktionen: 0, buchungen: 0, gedeckelt: 0 })
|
||
assert.equal(buchungen().length, 2)
|
||
assert.ok(aw.docs('termineConfig').get(F1))
|
||
assert.equal(fehlerLog().length, 1)
|
||
})
|
||
|
||
// Seitenweise Loeschung abgeschalteter Funktionen (A4): Seiten zu 100, Deckel loeschDeckel je Funktion, Einstellung erst nach leerer Seite
|
||
|
||
test('loescheAbgeschalteteFunktionen: seitenweise mit Deckel – 230 Buchungen, loeschDeckel 100 ⇒ 100 / 100 / 30, die Einstellung erst im dritten Lauf', async () => {
|
||
config.termine.loeschDeckel = 100
|
||
await abgeschaltetMitBuchungen(230)
|
||
const zellenProBuchung = claims('b_ab000').length
|
||
assert.ok(zellenProBuchung > 0)
|
||
const funktion = () => aw.docs('portalFeatures').get(F1)
|
||
const vorher = { state: funktion().state, disabledAt: funktion().disabledAt }
|
||
const stimmt = (uebrig) => {
|
||
assert.equal(buchungen().length, uebrig, 'Rest')
|
||
assert.equal(aw.docs('termineClaims').size, uebrig * zellenProBuchung, 'Zellen passen zum Rest')
|
||
assert.deepEqual({ state: funktion().state, disabledAt: funktion().disabledAt }, vorher, 'Zustand und disabledAt bleiben, die nächste Frist beginnt nicht neu')
|
||
}
|
||
|
||
let erg = await loescheAbgeschalteteFunktionen(JETZT)
|
||
assert.deepEqual(erg, { funktionen: 1, buchungen: 100, gedeckelt: 1 })
|
||
stimmt(130)
|
||
assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt')
|
||
assert.equal(deckelWarnungen().length, 1)
|
||
assert.doesNotMatch(deckelWarnungen()[0], /\bb_|f_p_|c_kunde|Müller|Mustermann/, 'keine ID, kein Name in der Warnung')
|
||
|
||
log.warn.mock.resetCalls()
|
||
erg = await loescheAbgeschalteteFunktionen(JETZT)
|
||
assert.deepEqual(erg, { funktionen: 1, buchungen: 100, gedeckelt: 1 }, 'Lauf 2: weitere 100')
|
||
stimmt(30)
|
||
assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt')
|
||
assert.equal(deckelWarnungen().length, 1)
|
||
|
||
log.warn.mock.resetCalls()
|
||
erg = await loescheAbgeschalteteFunktionen(JETZT)
|
||
assert.deepEqual(erg, { funktionen: 1, buchungen: 30, gedeckelt: 0 }, 'Lauf 3: die letzten 30')
|
||
stimmt(0)
|
||
assert.equal(aw.docs('termineConfig').get(F1), undefined, 'Einstellung erst jetzt gelöscht')
|
||
assert.equal(deckelWarnungen().length, 0)
|
||
assert.equal(fehlerLog().length, 0)
|
||
assert.equal(pruefeSeitenabfragen(), 6, 'je Lauf zwei Listenabfragen (limit ≤ 100, nur die Kennung), keine mit 500')
|
||
})
|
||
|
||
test('loescheAbgeschalteteFunktionen: der Deckel gilt je Funktion – A 150, B 10, loeschDeckel 100 ⇒ B im selben Lauf komplett samt Einstellung, A noch nicht', async () => {
|
||
config.termine.loeschDeckel = 100
|
||
await abgeschaltetMitBuchungen(150) // A = F1 steht in der Funktionsliste vor B
|
||
await abgeschaltetMitBuchungen(10, { featureId: F2, praefix: 'b_cd' })
|
||
const jeFunktion = (featureId) => buchungen().filter((b) => b.featureId === featureId).length
|
||
|
||
let erg = await loescheAbgeschalteteFunktionen(JETZT)
|
||
assert.deepEqual(erg, { funktionen: 2, buchungen: 110, gedeckelt: 1 })
|
||
assert.equal(jeFunktion(F1), 50, 'A: 100 gelöscht, der Rest folgt')
|
||
assert.ok(aw.docs('termineConfig').get(F1), 'A: Einstellung bleibt')
|
||
assert.equal(jeFunktion(F2), 0, 'B: im selben Lauf komplett')
|
||
assert.equal(aw.docs('termineConfig').get(F2), undefined, 'B: Einstellung gelöscht')
|
||
assert.equal(deckelWarnungen().length, 1, 'nur A endete am Deckel')
|
||
|
||
erg = await loescheAbgeschalteteFunktionen(JETZT)
|
||
assert.deepEqual(erg, { funktionen: 1, buchungen: 50, gedeckelt: 0 }, 'B zählt nicht noch einmal')
|
||
assert.equal(jeFunktion(F1), 0)
|
||
assert.equal(aw.docs('termineConfig').get(F1), undefined)
|
||
assert.equal(fehlerLog().length, 0)
|
||
})
|
||
|
||
test('loescheAbgeschalteteFunktionen: genau so viele Buchungen wie der Deckel ⇒ nicht gedeckelt, Einstellung im selben Lauf weg', async () => {
|
||
config.termine.loeschDeckel = 7
|
||
await abgeschaltetMitBuchungen(7, { zellen: false })
|
||
assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 1, buchungen: 7, gedeckelt: 0 })
|
||
assert.equal(aw.docs('termineConfig').get(F1), undefined)
|
||
assert.equal(deckelWarnungen().length, 0)
|
||
})
|
||
|
||
test('loescheAbgeschalteteFunktionen: ohne Buchungen geht nur die Einstellung weg (funktionen 1, buchungen 0)', async () => {
|
||
await abgeschaltetMitBuchungen(0)
|
||
assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 1, buchungen: 0, gedeckelt: 0 })
|
||
assert.equal(aw.docs('termineConfig').get(F1), undefined)
|
||
assert.equal(fehlerLog().length, 0)
|
||
})
|
||
|
||
test('loescheAbgeschalteteFunktionen: wird die Funktion nach der ersten Seite wieder eingeschaltet, folgt keine weitere Löschung (Einstellung bleibt, Log, kein Fehler)', async () => {
|
||
await abgeschaltetMitBuchungen(230, { zellen: false })
|
||
let listen = 0
|
||
const zweiteListe = (url, methode) => listeVonBuchungen(/featureId/)(url, methode) && (listen += 1) === 2
|
||
const erg = await mitEinwurf(zweiteListe, async () => schalteEin(), () => loescheAbgeschalteteFunktionen(JETZT))
|
||
assert.deepEqual(erg, { funktionen: 1, buchungen: 100, gedeckelt: 0 })
|
||
assert.equal(buchungen().length, 130, 'nach dem Einschalten kommt keine Löschung mehr')
|
||
assert.equal(aw.listCalls('termineBookings').length, 2, 'und keine weitere Seite wird gelesen')
|
||
assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt')
|
||
assert.equal(aw.docs('portalFeatures').get(F1).state, 'active')
|
||
assert.ok(warnLog().some((zeile) => /inzwischen wieder aktiv/.test(zeile)), 'Abbruch steht im Log')
|
||
assert.equal(fehlerLog().length, 0, 'kein Fehler, kein Wurf')
|
||
})
|
||
|
||
test('loescheAbgeschalteteFunktionen: wird die Funktion wieder eingeschaltet, während die zweite Seite gelesen wird, wird auch von ihr nichts gelöscht', async () => {
|
||
await abgeschaltetMitBuchungen(120, { zellen: false })
|
||
let listen = 0
|
||
// nach der Antwort auf die zweite Listenabfrage (20 Buchungen), vor der Prüfung der Seite
|
||
const zweiteListe = (url, methode) => listeVonBuchungen(/featureId/)(url, methode) && (listen += 1) === 2
|
||
const erg = await mitEinwurf(zweiteListe, async () => schalteEin(), () => loescheAbgeschalteteFunktionen(JETZT))
|
||
assert.deepEqual(erg, { funktionen: 1, buchungen: 100, gedeckelt: 0 })
|
||
assert.equal(buchungen().length, 20, 'die zweite Seite blieb unberührt')
|
||
assert.ok(aw.docs('termineConfig').get(F1))
|
||
})
|
||
|
||
test('loescheAbgeschalteteFunktionen: scheitert jedes Löschen, endet der Lauf nach einer Seite – Einstellung bleibt, Anfragen begrenzt, kein Wurf; der nächste Lauf holt alles nach', async () => {
|
||
await abgeschaltetMitBuchungen(230, { zellen: false })
|
||
let erg
|
||
const zaehler = await mitScheiternderLoeschung(
|
||
() => true,
|
||
async () => {
|
||
erg = await loescheAbgeschalteteFunktionen(JETZT)
|
||
},
|
||
)
|
||
assert.deepEqual(erg, { funktionen: 0, buchungen: 0, gedeckelt: 0 })
|
||
assert.equal(zaehler.versuche, 100, 'jede Buchung der einen Seite genau einmal versucht')
|
||
assert.equal(zaehler.listen, 1, 'eine Seite, dann Schluss')
|
||
assert.equal(buchungen().length, 230)
|
||
assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt')
|
||
assert.equal(aw.docs('portalFeatures').get(F1).state, 'disabled')
|
||
assert.equal(fehlerLog().length, 100, 'ein Fehler je Buchung, im Log')
|
||
pruefeSeitenabfragen()
|
||
|
||
log.error.mock.resetCalls()
|
||
assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 1, buchungen: 230, gedeckelt: 0 })
|
||
assert.equal(buchungen().length, 0)
|
||
assert.equal(aw.docs('termineConfig').get(F1), undefined)
|
||
assert.equal(fehlerLog().length, 0)
|
||
})
|
||
|
||
test('loescheAbgeschalteteFunktionen: einzelne Buchungen, die sich nicht löschen lassen, halten den Rest nicht auf; die Einstellung bleibt und es gibt keine Endlosschleife', async () => {
|
||
await abgeschaltetMitBuchungen(30, { zellen: false })
|
||
const kaputt = new Set(['b_ab000', 'b_ab001', 'b_ab002'])
|
||
let erg
|
||
const zaehler = await mitScheiternderLoeschung(
|
||
(id) => kaputt.has(id),
|
||
async () => {
|
||
erg = await loescheAbgeschalteteFunktionen(JETZT)
|
||
},
|
||
)
|
||
assert.deepEqual(erg, { funktionen: 1, buchungen: 27, gedeckelt: 0 })
|
||
assert.deepEqual(buchungen().map((b) => b.$id).sort(), [...kaputt])
|
||
assert.equal(zaehler.versuche, 3, 'jede kaputte Buchung nur einmal versucht')
|
||
assert.ok(zaehler.listen <= 3, `Listenabfragen: ${zaehler.listen}`)
|
||
assert.equal(fehlerLog().length, 3)
|
||
assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt, solange Buchungen übrig sind')
|
||
})
|
||
|
||
test('loescheAbgeschalteteFunktionen: scheitert das Lesen einer Seite, bleibt alles stehen (Fehler im Log, kein Wurf, Einstellung bleibt)', async () => {
|
||
await abgeschaltetMitBuchungen(120, { zellen: false })
|
||
// die erste Seite geht, das Neulesen danach scheitert (nur Listenabfragen zählen, nicht das Lesen einzelner Buchungen)
|
||
const echt = globalThis.fetch
|
||
let listen = 0
|
||
globalThis.fetch = async (url, optionen) => {
|
||
if (listeVonBuchungen(/featureId/)(String(url), optionen?.method ?? 'GET') && (listen += 1) === 2) {
|
||
return new Response(JSON.stringify({ message: 'Nachbau kaputt', code: 500 }), { status: 500, headers: { 'content-type': 'application/json' } })
|
||
}
|
||
return echt(url, optionen)
|
||
}
|
||
let erg
|
||
try {
|
||
erg = await loescheAbgeschalteteFunktionen(JETZT)
|
||
} finally {
|
||
globalThis.fetch = echt
|
||
}
|
||
assert.deepEqual(erg, { funktionen: 1, buchungen: 100, gedeckelt: 0 })
|
||
assert.equal(buchungen().length, 20)
|
||
assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung nur nach leerer Seite')
|
||
assert.equal(fehlerLog().length, 1)
|
||
assert.deepEqual(await loescheAbgeschalteteFunktionen(JETZT), { funktionen: 1, buchungen: 20, gedeckelt: 0 })
|
||
assert.equal(aw.docs('termineConfig').get(F1), undefined)
|
||
})
|
||
|
||
test('loescheAbgeschalteteFunktionen: wird die Funktion unmittelbar vor dem Löschen der Einstellung wieder eingeschaltet, bleibt die Einstellung (letzte Prüfung unter der Sperre)', async () => {
|
||
await abgeschaltetMitBuchungen(2, { zellen: false })
|
||
let listen = 0
|
||
// die zweite Listenabfrage ist die leere Seite: danach, vor dem Löschen der Einstellung, schaltet das Personal ein
|
||
const leereSeite = (url, methode) => listeVonBuchungen(/featureId/)(url, methode) && (listen += 1) === 2
|
||
const erg = await mitEinwurf(leereSeite, async () => schalteEin(), () => loescheAbgeschalteteFunktionen(JETZT))
|
||
assert.deepEqual(erg, { funktionen: 1, buchungen: 2, gedeckelt: 0 })
|
||
assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt')
|
||
assert.ok(warnLog().some((zeile) => /inzwischen wieder aktiv/.test(zeile)))
|
||
assert.equal(fehlerLog().length, 0)
|
||
})
|
||
|
||
// ---------------------------------------------------------------- Tagesjob
|
||
|
||
test('taeglicherJob: Löschfristen, Warteschlange, festgefahrene Mails, verwaiste Zellen und abgeschaltete Funktionen in einem Lauf', async () => {
|
||
await richteFunktionEin(F1)
|
||
await richteFunktionEin(F2, { state: 'disabled', disabledAt: iso(plus(JETZT, -45 * TAG)) })
|
||
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.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, 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, 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, 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: 1 }) // 1. GET = Funktionen der Löschfristen, 2. = abgeschaltete
|
||
const erg = await taeglicherJob(JETZT)
|
||
assert.deepEqual(erg.abgeschaltet, { funktionen: 0, buchungen: 0 })
|
||
assert.deepEqual(erg.gedeckelt, { loeschfristen: 0, abgeschaltet: 0, zellen: 0 })
|
||
assert.equal(erg.fehler, 1)
|
||
assert.equal(buchungen().length, 3)
|
||
assert.match(tagesbericht(erg), /, 0 Mails nicht zugestellt, 1 Fehler, \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: 2 }) // 1. GET = Funktionen der Löschfristen, 2. = abgeschaltete, 3. = 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: 2 })
|
||
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)
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 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`)
|
||
}
|
||
}
|
||
})
|