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 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() 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.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: '
Hallo
', 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 }) 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)) }) await mitEinwurf( listeVonBuchungen(/updatedAt/), async () => aw.seed('termineBookings', { ...doc, status: 'bestaetigt' }), () => wendeLoeschfristenAn(JETZT), ) assert.equal(status('b_race'), 'bestaetigt') assert.ok(claims('b_race').length > 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.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`) } } })