From 91e7f7d8a3fff0282e9e90e0bd3f958491eba993 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 17:45:48 +0000 Subject: [PATCH] =?UTF-8?q?Tagesjob:=20Abo-Abgleich=20vor=20der=20L=C3=B6s?= =?UTF-8?q?chung,=20auch=20bei=20TERMINE=5FENABLED=3Doff=20mit=20bezahlten?= =?UTF-8?q?=20Dokumenten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit taeglicherJob ruft gleicheAboAb(now) nach den Löschfristen und vor der Löschung abgeschalteter Funktionen auf (Schritt „Abo-Abgleich“, ein Fehler dort stoppt weder Löschung noch Rest; die Fehler des Abgleichs zählen in fehler). Das Ergebnis trägt aboAbgleich, tagesbericht hängt „Abo-Abgleich N geprüft / N abgeschaltet / N wieder ein / N Mails / N ausgenommen“ vor „N Fehler“ an. fuehreAus nimmt trotzAus: Im Modus off läuft der Tagesjob nur, wenn es ein vom Kauf freigeschaltetes Termine-Dokument gibt (eine Abfrage, limit 1; bei einem Lesefehler Warnung und { aus: true }). Dann läuft ein eingeschränkter Lauf (eingeschraenkt: true, gleiche Überlappungssperre): nur Abo-Abgleich und Löschung abgeschalteter Funktionen, mit eigener Berichtszeile und den Feldern, die meldeTagesjob und die Statusroute lesen. Co-Authored-By: Claude Sonnet 5.5 --- server/services/termine/jobs.js | 207 +++++++++++++---- test/termineJobs.test.js | 399 ++++++++++++++++++++++++++++++-- 2 files changed, 540 insertions(+), 66 deletions(-) diff --git a/server/services/termine/jobs.js b/server/services/termine/jobs.js index 8147c81..01a113d 100644 --- a/server/services/termine/jobs.js +++ b/server/services/termine/jobs.js @@ -1,7 +1,8 @@ import { config } from '../../config.js' -import { Query, deleteDocument, getDocument, listAllDocuments } from '../appwriteAdmin.js' +import { Query, deleteDocument, getDocument, listAllDocuments, listDocuments } from '../appwriteAdmin.js' import { withLocks } from '../keyedLock.js' import { gibFreiSicher, iso, vertragAm } from './ablauf.js' +import { gleicheAboAb } from './aboAbgleich.js' import { meldeTagesjob, pruefeBetrieb, zaehleAktiveBetriebe } from './betrieb.js' import { abgelaufeneHaltungen, @@ -33,12 +34,19 @@ import { gibZellenFrei, raeumeVerwaisteZellen } from './zellen.js' * Anmeldepause => Log und Team-Mail, betrieb.js) * fuenfMinutenJob alle 5 Minuten Erinnerungen (G05); bestaetigt 1 Stunde nach Ende => erledigt; Anfragen mit Beginn in * der Vergangenheit => abgelehnt (vom System, Zellen frei, keine Mail) - * taeglicherJob taeglich Loeschfristen (11), festgefahrene und alte Mails, abgeschaltete Funktionen, verwaiste Zellen; - * zuletzt die aktiven Betriebe zaehlen und den Lauf melden (Fehler, Loeschdeckel in Folge, - * ab 20 Betrieben => Log und Team-Mail, meldeTagesjob in betrieb.js) + * taeglicherJob taeglich Loeschfristen (11), Abo-Abgleich (aboAbgleich.js: der Terminplaner endet mit dem Wartungs-Abo), + * abgeschaltete Funktionen, festgefahrene und alte Mails, verwaiste Zellen; der Abgleich laeuft VOR + * der Loeschung abgeschalteter Funktionen, damit ein Terminplaner, dessen Abo heute zurueckkehrt, + * nicht mehr geloescht wird; zuletzt die aktiven Betriebe zaehlen und den Lauf melden (Fehler, + * Loeschdeckel in Folge, ab 20 Betrieben => Log und Team-Mail, meldeTagesjob in betrieb.js) * * Gemeinsame Regeln: - * - Modus off: jeder Job kehrt sofort zurueck, ohne Abfrage, mit { aus: true }. + * - Modus off: Minuten- und Fuenf-Minuten-Job kehren sofort zurueck, ohne Abfrage, mit { aus: true }. Der Tagesjob stellt eine einzige + * Frage (Guard: gibt es ein vom Kauf freigeschaltetes Termine-Dokument, unlockedByPurchase true?); ohne bezahltes Dokument oder bei + * einem Lesefehler (Warnung) kehrt auch er mit { aus: true } zurueck. Mit bezahltem Dokument laeuft ein eingeschraenkter Tagesjob + * (`eingeschraenkt: true`): nur der Abo-Abgleich und die Loeschung abgeschalteter Funktionen, damit ein beendetes Abo auch bei + * TERMINE_ENABLED=off zum Abschalten und zur Loeschung nach 30 Tagen fuehrt. Loeschfristen der Buchungen, Warteschlange und Zellen + * bleiben aus. * - Keine ueberlappenden Laeufe: laeuft ein Job noch, wird der naechste Takt uebersprungen ({ uebersprungen: true }, einmal im * Log je Ueberlappung). Die Sperre gilt je `job:teil` (fuehreAus mit { teil }): haengt beim Minutenjob der Verfall, versendet der * naechste Takt trotzdem die Warteschlange und umgekehrt; uebersprungen gilt der Takt erst, wenn beide Teile uebersprungen sind @@ -107,10 +115,24 @@ async function schritt(lauf, was, fn, ersatz) { * Rahmen jedes Jobs: Schalter, Ueberlappung, Laufdaten. => Ergebnis von arbeit(lauf) | { aus: true } | { uebersprungen: true } * `teil` (Name eines Teiljobs, z. B. „Verfall“) gibt dem Teil eine eigene Ueberlappungssperre `job:teil` und nennt ihn in der * Warnung (`job (teil)`); `lauf.job` bleibt der Jobname (Fehlerlog, Warnung bei fehlender Collection). + * `trotzAus` (async, => wahr/falsch): im Modus off laeuft der Job nur, wenn es wahr liefert; dann steht `lauf.eingeschraenkt` auf true + * und der Job beschraenkt sich auf das, was auch bei abgeschaltetem Terminplaner gelten muss. Wirft es, wird gewarnt (ohne Kennungen) + * und { aus: true } geliefert; ohne `trotzAus` gilt Modus off wie bisher (sofort { aus: true }, ohne Abfrage). Die Ueberlappungssperre + * gilt unveraendert je Job (bzw. `job:teil`), der eingeschraenkte Lauf teilt sie mit dem vollen. */ -async function fuehreAus(job, now, arbeit, { teil = null } = {}) { +async function fuehreAus(job, now, arbeit, { teil = null, trotzAus = null } = {}) { const nowMs = pruefeNow(now, job) - if (termineAus()) return { aus: true } + let eingeschraenkt = false + if (termineAus()) { + if (trotzAus === null) return { aus: true } + try { + if (!(await trotzAus())) return { aus: true } + } catch (err) { + console.warn(`[termine-jobs] ${job}: Modus off, die Prüfung auf bezahlte Terminplaner ist fehlgeschlagen, der Job bleibt aus: ${fehlerText(err)}`) + return { aus: true } + } + eingeschraenkt = true + } const sperre = teil === null ? job : `${job}:${teil}` if (laufend.has(sperre)) { if (!ueberlappGemeldet.has(sperre)) { @@ -121,7 +143,7 @@ async function fuehreAus(job, now, arbeit, { teil = null } = {}) { } laufend.add(sperre) try { - return await arbeit({ job, nowMs, fehler: 0, gedeckelt: 0 }) + return await arbeit({ job, nowMs, fehler: 0, gedeckelt: 0, eingeschraenkt }) } finally { laufend.delete(sperre) ueberlappGemeldet.delete(sperre) @@ -634,51 +656,92 @@ export function loescheAbgeschalteteFunktionen(now) { /** Summe aller Loeschzaehler eines Tagesjobs: Buchungen aus den Loeschfristen und aus abgeschalteten Funktionen (Zellen, Outbox und Mails zaehlen nicht). */ const buchungenGeloescht = (loeschfristen, abgeschaltet) => Object.values(loeschfristen).reduce((summe, n) => summe + n, 0) + abgeschaltet.buchungen +/** Zaehler des Abo-Abgleichs, wenn der Schritt selbst scheitert (gleicheAboAb wirft nie; der Fehler steht dann schon in lauf.fehler). */ +const aboErsatz = () => ({ geprueft: 0, abgeschaltet: 0, wiederEin: 0, mails: 0, ausgenommen: 0, fehler: 0 }) + +/** + * Guard fuer den Modus off (fuehreAus: trotzAus): gibt es ein vom Kauf freigeschaltetes Termine-Dokument? Eine einzige Abfrage + * (limit 1) ohne Kunden- oder Gastdaten; ein Lesefehler wirft und haelt den Tagesjob aus. + */ +async function gibtBezahlteTerminplaner() { + const treffer = await listDocuments(config.collections.portalFeatures, [ + Query.equal('featureKey', 'termine'), + Query.equal('unlockedByPurchase', true), + Query.limit(1), + ]) + return treffer.length > 0 +} + +/** + * Abo-Abgleich (aboAbgleich.js) als Schritt des Tagesjobs: ein Fehler stoppt weder die Loeschung noch den Rest. Die Fehler des + * Abgleichs (Lese- und Versandfehler, schon im Log) kommen in lauf.fehler. => Zaehler des Abgleichs + */ +async function aboAbgleichSchritt(lauf, now) { + const abgleich = await schritt(lauf, 'Abo-Abgleich', () => gleicheAboAb(now), aboErsatz()) + lauf.fehler += abgleich.fehler + return abgleich +} + /** * Taeglich: festgefahrene Mails beenden (vor dem Aufraeumen, damit sie 48 Stunden lang als Zustellfehler sichtbar bleiben), - * Loeschfristen, abgeschaltete Funktionen, alte Warteschlangen-Eintraege, verwaiste Zellen; zuletzt die aktiven Betriebe zaehlen - * (ein nicht lesbarer Zaehler ergibt null und einen Fehler) und den Lauf ans Team melden (meldeTagesjob: Fehler, Loeschdeckel an - * 3 Tagen in Folge, ab 20 Betrieben; wirft nie). Bricht der Job unerwartet ab, erfaehrt das Team es ebenfalls, und der Fehler geht - * unveraendert an den Aufrufer. - * `geloescht` = Summe aller Loeschzaehler (Buchungen aus Loeschfristen und abgeschalteten Funktionen), `aktiveBetriebe` = Zahl oder null. + * Loeschfristen, Abo-Abgleich (der Terminplaner endet mit dem Wartungs-Abo; VOR der Loeschung, damit ein Betrieb, dessen Abo heute + * zurueckkehrt, nicht mehr geloescht wird), abgeschaltete Funktionen, alte Warteschlangen-Eintraege, verwaiste Zellen; zuletzt die + * aktiven Betriebe zaehlen (ein nicht lesbarer Zaehler ergibt null und einen Fehler) und den Lauf ans Team melden (meldeTagesjob: + * Fehler, Loeschdeckel an 3 Tagen in Folge, ab 20 Betrieben; wirft nie). Bricht der Job unerwartet ab, erfaehrt das Team es ebenfalls, + * und der Fehler geht unveraendert an den Aufrufer. + * `geloescht` = Summe aller Loeschzaehler (Buchungen aus Loeschfristen und abgeschalteten Funktionen), `aktiveBetriebe` = Zahl oder null, + * `aboAbgleich` = Zaehler des Abgleichs { geprueft, abgeschaltet, wiederEin, mails, ausgenommen, fehler }. + * Modus off: ohne vom Kauf freigeschaltetes Termine-Dokument { aus: true } nach genau einer Abfrage (Guard). Mit einem bezahlten + * Dokument laeuft der eingeschraenkte Tagesjob nur mit Abo-Abgleich und Loeschung abgeschalteter Funktionen (Loeschfristen der + * Buchungen, Warteschlange und Zellen bleiben aus; ob die uebrigen Schritte dort auch laufen sollen, ist offen). Sein Ergebnis traegt + * dieselben Felder, die meldeTagesjob und die Statusroute lesen: { eingeschraenkt: true, aboAbgleich, abgeschaltet, gedeckelt + * { loeschfristen: 0, abgeschaltet, zellen: 0 }, geloescht, aktiveBetriebe: null, fehler }. Beide Laeufe teilen die Ueberlappungssperre. * @param {Date} now * @returns {Promise} */ export async function taeglicherJob(now) { let aktuellerLauf = null // gesetzt, sobald der Rahmen den Job startet (fuer die Meldung eines Abbruchs) try { - return await fuehreAus('taeglicherJob', now, async (lauf) => { - aktuellerLauf = lauf - const festgefahren = await schritt(lauf, 'Festgefahrene Mails', () => beendeFestgefahrene(now), 0) - const { gedeckelt: loeschfristenGedeckelt, ...loeschung } = await loeschfristen(lauf, now) - const { gedeckelt: abgeschaltetGedeckelt, ...abgeschaltet } = await schritt(lauf, 'Abgeschaltete Funktionen', () => loescheAbgeschaltete(lauf), { - funktionen: 0, - buchungen: 0, - gedeckelt: 0, - }) - const aufgeraeumt = await schritt(lauf, 'Warteschlange aufräumen', () => raeumeAuf(now), 0) - const zellenErgebnis = await schritt(lauf, 'Verwaiste Zellen', () => raeumeVerwaisteZellen(now), { geloescht: 0, gedeckelt: false }) - if (zellenErgebnis.gedeckelt) { - console.warn(`[termine-jobs] ${lauf.job}: Zellen-Sweep endet am Deckel von ${config.termine.zellenDeckel} je Lauf, der Rest folgt an den nächsten Tagen`) - } - const aktiveBetriebe = await schritt(lauf, 'Aktive Betriebe', () => zaehleAktiveBetriebe(), null) - // gedeckelt: Schritte bzw. Funktionen, die am Deckel endeten (zellen: 1, wenn der Zellen-Sweep daran endete); getrennt vom Loeschzaehler, damit die Summe im Tagesbericht stimmt - const gedeckelt = { loeschfristen: loeschfristenGedeckelt, abgeschaltet: abgeschaltetGedeckelt, zellen: zellenErgebnis.gedeckelt ? 1 : 0 } - const ergebnis = { - festgefahren, - loeschfristen: loeschung, - gedeckelt, - abgeschaltet, - aufgeraeumt, - zellen: zellenErgebnis.geloescht, - geloescht: buchungenGeloescht(loeschung, abgeschaltet), - aktiveBetriebe, - fehler: lauf.fehler, - } - // Zuletzt: die Meldung sieht den ganzen Lauf; sie wirft nie, ein Fehler dort stuende nur im Log - await schritt(lauf, 'Tagesjob-Meldung', () => meldeTagesjob(ergebnis, now), []) - return ergebnis - }) + return await fuehreAus( + 'taeglicherJob', + now, + async (lauf) => { + aktuellerLauf = lauf + if (lauf.eingeschraenkt) return eingeschraenkterTagesjob(lauf, now) + const festgefahren = await schritt(lauf, 'Festgefahrene Mails', () => beendeFestgefahrene(now), 0) + const { gedeckelt: loeschfristenGedeckelt, ...loeschung } = await loeschfristen(lauf, now) + const aboAbgleich = await aboAbgleichSchritt(lauf, now) + const { gedeckelt: abgeschaltetGedeckelt, ...abgeschaltet } = await schritt(lauf, 'Abgeschaltete Funktionen', () => loescheAbgeschaltete(lauf), { + funktionen: 0, + buchungen: 0, + gedeckelt: 0, + }) + const aufgeraeumt = await schritt(lauf, 'Warteschlange aufräumen', () => raeumeAuf(now), 0) + const zellenErgebnis = await schritt(lauf, 'Verwaiste Zellen', () => raeumeVerwaisteZellen(now), { geloescht: 0, gedeckelt: false }) + if (zellenErgebnis.gedeckelt) { + console.warn(`[termine-jobs] ${lauf.job}: Zellen-Sweep endet am Deckel von ${config.termine.zellenDeckel} je Lauf, der Rest folgt an den nächsten Tagen`) + } + const aktiveBetriebe = await schritt(lauf, 'Aktive Betriebe', () => zaehleAktiveBetriebe(), null) + // gedeckelt: Schritte bzw. Funktionen, die am Deckel endeten (zellen: 1, wenn der Zellen-Sweep daran endete); getrennt vom Loeschzaehler, damit die Summe im Tagesbericht stimmt + const gedeckelt = { loeschfristen: loeschfristenGedeckelt, abgeschaltet: abgeschaltetGedeckelt, zellen: zellenErgebnis.gedeckelt ? 1 : 0 } + const ergebnis = { + festgefahren, + loeschfristen: loeschung, + gedeckelt, + abgeschaltet, + aboAbgleich, + aufgeraeumt, + zellen: zellenErgebnis.geloescht, + geloescht: buchungenGeloescht(loeschung, abgeschaltet), + aktiveBetriebe, + fehler: lauf.fehler, + } + // Zuletzt: die Meldung sieht den ganzen Lauf; sie wirft nie, ein Fehler dort stuende nur im Log + await schritt(lauf, 'Tagesjob-Meldung', () => meldeTagesjob(ergebnis, now), []) + return ergebnis + }, + { trotzAus: gibtBezahlteTerminplaner }, + ) } catch (err) { // Abbruch ausserhalb der Schritte: das Team erfaehrt es; der Fehler (Text im Log des Aufrufers) geht unveraendert weiter if (aktuellerLauf) await schritt(aktuellerLauf, 'Tagesjob-Meldung', () => meldeTagesjob({ abbruch: true, fehler: aktuellerLauf.fehler + 1 }, now), []) @@ -686,26 +749,70 @@ export async function taeglicherJob(now) { } } +/** + * Der eingeschraenkte Tagesjob (Modus off, es gibt bezahlte Terminplaner): erst der Abo-Abgleich, dann die Loeschung abgeschalteter + * Funktionen (gleiche Reihenfolge wie im vollen Lauf). Mehr nicht: keine Loeschfristen der Buchungen, keine Warteschlange, keine + * Zellen, keine Zaehlung der aktiven Betriebe (`aktiveBetriebe: null`). Das Ergebnis traegt auch die Felder, die meldeTagesjob liest, + * und wird wie im vollen Lauf gemeldet (Fehler gehen als Vorfall ans Team). + */ +async function eingeschraenkterTagesjob(lauf, now) { + const aboAbgleich = await aboAbgleichSchritt(lauf, now) + const { gedeckelt: abgeschaltetGedeckelt, ...abgeschaltet } = await schritt(lauf, 'Abgeschaltete Funktionen', () => loescheAbgeschaltete(lauf), { + funktionen: 0, + buchungen: 0, + gedeckelt: 0, + }) + const ergebnis = { + eingeschraenkt: true, + aboAbgleich, + abgeschaltet, + gedeckelt: { loeschfristen: 0, abgeschaltet: abgeschaltetGedeckelt, zellen: 0 }, + geloescht: abgeschaltet.buchungen, + aktiveBetriebe: null, + fehler: lauf.fehler, + } + await schritt(lauf, 'Tagesjob-Meldung', () => meldeTagesjob(ergebnis, now), []) + return ergebnis +} + +/** Zahl fuer die Berichtszeile: ganze Zahl ab 0, alles andere (fehlt, Text) zaehlt als 0. */ +const zahl = (wert) => (Number.isInteger(wert) && wert > 0 ? wert : 0) + /** * Eine Zeile fuers Log nach dem Tagesjob: nur Zahlen, keine personenbezogenen Daten und keine Buchungs- oder Funktions-IDs - * (Spur der unwiderruflichen Loeschungen). Ganz am Ende steht „n aktive Betriebe“ (fehlt, wenn die Zahl unbekannt ist). - * null, wenn der Job nicht gelaufen ist (Modus off, uebersprungen). + * (Spur der unwiderruflichen Loeschungen). Reihenfolge der Segmente (verbindlich): `…, N Mails nicht zugestellt, [Löschläufe am + * Deckel: n, Rest folgt an den nächsten Tagen, ]Abo-Abgleich g geprüft / a abgeschaltet / w wieder ein / m Mails / x ausgenommen, + * N Fehler, n aktive Betriebe`; „n aktive Betriebe“ steht ganz am Ende und fehlt, wenn die Zahl unbekannt ist, „Abo-Abgleich“ fehlt, + * wenn das Ergebnis keine Zaehler dafuer traegt. Der eingeschraenkte Lauf (Modus off) hat eine eigene Zeile „[termine] Tagesjob (Modus + * off, nur Abo-Abgleich und Löschung): …“ ohne Zellen, Outbox, Mails und Betriebe. + * null, wenn der Job nicht gelaufen ist (Modus off ohne bezahlte Terminplaner, uebersprungen). * @param {object} ergebnis Rueckgabe von taeglicherJob * @returns {string|null} */ export function tagesbericht(ergebnis) { if (!ergebnis || ergebnis.aus || ergebnis.uebersprungen) return null - const l = ergebnis.loeschfristen const a = ergebnis.abgeschaltet - const gesamt = buchungenGeloescht(l, a) const amDeckel = Object.values(ergebnis.gedeckelt ?? {}).reduce((summe, n) => summe + n, 0) const deckel = amDeckel > 0 ? `Löschläufe am Deckel: ${amDeckel}, Rest folgt an den nächsten Tagen, ` : '' + const abo = ergebnis.aboAbgleich + const aboSegment = + abo === null || typeof abo !== 'object' + ? '' + : `Abo-Abgleich ${zahl(abo.geprueft)} geprüft / ${zahl(abo.abgeschaltet)} abgeschaltet / ${zahl(abo.wiederEin)} wieder ein / ${zahl(abo.mails)} Mails / ${zahl(abo.ausgenommen)} ausgenommen, ` + if (ergebnis.eingeschraenkt) { + return ( + `[termine] Tagesjob (Modus off, nur Abo-Abgleich und Löschung): ${a.buchungen} Buchungen gelöscht (abgeschaltete Funktionen ${a.buchungen}), ` + + `${a.funktionen} Funktionen bereinigt, ${deckel}${aboSegment}${ergebnis.fehler} Fehler` + ) + } + const l = ergebnis.loeschfristen + const gesamt = buchungenGeloescht(l, a) const betriebe = Number.isInteger(ergebnis.aktiveBetriebe) && ergebnis.aktiveBetriebe >= 0 ? `, ${ergebnis.aktiveBetriebe} aktive Betriebe` : '' return ( `[termine] Tagesjob: ${gesamt} Buchungen gelöscht (verfallen ${l.verfallen}, abgesagt ${l.abgesagt}, abgelehnt ${l.abgelehnt}, ` + `erledigt ${l.erledigt}, nichterschienen ${l.nichterschienen}, abgeschaltete Funktionen ${a.buchungen}), ` + `${a.funktionen} Funktionen bereinigt, ${ergebnis.zellen} Zellen, ${ergebnis.aufgeraeumt} Outbox, ` + - `${ergebnis.festgefahren} Mails nicht zugestellt, ${deckel}${ergebnis.fehler} Fehler${betriebe}` + `${ergebnis.festgefahren} Mails nicht zugestellt, ${deckel}${aboSegment}${ergebnis.fehler} Fehler${betriebe}` ) } diff --git a/test/termineJobs.test.js b/test/termineJobs.test.js index 6ab79de..c1e832f 100644 --- a/test/termineJobs.test.js +++ b/test/termineJobs.test.js @@ -12,6 +12,8 @@ import { TERMINE_DATETIMES } from '../server/services/termine/schema.js' const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures'), + subscriptions: schemaKeys('subscriptions'), + contractEvents: schemaKeys('contractEvents'), termineConfig: schemaKeys('termineConfig'), termineBookings: schemaKeys('termineBookings'), termineClaims: schemaKeys('termineClaims'), @@ -24,11 +26,12 @@ after(() => aw.close()) useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) const { config } = await import('../server/config.js') +const { subscriptionDoc, wartungDoc } = await import('./support/fixtures.js') const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const { normalisiereEinstellung } = await import('../server/services/termine/einstellungen.js') const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js') -const { letzterTagesjob, resetBetriebForTests } = await import('../server/services/termine/betrieb.js') +const { letzterTagesjob, resetBetriebForTests, statusDaten } = await import('../server/services/termine/betrieb.js') const { neuesToken, verwaltungsToken, buchungsNummer } = await import('../server/services/termine/token.js') const { speichereEinstellung, resetKontextForTests } = await import('../server/services/termine/kontext.js') const { belegeZellen } = await import('../server/services/termine/zellen.js') @@ -71,6 +74,9 @@ const feature = (id = F1, over = {}) => ({ ...over, }) +/** Vom Kauf freigeschalteter Terminplaner (unlockedByPurchase): nur solche halten im Modus off den eingeschränkten Tagesjob am Laufen. */ +const BEZAHLT = { unlockedByPurchase: true, purchaseStatus: 'paid' } + const eingabeEinstellung = (over = {}) => ({ betrieb: { name: 'Salon Müller', @@ -113,7 +119,7 @@ async function speichere(id, einstellungOver) { } beforeEach(async () => { - for (const collection of ['portalFeatures', 'termineConfig', 'termineBookings', 'termineClaims', 'termineOutbox', 'customers', 'websiteProjects']) { + for (const collection of ['portalFeatures', 'subscriptions', 'contractEvents', 'termineConfig', 'termineBookings', 'termineClaims', 'termineOutbox', 'customers', 'websiteProjects']) { for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id) } resetKontextForTests() @@ -332,21 +338,71 @@ async function mitFehlenderCollection(collection, fn) { // ---------------------------------------------------------------- Schalter -test('Modus off: kein Job fragt Appwrite oder sendet, auch nicht bei fälligen Daten', async () => { - await richteFunktionEin() +/** Eine Anfrage an die Funktionsliste, die der Guard des Tagesjobs im Modus off stellt: Termine, bezahlt, hoechstens ein Treffer. */ +const istGuardAbfrage = (aufruf) => { + const q = aufruf.queries + return ( + aufruf.collection === 'portalFeatures' && + q.length === 3 && + q.some((k) => k.method === 'equal' && k.attribute === 'featureKey' && k.values[0] === 'termine') && + q.some((k) => k.method === 'equal' && k.attribute === 'unlockedByPurchase' && k.values[0] === true) && + q.some((k) => k.method === 'limit' && k.values[0] === 1) + ) +} + +test('Modus off ohne bezahlten Terminplaner: Minuten- und Fünf-Minuten-Job fragen nichts, der Tagesjob stellt genau eine Frage (den Guard) und tut sonst nichts', async () => { + await richteFunktionEin() // aktiv, aber nicht vom Kauf freigeschaltet (Pilot/Kulanz) await legeBuchung('b_abgelaufen', { status: 'gehalten', heldUntil: iso(plus(JETZT, -5 * MIN)) }) + await legeLoeschFall('b_verfallen', 'verfallen', 1) await mitErinnerung('b_erinnern', 23 * STUNDE) legeEintrag('o_wartet') + legeEintrag('o_festgefahren', { createdAt: iso(plus(JETZT, -3 * TAG)) }) + await richteFunktionEin(F2, { state: 'disabled', disabledAt: iso(plus(JETZT, -45 * TAG)) }) // fällig zur Löschung, aber nicht bezahlt + await legeBuchung('b_abgeschaltet', { featureId: F2, start: plus(JETZT, 5 * TAG) }) config.termine.mode = 'off' const n = await zaehleAnfragen(async () => { - for (const job of [minutenJob, fuenfMinutenJob, taeglicherJob]) assert.deepEqual(await job(JETZT), { aus: true }) + for (const job of [minutenJob, fuenfMinutenJob]) assert.deepEqual(await job(JETZT), { aus: true }) }) - assert.equal(n, 0, 'keine einzige Anfrage an Appwrite') + assert.equal(n, 0, 'Minuten- und Fünf-Minuten-Job: keine einzige Anfrage an Appwrite') + + aw.resetListCalls() + const m = await zaehleAnfragen(async () => assert.deepEqual(await taeglicherJob(JETZT), { aus: true })) + assert.equal(m, 1, 'der Tagesjob stellt genau eine Anfrage: die Guard-Abfrage') + const abfragen = aw.listCalls() + assert.equal(abfragen.length, 1) + assert.ok(istGuardAbfrage(abfragen[0]), JSON.stringify(abfragen[0])) + assert.equal(status('b_abgelaufen'), 'gehalten') + assert.ok(buchung('b_verfallen'), 'nichts gelöscht') + assert.ok(buchung('b_abgeschaltet')) + assert.ok(aw.docs('termineConfig').get(F2)) assert.equal(buchung('b_erinnern').erinnerungGesendetAt, undefined) assert.equal(sink.sent.length, 0) - assert.equal(eintraege()[0].status, 'wartet') + assert.equal(eintraege().find((e) => e.$id === 'o_wartet').status, 'wartet') + assert.equal(eintraege().find((e) => e.$id === 'o_festgefahren').status, 'wartet', 'festgefahrene Mails bleiben liegen') + assert.equal(letzterTagesjob(), null, 'keine Tagesjob-Meldung') + assert.equal(fehlerLog().length + warnLog().length, 0) +}) + +test('Modus off: schlägt die Guard-Abfrage fehl, bleibt der Tagesjob aus (Warnung ohne Kennungen, keine Löschung, kein Wurf)', async () => { + await richteFunktionEin(F1, { state: 'disabled', disabledAt: iso(plus(JETZT, -45 * TAG)), disabledReason: 'abo_ende', aboEndeMailAt: iso(plus(JETZT, -44 * TAG)), ...BEZAHLT }) + await legeBuchung('b_fest', { start: plus(JETZT, 5 * TAG) }) + config.termine.mode = 'off' + aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau kaputt' }) + + assert.deepEqual(await taeglicherJob(JETZT), { aus: true }) + assert.ok(buchung('b_fest'), 'keine Löschung') + assert.ok(aw.docs('termineConfig').get(F1)) + assert.equal(warnLog().filter((zeile) => /taeglicherJob/.test(zeile) && /Modus off/.test(zeile) && /Nachbau kaputt/.test(zeile)).length, 1, 'eine Warnung') + assert.doesNotMatch(warnLog().join('\n'), /\bb_|\bf_|\bp_|\bc_|@/) + assert.equal(fehlerLog().length, 0) + assert.equal(letzterTagesjob(), null) + + // am nächsten Tag lesbar: der eingeschränkte Lauf holt die Löschung nach + const erg = await taeglicherJob(plus(JETZT, TAG)) + assert.equal(erg.eingeschraenkt, true) + assert.equal(buchung('b_fest'), undefined) }) test('jeder Job verlangt eine gültige Uhrzeit', async () => { @@ -1353,7 +1409,7 @@ test('taeglicherJob: gedeckelt zählt je Schritt; der Tagesbericht nennt „Lös assert.deepEqual(erg.gedeckelt, { loeschfristen: 2, abgeschaltet: 0, zellen: 0 }) let zeile = tagesbericht(erg) assert.match(zeile, /^\[termine\] Tagesjob: 4 Buchungen gelöscht \(/) - assert.match(zeile, /, 0 Mails nicht zugestellt, Löschläufe am Deckel: 2, Rest folgt an den nächsten Tagen, 0 Fehler, 1 aktive Betriebe$/) + assert.match(zeile, /, 0 Mails nicht zugestellt, Löschläufe am Deckel: 2, Rest folgt an den nächsten Tagen, Abo-Abgleich 0 geprüft \/ 0 abgeschaltet \/ 0 wieder ein \/ 0 Mails \/ 1 ausgenommen, 0 Fehler, 1 aktive Betriebe$/) assert.doesNotMatch(zeile, /\bb_|\bf_|@|Mustermann|Salon/) config.termine.loeschDeckel = DECKEL_VORHER @@ -1361,7 +1417,7 @@ test('taeglicherJob: gedeckelt zählt je Schritt; der Tagesbericht nennt „Lös assert.deepEqual(erg.gedeckelt, { loeschfristen: 0, abgeschaltet: 0, zellen: 0 }) zeile = tagesbericht(erg) assert.doesNotMatch(zeile, /Deckel/, 'ohne Deckel bleibt die Zeile unverändert') - assert.match(zeile, /, 0 Mails nicht zugestellt, 0 Fehler, 1 aktive Betriebe$/) + assert.match(zeile, /, 0 Mails nicht zugestellt, Abo-Abgleich 0 geprüft \/ 0 abgeschaltet \/ 0 wieder ein \/ 0 Mails \/ 1 ausgenommen, 0 Fehler, 1 aktive Betriebe$/) }) test('tagesbericht: das Segment zählt die Schritte aller Bereiche und fehlt bei gedeckelt 0', () => { @@ -1713,6 +1769,7 @@ test('taeglicherJob: Löschfristen, Warteschlange, festgefahrene Mails, verwaist assert.deepEqual(erg.abgeschaltet, { funktionen: 1, buchungen: 1 }) assert.equal(erg.geloescht, 3, 'verfallen 1 + erledigt 1 + abgeschaltete Funktion 1') assert.equal(erg.aktiveBetriebe, 1, 'F1 ist aktiv, F2 abgeschaltet') + assert.deepEqual(erg.aboAbgleich, { geprueft: 0, abgeschaltet: 0, wiederEin: 0, mails: 0, ausgenommen: 1, fehler: 0 }, 'F1 ist aktiv ohne Kauf (ausgenommen), F2 abgeschaltet ohne Grund (bleibt unberührt)') assert.equal(erg.aufgeraeumt, 1) assert.ok(erg.zellen >= 1) @@ -1731,7 +1788,7 @@ test('taeglicherJob: Löschfristen, Warteschlange, festgefahrene Mails, verwaist const zeile = tagesbericht(erg) assert.equal( zeile, - `[termine] Tagesjob: 3 Buchungen gelöscht (verfallen 1, abgesagt 0, abgelehnt 0, erledigt 1, nichterschienen 0, abgeschaltete Funktionen 1), 1 Funktionen bereinigt, ${erg.zellen} Zellen, 1 Outbox, 1 Mails nicht zugestellt, 0 Fehler, 1 aktive Betriebe`, + `[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, Abo-Abgleich 0 geprüft / 0 abgeschaltet / 0 wieder ein / 0 Mails / 1 ausgenommen, 0 Fehler, 1 aktive Betriebe`, ) assert.doesNotMatch(zeile, /\bb_|\bf_|@|Mustermann|Salon/) }) @@ -1746,7 +1803,7 @@ test('taeglicherJob: abgeschaltete Funktion am Deckel ⇒ gedeckelt.abgeschaltet assert.equal(erg.fehler, 0) let zeile = tagesbericht(erg) assert.match(zeile, /^\[termine\] Tagesjob: 2 Buchungen gelöscht \(.*abgeschaltete Funktionen 2\), 1 Funktionen bereinigt, /) - assert.match(zeile, /, 0 Mails nicht zugestellt, Löschläufe am Deckel: 1, Rest folgt an den nächsten Tagen, 0 Fehler, \d+ aktive Betriebe$/) + assert.match(zeile, /, 0 Mails nicht zugestellt, Löschläufe am Deckel: 1, Rest folgt an den nächsten Tagen, Abo-Abgleich 0 geprüft \/ 0 abgeschaltet \/ 0 wieder ein \/ 0 Mails \/ 0 ausgenommen, 0 Fehler, \d+ aktive Betriebe$/) assert.doesNotMatch(zeile, /\bb_|\bf_|@|Mustermann|Salon/) erg = await taeglicherJob(JETZT) @@ -1787,7 +1844,7 @@ test('taeglicherJob: Zellen-Sweep am Deckel ⇒ gedeckelt.zellen zählt, zellen assert.doesNotMatch(warnLog().join('\n'), /\bb_|\bf_|\br_|\bc_/, 'die Warnung nennt keine Kennungen') const zeile = tagesbericht(erg) assert.match(zeile, /, 250 Zellen, /) - assert.match(zeile, /Löschläufe am Deckel: 1, Rest folgt an den nächsten Tagen, 0 Fehler, 0 aktive Betriebe$/) + assert.match(zeile, /Löschläufe am Deckel: 1, Rest folgt an den nächsten Tagen, Abo-Abgleich 0 geprüft \/ 0 abgeschaltet \/ 0 wieder ein \/ 0 Mails \/ 0 ausgenommen, 0 Fehler, 0 aktive Betriebe$/) erg = await taeglicherJob(JETZT) assert.equal(erg.zellen, 250) @@ -1824,13 +1881,13 @@ test('taeglicherJob: Zellen-Sweep mit Standard-Deckel ⇒ zellen ist die Zahl, g test('taeglicherJob: ist die Liste der abgeschalteten Funktionen nicht lesbar, zählt der Schritt nichts, gedeckelt.abgeschaltet bleibt eine Zahl und die übrigen Schritte laufen', async () => { await abgeschaltetMitBuchungen(3, { zellen: false }) - aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau kaputt', after: 1 }) // 1. GET = Funktionen der Löschfristen, 2. = abgeschaltete + aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau kaputt', after: 2 }) // 1. GET = Funktionen der Löschfristen, 2. = Abo-Abgleich, 3. = abgeschaltete const erg = await taeglicherJob(JETZT) assert.deepEqual(erg.abgeschaltet, { funktionen: 0, buchungen: 0 }) assert.deepEqual(erg.gedeckelt, { loeschfristen: 0, abgeschaltet: 0, zellen: 0 }) assert.equal(erg.fehler, 1) assert.equal(buchungen().length, 3) - assert.match(tagesbericht(erg), /, 0 Mails nicht zugestellt, 1 Fehler, \d+ aktive Betriebe$/) + assert.match(tagesbericht(erg), /, 0 Mails nicht zugestellt, Abo-Abgleich 0 geprüft \/ 0 abgeschaltet \/ 0 wieder ein \/ 0 Mails \/ 0 ausgenommen, 1 Fehler, \d+ aktive Betriebe$/) }) test('tagesbericht: keine Zeile, wenn der Job nicht gelaufen ist', () => { @@ -1873,7 +1930,7 @@ test('taeglicherJob: aktiveBetriebe zählt nur aktive Termine-Dokumente (kein ab test('taeglicherJob: ist die Zahl der aktiven Betriebe nicht lesbar, ist aktiveBetriebe null, der Fehler gezählt, der Rest des Laufs fertig und die Zeile ohne Segment', async () => { await legeLoeschFall('b_ver000', 'verfallen', 1) - aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau kaputt', after: 2 }) // 1. GET = Funktionen der Löschfristen, 2. = abgeschaltete, 3. = Zählung + aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau kaputt', after: 3 }) // 1. GET = Funktionen der Löschfristen, 2. = Abo-Abgleich, 3. = abgeschaltete, 4. = Zählung const erg = await taeglicherJob(JETZT) assert.equal(erg.aktiveBetriebe, null) assert.equal(erg.fehler, 1) @@ -1890,7 +1947,7 @@ const team20 = () => sink.sent.filter((mail) => /^\[Terminplaner\]/.test(mail.su test('taeglicherJob meldet am Ende: Fehler im Lauf ⇒ Team-Mail „Tagesjob mit Fehlern“, letzterTagesjob trägt nur Zählungen; ein sauberer Lauf meldet nichts', async () => { assert.equal(letzterTagesjob(), null) await legeLoeschFall('b_ver000', 'verfallen', 1) - aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau kaputt', after: 2 }) + aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau kaputt', after: 3 }) // die Zählung der aktiven Betriebe const erg = await taeglicherJob(JETZT) assert.equal(erg.fehler, 1) assert.equal(team20().length, 1) @@ -1989,6 +2046,316 @@ test('taeglicherJob: festgefahrene Mail wird fehler und erst 48 Stunden später assert.equal(aw.docs('termineOutbox').get('o_alt'), undefined) }) +// ---------------------------------------------------------------- Tagesjob: Abo-Abgleich (E7) + +const K22_BETREFF = /^Dein Terminplaner wurde beendet/ +const K23_BETREFF = /^Dein Terminplaner läuft wieder/ +const kundenMails = (betreff) => sink.sent.filter((mail) => betreff.test(mail.subject)) +/** Laufendes (oder mit `over` beendetes) Wartungs-Abo von KUNDE. */ +const wartung = (stand = 'active', over = {}) => aw.seed('subscriptions', subscriptionDoc('sub_w', { ...wartungDoc, customerId: KUNDE, status: stand, ...over })) +/** Bezahlter, seit `tage` Tagen wegen des Abo-Endes abgeschalteter Terminplaner; K22 ging schon (Marker), der Abgleich holt nichts nach. */ +const abgeschaltetBezahlt = (tage, over = {}) => ({ + state: 'disabled', + enabled: false, + disabledAt: iso(plus(JETZT, -tage * TAG)), + disabledReason: 'abo_ende', + aboEndeMailAt: iso(plus(JETZT, -tage * TAG)), + ...BEZAHLT, + ...over, +}) +const ABO_NULL = { geprueft: 0, abgeschaltet: 0, wiederEin: 0, mails: 0, ausgenommen: 0, fehler: 0 } +/** Der Abgleich liest die Funktionsliste mit notEqual state off; die Löschfristen, die Löschung und der Guard tun das nicht. */ +const abgleichListe = (url, methode) => funktionsListe(url, methode) && /notEqual/.test(url) + +test('taeglicherJob: der Abo-Abgleich steht im Ergebnis mit seinen Zählern; ein bezahlter Terminplaner ohne Abo wird abgeschaltet, ein Pilot-Dokument ausgenommen', async () => { + await richteFunktionEin(F1, BEZAHLT) + await richteFunktionEin(F2) // aktiv, nicht vom Kauf freigeschaltet + + const erg = await taeglicherJob(JETZT) + assert.deepEqual(erg.aboAbgleich, { geprueft: 1, abgeschaltet: 1, wiederEin: 0, mails: 1, ausgenommen: 1, fehler: 0 }) + assert.equal(erg.fehler, 0) + assert.equal(erg.eingeschraenkt, undefined, 'im Modus pilot läuft der volle Tagesjob') + assert.equal(aw.docs('portalFeatures').get(F1).state, 'disabled') + assert.equal(aw.docs('portalFeatures').get(F1).disabledReason, 'abo_ende') + assert.equal(aw.docs('portalFeatures').get(F2).state, 'active', 'das Pilot-Dokument bleibt') + assert.equal(kundenMails(K22_BETREFF).length, 1) + assert.match(tagesbericht(erg), /, 0 Mails nicht zugestellt, Abo-Abgleich 1 geprüft \/ 1 abgeschaltet \/ 0 wieder ein \/ 1 Mails \/ 1 ausgenommen, 0 Fehler, 1 aktive Betriebe$/) +}) + +test('taeglicherJob: der Abgleich läuft vor der Löschung – ein Terminplaner, dessen Abo heute zurückkehrt, wird nicht gelöscht', async () => { + await richteFunktionEin(F1, abgeschaltetBezahlt(40)) + await legeBuchung('b_fest', { start: plus(JETZT, 5 * TAG) }) + wartung('active') + + const erg = await taeglicherJob(JETZT) + assert.deepEqual(erg.aboAbgleich, { ...ABO_NULL, geprueft: 1, wiederEin: 1, mails: 1 }) + assert.deepEqual(erg.abgeschaltet, { funktionen: 0, buchungen: 0 }, 'nichts gelöscht') + assert.equal(erg.fehler, 0) + const doc = aw.docs('portalFeatures').get(F1) + assert.equal(doc.state, 'active') + assert.equal(doc.disabledAt, null) + assert.ok(buchung('b_fest')) + assert.ok(claims('b_fest').length > 0) + assert.ok(aw.docs('termineConfig').get(F1), 'Einstellung bleibt') + assert.equal(kundenMails(K23_BETREFF).length, 1) +}) + +test('taeglicherJob: Kontrolle zur Reihenfolge – dieselbe Lage ohne laufendes Abo wird nach 30 Tagen gelöscht', async () => { + await richteFunktionEin(F1, abgeschaltetBezahlt(40)) + await legeBuchung('b_fest', { start: plus(JETZT, 5 * TAG) }) + wartung('canceled', { endedAt: iso(plus(JETZT, -40 * TAG)) }) + + const erg = await taeglicherJob(JETZT) + assert.deepEqual(erg.aboAbgleich, { ...ABO_NULL, geprueft: 1 }) + assert.deepEqual(erg.abgeschaltet, { funktionen: 1, buchungen: 1 }) + assert.equal(buchung('b_fest'), undefined) + assert.equal(aw.docs('termineConfig').get(F1), undefined) + assert.equal(kundenMails(K23_BETREFF).length, 0) +}) + +test('taeglicherJob: ist die Funktionsliste des Abgleichs nicht lesbar, läuft die Löschung weiter; der Fehler zählt einmal und geht ans Team', async () => { + await abgeschaltetMitBuchungen(3, { zellen: false }) + aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau kaputt', after: 1 }) // 1. GET = Funktionen der Löschfristen, 2. = Abo-Abgleich + const erg = await taeglicherJob(JETZT) + assert.deepEqual(erg.aboAbgleich, { ...ABO_NULL, fehler: 1 }) + assert.equal(erg.fehler, 1, 'der Fehler des Abgleichs steht in fehler (einmal)') + assert.deepEqual(erg.abgeschaltet, { funktionen: 1, buchungen: 3 }, 'die Löschung lief') + assert.equal(buchungen().length, 0) + assert.ok(fehlerLog().some((zeile) => /\[termine-abo\] Funktionen nicht lesbar/.test(zeile) && /Nachbau kaputt/.test(zeile))) + assert.equal(team20().length, 1) + assert.equal(team20()[0].subject, '[Terminplaner] Tagesjob mit Fehlern') + assert.match(tagesbericht(erg), /, 0 Mails nicht zugestellt, Abo-Abgleich 0 geprüft \/ 0 abgeschaltet \/ 0 wieder ein \/ 0 Mails \/ 0 ausgenommen, 1 Fehler, \d+ aktive Betriebe$/) +}) + +test('tagesbericht: die vollständige Zeile gleicht gegen das Literal (Abo-Abgleich vor den Fehlern, aktive Betriebe zuletzt), ohne Kennungen und Adressen', async () => { + const erg = await taeglicherJob(JETZT) + const zeile = tagesbericht(erg) + assert.equal( + zeile, + '[termine] Tagesjob: 0 Buchungen gelöscht (verfallen 0, abgesagt 0, abgelehnt 0, erledigt 0, nichterschienen 0, abgeschaltete Funktionen 0), 0 Funktionen bereinigt, 0 Zellen, 0 Outbox, 0 Mails nicht zugestellt, Abo-Abgleich 0 geprüft / 0 abgeschaltet / 0 wieder ein / 0 Mails / 0 ausgenommen, 0 Fehler, 0 aktive Betriebe', + ) + assert.doesNotMatch(zeile, /\bb_|\bf_|@/) +}) + +test('tagesbericht: mit Löschdeckel steht dessen Segment zwischen „Mails nicht zugestellt“ und „Abo-Abgleich“; die Zahlen des Abgleichs erscheinen unverändert', () => { + const basis = { + festgefahren: 3, + loeschfristen: { verfallen: 1, abgesagt: 2, abgelehnt: 3, erledigt: 4, nichterschienen: 5 }, + abgeschaltet: { funktionen: 1, buchungen: 6 }, + aufgeraeumt: 7, + zellen: 8, + aboAbgleich: { geprueft: 5, abgeschaltet: 1, wiederEin: 2, mails: 3, ausgenommen: 4, fehler: 0 }, + fehler: 9, + aktiveBetriebe: 12, + } + const ohne = tagesbericht({ ...basis, gedeckelt: { loeschfristen: 0, abgeschaltet: 0, zellen: 0 } }) + assert.equal( + ohne, + '[termine] Tagesjob: 21 Buchungen gelöscht (verfallen 1, abgesagt 2, abgelehnt 3, erledigt 4, nichterschienen 5, abgeschaltete Funktionen 6), 1 Funktionen bereinigt, 8 Zellen, 7 Outbox, 3 Mails nicht zugestellt, Abo-Abgleich 5 geprüft / 1 abgeschaltet / 2 wieder ein / 3 Mails / 4 ausgenommen, 9 Fehler, 12 aktive Betriebe', + ) + const mit = tagesbericht({ ...basis, gedeckelt: { loeschfristen: 2, abgeschaltet: 1, zellen: 1 } }) + assert.equal( + mit, + '[termine] Tagesjob: 21 Buchungen gelöscht (verfallen 1, abgesagt 2, abgelehnt 3, erledigt 4, nichterschienen 5, abgeschaltete Funktionen 6), 1 Funktionen bereinigt, 8 Zellen, 7 Outbox, 3 Mails nicht zugestellt, Löschläufe am Deckel: 4, Rest folgt an den nächsten Tagen, Abo-Abgleich 5 geprüft / 1 abgeschaltet / 2 wieder ein / 3 Mails / 4 ausgenommen, 9 Fehler, 12 aktive Betriebe', + ) + assert.doesNotMatch(mit, /\bb_|\bf_|@/) +}) + +// ---------------------------------------------------------------- Tagesjob bei TERMINE_ENABLED=off mit bezahlten Terminplanern (eingeschränkter Lauf) + +test('Modus off, bezahlter Terminplaner abgeschaltet seit 31 Tagen: Buchungen, Zellen und Einstellung weg; das Ergebnis trägt alle Felder, die Meldung und Status lesen', async () => { + await richteFunktionEin(F1, abgeschaltetBezahlt(31)) + await legeBuchung('b_fest', { start: plus(JETZT, 5 * TAG) }) + assert.ok(claims('b_fest').length > 0) + config.termine.mode = 'off' + + const erg = await taeglicherJob(JETZT) + assert.deepEqual(erg, { + eingeschraenkt: true, + aboAbgleich: { ...ABO_NULL, geprueft: 1 }, + abgeschaltet: { funktionen: 1, buchungen: 1 }, + gedeckelt: { loeschfristen: 0, abgeschaltet: 0, zellen: 0 }, + geloescht: 1, + aktiveBetriebe: null, + fehler: 0, + }) + assert.equal(buchung('b_fest'), undefined) + assert.equal(claims('b_fest').length, 0, 'Zellen frei') + assert.equal(aw.docs('termineConfig').get(F1), undefined, 'Einstellung gelöscht') + // (Dokument selbst auf `off` setzen: E8) + + // die Tagesjob-Meldung und die Statusroute lesen daraus Zahlen, nie undefined + assert.deepEqual(letzterTagesjob(), { at: JETZT.toISOString(), fehler: 0, gedeckelt: 0, geloescht: 1 }) + const statusErg = await statusDaten(JETZT) + assert.deepEqual(statusErg.letzterTagesjob, { at: JETZT.toISOString(), fehler: 0, gedeckelt: 0, geloescht: 1 }) + assert.ok(Object.values(statusErg.letzterTagesjob).every((wert) => wert !== undefined && !Number.isNaN(wert))) + + const zeile = tagesbericht(erg) + assert.equal( + zeile, + '[termine] Tagesjob (Modus off, nur Abo-Abgleich und Löschung): 1 Buchungen gelöscht (abgeschaltete Funktionen 1), 1 Funktionen bereinigt, Abo-Abgleich 1 geprüft / 0 abgeschaltet / 0 wieder ein / 0 Mails / 0 ausgenommen, 0 Fehler', + ) + assert.doesNotMatch(zeile, /\bb_|\bf_|\bp_|\bc_|@|aktive Betriebe/) +}) + +test('Modus off: am Löschdeckel zählt gedeckelt.abgeschaltet, letzterTagesjob und die Zeile nennen den Deckel (vor dem Abo-Abgleich), der Rest folgt am nächsten Tag', async () => { + config.termine.loeschDeckel = 2 + await abgeschaltetMitBuchungen(5, { zellen: false }) + aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(F1), ...BEZAHLT, disabledReason: 'abo_ende', aboEndeMailAt: iso(plus(JETZT, -39 * TAG)) }) + config.termine.mode = 'off' + + let erg = await taeglicherJob(JETZT) + assert.deepEqual(erg.abgeschaltet, { funktionen: 1, buchungen: 2 }) + assert.deepEqual(erg.gedeckelt, { loeschfristen: 0, abgeschaltet: 1, zellen: 0 }) + assert.equal(erg.geloescht, 2) + assert.deepEqual(letzterTagesjob(), { at: JETZT.toISOString(), fehler: 0, gedeckelt: 1, geloescht: 2 }) + assert.equal( + tagesbericht(erg), + '[termine] Tagesjob (Modus off, nur Abo-Abgleich und Löschung): 2 Buchungen gelöscht (abgeschaltete Funktionen 2), 1 Funktionen bereinigt, Löschläufe am Deckel: 1, Rest folgt an den nächsten Tagen, Abo-Abgleich 1 geprüft / 0 abgeschaltet / 0 wieder ein / 0 Mails / 0 ausgenommen, 0 Fehler', + ) + erg = await taeglicherJob(plus(JETZT, TAG)) + assert.deepEqual(erg.abgeschaltet, { funktionen: 1, buchungen: 2 }) + erg = await taeglicherJob(plus(JETZT, 2 * TAG)) + assert.deepEqual(erg.abgeschaltet, { funktionen: 1, buchungen: 1 }, 'der letzte Tag: eine Buchung und die Einstellung') + assert.deepEqual(erg.gedeckelt, { loeschfristen: 0, abgeschaltet: 0, zellen: 0 }) + assert.equal(aw.docs('termineConfig').get(F1), undefined) + assert.equal(buchungen().length, 0) +}) + +test('Modus off, bezahlter aktiver Terminplaner ohne Abo: abgeschaltet, K22 geht raus; am Folgetag nichts mehr', async () => { + await richteFunktionEin(F1, BEZAHLT) + config.termine.mode = 'off' + + const erg = await taeglicherJob(JETZT) + assert.equal(erg.eingeschraenkt, true) + assert.deepEqual(erg.aboAbgleich, { geprueft: 1, abgeschaltet: 1, wiederEin: 0, mails: 1, ausgenommen: 0, fehler: 0 }) + assert.deepEqual(erg.abgeschaltet, { funktionen: 0, buchungen: 0 }) + const doc = aw.docs('portalFeatures').get(F1) + assert.equal(doc.state, 'disabled') + assert.equal(doc.disabledReason, 'abo_ende') + assert.equal(kundenMails(K22_BETREFF).length, 1) + assert.deepEqual(kundenMails(K22_BETREFF)[0].to.map((a) => a.address), ['silke@example.com']) + + const zweiter = await taeglicherJob(plus(JETZT, TAG)) + assert.equal(zweiter.eingeschraenkt, true, 'das abgeschaltete bezahlte Dokument hält den Lauf am Laufen (bis zur Löschung)') + assert.deepEqual(zweiter.aboAbgleich, { ...ABO_NULL, geprueft: 1 }) + assert.equal(kundenMails(K22_BETREFF).length, 1, 'keine zweite Mail') +}) + +test('Modus off: der eingeschränkte Lauf tut nur Abgleich und Löschung – keine Löschfristen der Buchungen, keine Outbox, keine Zellen', async () => { + await richteFunktionEin(F1, BEZAHLT) + wartung('active') + await richteFunktionEin(F2) // aktiv, nicht bezahlt: ausgenommen + await legeLoeschFall('b_verfallen', 'verfallen', 1, { featureId: F2 }) + await legeLoeschFall('b_abgesagt', 'abgesagt', 1, { featureId: F2 }) + await belegeZellen({ featureId: F1, ressourceId: 'r_geist', bookingId: 'b_geist', start: plus(JETZT, -3 * TAG), blockEnde: plus(JETZT, -3 * TAG + 30 * MIN), now: JETZT }) + legeEintrag('o_festgefahren', { createdAt: iso(plus(JETZT, -3 * TAG)) }) + legeEintrag('o_gesendet', { status: 'gesendet', an: '', html: '', text: '', naechsterVersuch: iso(plus(JETZT, -MIN)) }) + config.termine.mode = 'off' + + const erg = await taeglicherJob(JETZT) + assert.deepEqual(erg, { + eingeschraenkt: true, + aboAbgleich: { ...ABO_NULL, geprueft: 1, ausgenommen: 1 }, + abgeschaltet: { funktionen: 0, buchungen: 0 }, + gedeckelt: { loeschfristen: 0, abgeschaltet: 0, zellen: 0 }, + geloescht: 0, + aktiveBetriebe: null, + fehler: 0, + }) + assert.ok(buchung('b_verfallen'), 'die verfallene Buchung eines anderen Betriebs bleibt') + assert.ok(buchung('b_abgesagt')) + assert.ok(claims('b_geist').length > 0, 'kein Zellen-Sweep') + assert.equal(aw.docs('termineOutbox').get('o_festgefahren').status, 'wartet', 'festgefahrene Mails bleiben liegen') + assert.ok(aw.docs('termineOutbox').get('o_gesendet'), 'kein Aufräumen der Warteschlange') + assert.equal(aw.docs('portalFeatures').get(F1).state, 'active', 'das Abo läuft') + assert.equal(sink.sent.length, 0) +}) + +test('Modus off: ein Fehler im Abgleich hält die Löschung nicht auf, zählt einmal, steht in letzterTagesjob und geht als Vorfall ans Team', async () => { + await abgeschaltetMitBuchungen(2, { zellen: false }) + aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(F1), ...BEZAHLT, disabledReason: 'abo_ende', aboEndeMailAt: iso(plus(JETZT, -39 * TAG)) }) + config.termine.mode = 'off' + aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau kaputt', after: 1 }) // 1. GET = Guard, 2. = Abo-Abgleich + + const erg = await taeglicherJob(JETZT) + assert.equal(erg.eingeschraenkt, true) + assert.deepEqual(erg.aboAbgleich, { ...ABO_NULL, fehler: 1 }) + assert.equal(erg.fehler, 1) + assert.deepEqual(erg.abgeschaltet, { funktionen: 1, buchungen: 2 }, 'die Löschung lief') + assert.deepEqual(letzterTagesjob(), { at: JETZT.toISOString(), fehler: 1, gedeckelt: 0, geloescht: 2 }) + assert.equal(team20().length, 1) + assert.equal(team20()[0].subject, '[Terminplaner] Tagesjob mit Fehlern') +}) + +test('Modus off: der eingeschränkte Lauf nutzt dieselbe Überlappungssperre wie der volle Tagesjob (zweiter paralleler Aufruf ⇒ übersprungen)', async () => { + await richteFunktionEin(F1, abgeschaltetBezahlt(40)) + await legeBuchung('b_fest', { start: plus(JETZT, 5 * TAG) }) + config.termine.mode = 'off' + const haenger = mitHaengern(abgleichListe) + try { + const langsam = taeglicherJob(JETZT) + await haenger.warteBisRuhig() + assert.ok(haenger.gehalten(), 'der Abgleich hängt') + assert.deepEqual(await taeglicherJob(plus(JETZT, MIN)), { uebersprungen: true }) + assert.equal(warnLog().filter((zeile) => /taeglicherJob/.test(zeile) && /nicht fertig/.test(zeile)).length, 1) + assert.ok(buchung('b_fest'), 'der zweite Lauf hat nichts getan') + haenger.freigeben() + const erg = await langsam + assert.equal(erg.eingeschraenkt, true) + assert.deepEqual(erg.abgeschaltet, { funktionen: 1, buchungen: 1 }) + } finally { + haenger.freigeben() + haenger.ende() + } + assert.equal((await taeglicherJob(plus(JETZT, 2 * MIN))).uebersprungen, undefined, 'danach ist die Sperre frei') +}) + +test('Überlappung: ein hängender Tagesjob hält Verfall und Mailversand des Minutenjobs nicht auf', async () => { + await richteFunktionEin(F1, BEZAHLT) + wartung('active') + await legeBuchung('b_vorbei', { status: 'gehalten', heldUntil: iso(plus(JETZT, -MIN)) }) + legeEintrag('o_erst') + const haenger = mitHaengern(abgleichListe) + try { + const langsam = taeglicherJob(JETZT) + await haenger.warteBisRuhig() + assert.ok(haenger.gehalten(), 'der Tagesjob hängt im Abgleich') + const minute = await minutenJob(plus(JETZT, MIN)) + assert.deepEqual(minute, { verfallen: 1, faellig: 1, gesendet: 1, fehler: 0 }, 'weder übersprungen noch Teile übersprungen') + assert.equal(status('b_vorbei'), 'verfallen') + assert.equal(aw.docs('termineOutbox').get('o_erst').status, 'gesendet') + assert.equal(warnLog().filter((zeile) => /nicht fertig/.test(zeile)).length, 0) + haenger.freigeben() + assert.equal((await langsam).fehler, 0) + } finally { + haenger.freigeben() + haenger.ende() + } +}) + +test('Überlappung: hängt der Verfall des Minutenjobs, läuft der Tagesjob trotzdem (und die Warteschlange des Minutenjobs ebenso)', async () => { + await richteFunktionEin(F1, BEZAHLT) + wartung('active') + await legeBuchung('b_vorbei', { status: 'gehalten', heldUntil: iso(plus(JETZT, -MIN)) }) + const haenger = mitHaengern(listeVonBuchungen(/heldUntil/)) + try { + const langsam = minutenJob(JETZT) + await haenger.warteBisRuhig() + assert.ok(haenger.gehalten(), 'der Verfall hängt') + const tag = await taeglicherJob(plus(JETZT, MIN)) + assert.equal(tag.uebersprungen, undefined) + assert.deepEqual(tag.aboAbgleich, { ...ABO_NULL, geprueft: 1 }) + assert.equal(tag.fehler, 0) + assert.equal(warnLog().filter((zeile) => /taeglicherJob/.test(zeile)).length, 0) + haenger.freigeben() + assert.equal((await langsam).verfallen, 1) + } finally { + haenger.freigeben() + haenger.ende() + } +}) + // ---------------------------------------------------------------- Fehlende Collection, Überlappung test('fehlende Collection: höchstens eine Warnung je Stunde und Job, kein Wurf', async () => {