loeschKandidaten liest eine Seite (Standard 100, aelteste Bezugszeit zuerst, optional je Funktion) ohne Gastfelder; die Frist steht in der Abfrage. arbeiteSeiten (Seite lesen, bearbeiten, neu lesen) endet bei leerer Seite, am Deckel (config.termine.loeschDeckel, 2000 je Status und Lauf, Rest an den Folgetagen, gezaehlt als gedeckelt) oder bei einer Seite ohne Fortschritt. verfallen, abgesagt und abgelehnt laufen darueber, unter der Buchungssperre werden Status und Bezugszeit frisch geprueft. taeglicherJob liefert gedeckelt getrennt vom Loeschzaehler. Neue Indizes idx_status_ends und idx_status_updated auf termineBookings (scripts/termine-setup.mjs legt sie an). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1278 lines
64 KiB
JavaScript
1278 lines
64 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 { 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 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 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()
|
||
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.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)
|
||
|
||
/** 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: keine Einstellung gespeichert ⇒ 90; F4: unlesbare Einstellung ⇒ 90
|
||
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
|
||
legeEintrag('o_gesendet', { status: 'gesendet', an: '', html: '', text: '', naechsterVersuch: iso(plus(JETZT, -MIN)) })
|
||
await belegeZellen({ featureId: F1, ressourceId: 'r_geist', bookingId: 'b_geist', start: plus(JETZT, 2 * TAG), blockEnde: plus(JETZT, 2 * TAG + 30 * MIN), now: JETZT })
|
||
|
||
let erg
|
||
const zaehler = await mitScheiternderLoeschung(
|
||
() => true,
|
||
async () => {
|
||
erg = await taeglicherJob(JETZT)
|
||
},
|
||
)
|
||
assert.deepEqual(erg.loeschfristen, { verfallen: 0, abgesagt: 0, abgelehnt: 0, erledigt: 0, nichterschienen: 0 })
|
||
assert.deepEqual(erg.gedeckelt, { loeschfristen: 0, abgeschaltet: 0, zellen: 0 })
|
||
assert.equal(erg.fehler, 90, 'ein Fehler je Buchung, im Log')
|
||
assert.equal(fehlerLog().length, 90)
|
||
assert.equal(zaehler.versuche, 90, 'jede Buchung genau einmal versucht')
|
||
for (const art of ARTEN) assert.equal(listenFuer(art), 1, `${art}: eine Seite, dann Schluss`)
|
||
assert.equal(pruefeSeitenabfragen(loeschListen()), 5, 'drei Status plus die beiden unveränderten Schritte, je eine Seite')
|
||
assert.equal(erg.aufgeraeumt, 1, 'Warteschlange wurde trotzdem aufgeräumt')
|
||
assert.ok(erg.zellen >= 1, 'verwaiste Zellen wurden trotzdem geräumt')
|
||
assert.equal(claims('b_geist').length, 0)
|
||
assert.equal(buchungen().length, 91)
|
||
|
||
// nächster Lauf, Löschen geht wieder: alles Fällige weg, die junge bleibt
|
||
log.error.mock.resetCalls()
|
||
aw.resetListCalls()
|
||
const zweiter = await taeglicherJob(JETZT)
|
||
assert.deepEqual(zweiter.loeschfristen, { verfallen: 30, abgesagt: 30, abgelehnt: 30, erledigt: 0, nichterschienen: 0 })
|
||
assert.equal(zweiter.fehler, 0)
|
||
assert.deepEqual(buchungen().map((b) => b.$id), ['b_ver_jung'])
|
||
assert.equal(aw.docs('termineClaims').size, claims('b_ver_jung').length)
|
||
})
|
||
|
||
test('wendeLoeschfristenAn: einzelne Buchungen, die sich nicht löschen lassen, halten den Rest nicht auf und führen nicht in eine Endlosschleife', async () => {
|
||
// die drei ältesten stehen in jeder Liste vorn und scheitern immer
|
||
const kaputt = new Set(['b_ver027', 'b_ver028', 'b_ver029'])
|
||
for (let i = 0; i < 30; i += 1) await legeLoeschFall(`b_ver${nr(i)}`, 'verfallen', 1 + i * 1000)
|
||
|
||
const zaehler = await mitScheiternderLoeschung((id) => kaputt.has(id), async () => {
|
||
const erg = await wendeLoeschfristenAn(JETZT)
|
||
assert.equal(erg.verfallen, 27)
|
||
})
|
||
assert.deepEqual(buchungen().map((b) => b.$id).sort(), [...kaputt])
|
||
assert.equal(zaehler.versuche, 3, 'jede kaputte Buchung nur einmal versucht')
|
||
assert.equal(fehlerLog().length, 3)
|
||
assert.ok(listenFuer('verfallen') <= 3, 'eine Seite mit allem, eine mit dem Rest (nichts Neues darauf ⇒ Schluss)')
|
||
})
|
||
|
||
test('wendeLoeschfristenAn: ändert sich die Bezugszeit zwischen Liste und Sperre (abgesagt wird erneut geändert), bleibt die Buchung', async () => {
|
||
const doc = await legeLoeschFall('b_spaet', 'abgesagt', 1000)
|
||
await legeLoeschFall('b_alt', 'abgesagt', 2000)
|
||
let eingeworfen = false
|
||
await mitEinwurf(
|
||
listeVonBuchungen(/"abgesagt"/),
|
||
async () => {
|
||
eingeworfen = true
|
||
aw.seed('termineBookings', { ...doc, updatedAt: iso(JETZT) }) // Status bleibt abgesagt, die letzte Änderung ist neu
|
||
},
|
||
() => wendeLoeschfristenAn(JETZT),
|
||
)
|
||
assert.ok(eingeworfen)
|
||
assert.ok(buchung('b_spaet'), 'die 30 Tage laufen ab der neuen Änderung')
|
||
assert.ok(claims('b_spaet').length > 0)
|
||
assert.equal(buchung('b_alt'), undefined)
|
||
})
|
||
|
||
test('taeglicherJob: gedeckelt zählt je Schritt und verfälscht den Löschzähler im Tagesbericht nicht', async () => {
|
||
config.termine.loeschDeckel = 2
|
||
for (let i = 0; i < 5; i += 1) await legeLoeschFall(`b_ver${nr(i)}`, 'verfallen', 1 + i)
|
||
for (let i = 0; i < 5; i += 1) await legeLoeschFall(`b_abs${nr(i)}`, 'abgesagt', 1 + i)
|
||
await legeLoeschFall('b_abl000', 'abgelehnt', 1)
|
||
|
||
const erg = await taeglicherJob(JETZT)
|
||
assert.deepEqual(erg.loeschfristen, { verfallen: 2, abgesagt: 2, abgelehnt: 1, erledigt: 0, nichterschienen: 0 }, 'nur Löschzähler')
|
||
assert.deepEqual(erg.gedeckelt, { loeschfristen: 2, abgeschaltet: 0, zellen: 0 })
|
||
assert.match(tagesbericht(erg), /^\[termine\] Tagesjob: 5 Buchungen gelöscht \(verfallen 2, abgesagt 2, abgelehnt 1, erledigt 0, nichterschienen 0, abgeschaltete Funktionen 0\)/)
|
||
})
|
||
|
||
test('loescheAbgeschalteteFunktionen: disabled länger als 30 Tage ⇒ Buchungen, Zellen und Einstellung weg; alles andere bleibt', async () => {
|
||
const seit = (tage, zusatz = 0) => iso(plus(JETZT, -tage * TAG + zusatz))
|
||
await richteFunktionEin(F1, { state: 'disabled', disabledAt: seit(31) })
|
||
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 })
|
||
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 }, '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 })
|
||
assert.equal(buchung('b_eins'), undefined)
|
||
assert.equal(aw.docs('termineConfig').get(F1), undefined)
|
||
})
|
||
|
||
/** Eine abgeschaltete Funktion (seit 40 Tagen) mit n Buchungen; mit zellen bekommt jede ihre Zellen. */
|
||
async function abgeschaltetMitBuchungen(n, { zellen = true } = {}) {
|
||
await richteFunktionEin(F1, { state: 'disabled', disabledAt: iso(plus(JETZT, -40 * TAG)) })
|
||
for (let i = 0; i < n; i++) await legeBuchung(`b_ab${String(i).padStart(3, '0')}`, { start: plus(JETZT, TAG + i * 2 * STUNDE), zellen })
|
||
}
|
||
/** Das Personal schaltet die Funktion wieder ein: siteFeatures.activate sperrt nicht und lässt disabledAt stehen. */
|
||
const schalteEin = () => aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(F1), state: 'active', enabled: true })
|
||
/** Anfragen an die Buchungsliste einer Funktion bzw. an die Funktionsliste */
|
||
const funktionsListe = (url, methode) => methode === 'GET' && /\/collections\/portalFeatures\/documents(\?|$)/.test(url)
|
||
const buchungLoeschen = (url, methode) => methode === 'DELETE' && /\/collections\/termineBookings\/documents\/b_ab/.test(url)
|
||
|
||
for (const [wann, passt] of [['zwischen Funktionsliste und Buchungsliste', funktionsListe], ['zwischen Buchungsliste und erster Löschung', listeVonBuchungen(/featureId/)]]) {
|
||
for (const [was, umschalten] of [
|
||
['wieder eingeschaltet (disabledAt bleibt alt)', schalteEin],
|
||
['frisch neu abgeschaltet', () => aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(F1), disabledAt: iso(plus(JETZT, -TAG)) })],
|
||
]) {
|
||
test(`loescheAbgeschalteteFunktionen: ${was} ${wann} ⇒ nichts wird gelöscht`, async () => {
|
||
await abgeschaltetMitBuchungen(3)
|
||
const erg = await mitEinwurf(passt, async () => umschalten(), () => loescheAbgeschalteteFunktionen(JETZT))
|
||
assert.deepEqual(erg, { funktionen: 0, buchungen: 0 })
|
||
assert.equal(buchungen().length, 3, 'alle Buchungen bleiben')
|
||
assert.equal(aw.docs('termineClaims').size > 0, true)
|
||
for (const b of buchungen()) assert.ok(claims(b.$id).length > 0, `${b.$id}: Zellen bleiben`)
|
||
assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt')
|
||
assert.ok(warnLog().some((zeile) => /inzwischen wieder aktiv oder neu abgeschaltet/.test(zeile)), 'Abbruch steht im Log')
|
||
assert.equal(fehlerLog().length, 0, 'kein Fehler, kein Wurf')
|
||
})
|
||
}
|
||
}
|
||
|
||
test('loescheAbgeschalteteFunktionen: wird die Funktion nach 50 Buchungen wieder eingeschaltet, endet die Löschung dort', async () => {
|
||
await abgeschaltetMitBuchungen(60, { zellen: false })
|
||
const echt = globalThis.fetch
|
||
let geloescht = 0
|
||
globalThis.fetch = async (url, optionen) => {
|
||
const antwort = await echt(url, optionen)
|
||
if (buchungLoeschen(String(url), optionen?.method ?? 'GET')) {
|
||
geloescht += 1
|
||
if (geloescht === 50) schalteEin()
|
||
}
|
||
return antwort
|
||
}
|
||
let erg
|
||
try {
|
||
erg = await loescheAbgeschalteteFunktionen(JETZT)
|
||
} finally {
|
||
globalThis.fetch = echt
|
||
}
|
||
assert.equal(geloescht, 50, 'nach dem Einschalten kommt keine Löschung mehr')
|
||
assert.deepEqual(erg, { funktionen: 1, buchungen: 50 })
|
||
assert.equal(buchungen().length, 10)
|
||
assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt')
|
||
})
|
||
|
||
test('loescheAbgeschalteteFunktionen: wird die Funktion nach der letzten Buchung wieder eingeschaltet, bleibt die Einstellung', async () => {
|
||
await abgeschaltetMitBuchungen(2)
|
||
const erg = await mitEinwurf(buchungLoeschen, async () => schalteEin(), () => loescheAbgeschalteteFunktionen(JETZT))
|
||
// die erste Buchung ging noch vor dem Einschalten; die Funktion ist danach aktiv, ihre Einstellung muss bleiben
|
||
assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt')
|
||
assert.equal(erg.funktionen, 1)
|
||
assert.equal(erg.buchungen, 2, 'beide Buchungen waren in einem Block von 50, vor der nächsten Prüfung gelöscht')
|
||
})
|
||
|
||
test('loescheAbgeschalteteFunktionen: kann die Funktion vor dem Löschen nicht gelesen werden, wird nichts gelöscht (Fehler im Log)', async () => {
|
||
await abgeschaltetMitBuchungen(2)
|
||
aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau kaputt', after: 1 }) // 1. GET = Liste, 2. = Neulesen
|
||
const erg = await loescheAbgeschalteteFunktionen(JETZT)
|
||
assert.deepEqual(erg, { funktionen: 0, buchungen: 0 })
|
||
assert.equal(buchungen().length, 2)
|
||
assert.ok(aw.docs('termineConfig').get(F1))
|
||
assert.equal(fehlerLog().length, 1)
|
||
})
|
||
|
||
// ---------------------------------------------------------------- Tagesjob
|
||
|
||
test('taeglicherJob: Löschfristen, Warteschlange, festgefahrene Mails, verwaiste Zellen und abgeschaltete Funktionen in einem Lauf', async () => {
|
||
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
|
||
await belegeZellen({ featureId: F1, ressourceId: 'r_geist', bookingId: 'b_geist', start: plus(JETZT, 2 * TAG), blockEnde: plus(JETZT, 2 * 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.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`,
|
||
)
|
||
assert.doesNotMatch(zeile, /\bb_|\bf_|@|Mustermann|Salon/)
|
||
})
|
||
|
||
test('tagesbericht: keine Zeile, wenn der Job nicht gelaufen ist', () => {
|
||
assert.equal(tagesbericht({ aus: true }), null)
|
||
assert.equal(tagesbericht({ uebersprungen: true }), null)
|
||
assert.equal(tagesbericht(undefined), null)
|
||
})
|
||
|
||
test('taeglicherJob: festgefahrene Mail wird fehler und erst 48 Stunden später gelöscht (nicht im selben Lauf)', async () => {
|
||
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: ein langsamer Lauf wird vom nächsten Takt übersprungen (einmal im Log), danach geht es normal weiter', async () => {
|
||
await legeBuchung('b_vorbei', { status: 'gehalten', heldUntil: iso(plus(JETZT, -MIN)) })
|
||
const echt = globalThis.fetch
|
||
let freigeben
|
||
const tor = new Promise((resolve) => (freigeben = resolve))
|
||
let erste = true
|
||
globalThis.fetch = async (url, optionen) => {
|
||
if (erste) {
|
||
erste = false
|
||
await tor
|
||
}
|
||
return echt(url, optionen)
|
||
}
|
||
try {
|
||
const langsam = minutenJob(JETZT)
|
||
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/.test(zeile)).length, 1, 'nur einmal gemeldet')
|
||
// andere Jobs sind davon nicht betroffen
|
||
assert.equal((await fuenfMinutenJob(JETZT)).uebersprungen, undefined)
|
||
freigeben()
|
||
assert.equal((await langsam).verfallen, 1)
|
||
} finally {
|
||
globalThis.fetch = echt
|
||
}
|
||
assert.equal((await minutenJob(plus(JETZT, 3 * MIN))).uebersprungen, undefined, 'danach läuft der Job wieder')
|
||
})
|
||
|
||
test('ein Job, der mit einem Fehler endet, gibt die Überlappungssperre 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, der Job wirft nicht')
|
||
} finally {
|
||
globalThis.fetch = echt
|
||
}
|
||
assert.equal((await minutenJob(JETZT)).uebersprungen, undefined)
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 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`)
|
||
}
|
||
}
|
||
})
|