From 91b1a3bcd8316799fa61776ad78b0e3a0126b747 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 20:33:33 +0000 Subject: [PATCH] Terminplaner TP2: Entwuerfe nach 90 Tagen und bei Kundenloeschung entfernen Neu: server/services/termine/entwuerfe.js. Ein Terminplaner im Zustand off ist ein Entwurf (Fragebogen, Einstellung). loescheEntwuerfe(now) im Tagesjob (nach den abgeschalteten Funktionen, nur im vollen Lauf): letzte Aenderung = juengerer Zeitpunkt aus $updatedAt des Funktionsdokuments und termineConfig.updatedAt, aelter als 90 Tage => Einstellung weg, das Funktionsdokument nur, wenn es nie in Betrieb oder bezahlt war (sonst bleibt es wegen des siteKeys; ein von E8 zurueckgesetztes Dokument nie). Vor jedem Loeschen unter feature-order, feature-admin und termine-cfg frisch lesen (noch off, Frist, keine Buchung). Seiten zu 100 per cursorAfter, hoechstens 2000 geprueft und 100 geloescht je Lauf, der Rest folgt an Folgetagen; Einzelfehler zaehlen in fehler. Tagesbericht: Segment "Entwuerfe n geloescht (m offen)". loescheEntwuerfeVonKunde: Kundenloeschung entfernt alle off-Entwuerfe vollstaendig, Fehler werden nur protokolliert. Co-Authored-By: Claude Sonnet 5.5 --- server/services/customerAdmin.js | 9 + server/services/termine/entwuerfe.js | 204 +++++++++++++ server/services/termine/jobs.js | 40 ++- test/funktionenTermine.test.js | 47 ++- test/termineEntwuerfe.test.js | 427 +++++++++++++++++++++++++++ test/termineJobs.test.js | 124 +++++++- 6 files changed, 827 insertions(+), 24 deletions(-) create mode 100644 server/services/termine/entwuerfe.js create mode 100644 test/termineEntwuerfe.test.js diff --git a/server/services/customerAdmin.js b/server/services/customerAdmin.js index 74e330a..eb80902 100644 --- a/server/services/customerAdmin.js +++ b/server/services/customerAdmin.js @@ -291,6 +291,15 @@ export async function deleteCustomerWithPortalAccess(customerId) { console.error(`[customerAdmin] Terminplaner von Kunde ${customerId} nicht abgeschaltet:`, err.message) } + // Entwuerfe (Terminplaner im Zustand off: nie bestellt oder nach dem Abo-Ende zurueckgesetzt) gehen sofort mit dem Kunden, Funktionsdokument + // und Einstellung. Fehler halten das Loeschen nicht auf (wie oben); ein uebrig gebliebener Entwurf faellt nach 90 Tagen im Tageslauf. + try { + const { loescheEntwuerfeVonKunde } = await import('./termine/entwuerfe.js') + await loescheEntwuerfeVonKunde(customerId) + } catch (err) { + console.error(`[customerAdmin] Entwürfe von Kunde ${customerId} nicht gelöscht:`, err.message) + } + const portalAccess = await getPortalAccessByCustomerId(customerId) if (portalAccess) { await deleteDocument( diff --git a/server/services/termine/entwuerfe.js b/server/services/termine/entwuerfe.js new file mode 100644 index 0000000..8f9b7dd --- /dev/null +++ b/server/services/termine/entwuerfe.js @@ -0,0 +1,204 @@ +import { config } from '../../config.js' +import { Query, deleteDocument, listDocuments } from '../appwriteAdmin.js' +import { withLocks } from '../keyedLock.js' +import { listCustomerFeatureDocs } from '../siteFeatures.js' +import { ersteBuchungen } from './buchungen.js' +import { holeOderNull } from './kontext.js' +import { pruefeNow, pruefeText, zeitMs } from './util.js' + +/** + * Entwuerfe des Terminplaners aufbewahren und loeschen (TP2, Spezifikation 2026-10-03-terminplaner-tp2-verkauf-design.md, Abschnitt 3.7). + * + * Ein Entwurf ist ein Funktionsdokument im Zustand `off` (portalFeatures, featureKey termine) samt Einstellung und Fragebogen + * (termineConfig, gleiche ID), die ein Kunde vor der Bestellung ausfuellt. Bestellt er nie, bleiben diese Angaben nicht ewig liegen: + * + * - loescheEntwuerfe (Baustein des Tagesjobs, jobs.js): ein Entwurf, dessen letzte Aenderung laenger als ENTWURF_TAGE (90) Tage her + * ist, verliert seine Einstellung und, wenn es nie in Betrieb oder bezahlt war, auch das Funktionsdokument. Die letzte Aenderung + * ist der juengere Zeitpunkt aus `$updatedAt` des Funktionsdokuments (Zustandswechsel, z. B. nach einer abgebrochenen Zahlung) und + * `termineConfig.updatedAt` (Speichern der Einstellung oder des Fragebogens; PUT /fragebogen aendert nur das termineConfig-Dokument). + * - loescheEntwuerfeVonKunde (Kundenloeschung, customerAdmin.js): alle `off`-Entwuerfe des Kunden sofort und vollstaendig. + * + * Das Funktionsdokument bleibt, sobald es einmal in Betrieb oder bezahlt war (activatedAt, ticketId, disabledAt, unlockedByPurchase, + * purchaseStatus paid), und ebenso das von E8 zurueckgesetzte (purchaseStatus zurueckgesetzt, dauerhafte Marke der Loeschung nach dem + * Abo-Ende): sein siteKey steckt im Snippet auf der Website des Kunden und muss gueltig bleiben. Solche Dokumente verlieren hoechstens + * ihre Einstellung. Nie angefasst werden ordered, setup, active und disabled (die Abfrage liest nur `off`). + * + * Sicherheit gegen Rennen: Die Liste ist nur die Vorauswahl. Vor jedem Loeschen werden unter den Sperren + * feature-order:: (Bestellung), feature-admin: (Team) und termine-cfg: (Speichern der Einstellung) das + * Funktionsdokument und die Einstellung frisch gelesen und alle Bedingungen erneut geprueft (noch `off`, Frist noch ueberschritten, + * keine Buchung zur Funktion). Im Zweifel (Lesefehler, unlesbares Datum) wird nichts geloescht. + * + * Deckel: eine Seite der Liste hat hoechstens SEITE (100) Dokumente (sortiert nach $id, per cursorAfter). Die Liste wird ganz gelesen, + * bevor das erste Dokument geloescht wird (ein geloeschtes Dokument taugt nicht mehr als Cursor); nach MAX_GEPRUEFT (2000) Dokumenten + * endet sie mit einer Logzeile. Danach werden hoechstens MAX_GELOESCHT (100) Entwuerfe je Lauf geloescht; die uebrigen faelligen + * zaehlen als `uebrig`. Geloeschte Entwuerfe fehlen am naechsten Tag in der Liste, der Rest laeuft also an Folgetagen weiter. + * Einzelfehler zaehlen in `fehler` und brechen den Lauf nicht ab (Log ohne Kennungen: weder Kunden-, Projekt- und Funktions-IDs noch + * siteKeys). + */ + +export const ENTWURF_TAGE = 90 +export const MAX_GEPRUEFT = 2000 +export const MAX_GELOESCHT = 100 + +const FUNKTION = 'termine' +const SEITE = 100 +const TAG_MS = 24 * 60 * 60 * 1000 +/** So viele Einzelfehler stehen je Lauf im Log; die Zahl steht danach in einer Schlusszeile (fehler im Ergebnis zaehlt alle). */ +const MAX_FEHLER_IM_LOG = 5 + +const fehlerText = (err) => `${err?.name ?? 'Fehler'} ${String(err?.message ?? '').slice(0, 200)}` +/** 404 "Dokument fehlt" (nicht: Collection oder Datenbank fehlt). */ +const dokumentFehlt = (err) => err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found' + +/** Dokument loeschen. => true, wenn es geloescht wurde, false, wenn es schon weg war (404 ist kein Fehler). */ +async function loescheOderWeg(collectionId, id) { + try { + await deleteDocument(collectionId, id) + return true + } catch (err) { + if (dokumentFehlt(err)) return false + throw err + } +} + +/** + * Zeitpunkt (ms) der letzten Aenderung eines Entwurfs: der juengere aus $updatedAt des Funktionsdokuments und updatedAt der + * Einstellung (fehlt das Attribut, deren $updatedAt). NaN, wenn sich ein vorhandener Zeitpunkt nicht lesen laesst (dann gilt der + * Entwurf nie als faellig). + */ +function letzteAenderung(funktion, einstellung) { + const zeiten = [zeitMs(funktion.$updatedAt)] + if (einstellung) { + const eigene = zeitMs(einstellung.updatedAt) + zeiten.push(Number.isFinite(eigene) ? eigene : zeitMs(einstellung.$updatedAt)) + } + return zeiten.every(Number.isFinite) ? Math.max(...zeiten) : Number.NaN +} + +/** Darf auch das Funktionsdokument weg? Nur, wenn es nie in Betrieb oder bezahlt war und nicht von E8 zurueckgesetzt wurde (siteKey). */ +const nieInBetrieb = (funktion) => + !funktion.activatedAt && + !funktion.ticketId && + !funktion.disabledAt && + !funktion.unlockedByPurchase && + funktion.purchaseStatus !== 'paid' && + funktion.purchaseStatus !== 'zurueckgesetzt' + +/** + * Einen Entwurf entfernen: unter den drei Sperren frisch lesen, pruefen, dann die Einstellung loeschen und, wenn es zulaessig ist, + * das Funktionsdokument. `grenze` (ms): nur Entwuerfe, deren letzte Aenderung davor liegt; null = Kundenloeschung, alles was `off` + * ist, ohne Frist und ohne Pruefung auf Spuren (der siteKey stirbt mit dem Kunden). Buchungen zur Funktion halten den Entwurf in + * beiden Faellen zurueck (ein `off`-Dokument hat keine; waeren doch welche da, blieben sonst Gastdaten ohne Funktion zurueck). + * Die Einstellung wird zuerst geloescht: scheitert danach das Dokument, holt der naechste Lauf es nach (die Frist ist unveraendert). + * Ein Lesefehler oder ein Fehler beim Loeschen wirft; der Aufrufer zaehlt ihn. + * => true, wenn etwas geloescht wurde (Einstellung oder Funktionsdokument), sonst false + */ +function entferne(kandidat, grenze) { + const { $id: id, customerId, projectId } = kandidat + if (!id || !customerId || !projectId) throw new Error('Entwurf ohne Kunde oder Projekt') + return withLocks([`feature-order:${customerId}:${projectId}`, `feature-admin:${projectId}`, `termine-cfg:${id}`], async () => { + const funktion = await holeOderNull(config.collections.portalFeatures, id) + if (!funktion || funktion.featureKey !== FUNKTION || funktion.state !== 'off') return false + if (funktion.customerId !== customerId || funktion.projectId !== projectId) return false + const einstellung = await holeOderNull(config.collections.termineConfig, id) + if (grenze !== null && !(letzteAenderung(funktion, einstellung) < grenze)) return false + if ((await ersteBuchungen(id, 1)).length > 0) return false + + let geloescht = false + if (einstellung && (await loescheOderWeg(config.collections.termineConfig, id))) geloescht = true + if ((grenze === null || nieInBetrieb(funktion)) && (await loescheOderWeg(config.collections.portalFeatures, id))) geloescht = true + return geloescht + }) +} + +/** + * Entwuerfe nach 90 Tagen loeschen (Baustein des Tagesjobs, ohne Schalter- und Ueberlappungspruefung). Wirft nie wegen eines + * einzelnen Dokuments oder einer nicht lesbaren Liste (das zaehlt in `fehler`); nur eine ungueltige Uhrzeit ist ein TypeError. + * @param {Date} now + * @returns {Promise<{ geprueft: number, geloescht: number, uebrig: number, fehler: number }>} + * geprueft = gelesene Kandidaten (hoechstens MAX_GEPRUEFT), geloescht = Entwuerfe, bei denen Einstellung oder Funktionsdokument + * geloescht wurden (hoechstens MAX_GELOESCHT), uebrig = faellig wirkende Kandidaten, die der Deckel aufhielt, fehler = Einzelfehler + */ +export async function loescheEntwuerfe(now) { + const nowMs = pruefeNow(now, 'loescheEntwuerfe') + const grenze = nowMs - ENTWURF_TAGE * TAG_MS + const erg = { geprueft: 0, geloescht: 0, uebrig: 0, fehler: 0 } + const melde = (was, err) => { + erg.fehler += 1 + if (erg.fehler <= MAX_FEHLER_IM_LOG) console.error(`[termine-entwuerfe] ${was}: ${fehlerText(err)}`) + } + + // 1 Vorauswahl: die ganze Liste lesen, bevor etwas geloescht wird. $updatedAt des Funktionsdokuments kann nur wachsen, ein + // Dokument, das schon hier juenger als die Frist ist, bleibt also auch nach dem frischen Lesen juenger. + const faellig = [] + let cursor = null + liste: for (;;) { + let seite + try { + seite = await listDocuments(config.collections.portalFeatures, [ + Query.equal('featureKey', FUNKTION), + Query.equal('state', 'off'), + Query.orderAsc('$id'), + Query.limit(SEITE), + ...(cursor ? [Query.cursorAfter(cursor)] : []), + ]) + } catch (err) { + melde('Liste der Entwürfe nicht gelesen', err) + break + } + for (const kandidat of seite) { + if (erg.geprueft >= MAX_GEPRUEFT) { + console.warn(`[termine-entwuerfe] Prüfung endet nach ${MAX_GEPRUEFT} Dokumenten, der Rest folgt an den nächsten Tagen`) + break liste + } + erg.geprueft += 1 + if (zeitMs(kandidat.$updatedAt) < grenze) faellig.push(kandidat) + } + if (seite.length < SEITE) break + cursor = seite[seite.length - 1].$id + } + + // 2 Loeschen, je Kandidat unter den Sperren mit frischem Lesen (entferne); am Deckel zaehlt der Rest als uebrig + for (const kandidat of faellig) { + if (erg.geloescht >= MAX_GELOESCHT) { + erg.uebrig += 1 + continue + } + try { + if (await entferne(kandidat, grenze)) erg.geloescht += 1 + } catch (err) { + melde('Entwurf nicht bearbeitet', err) + } + } + if (erg.uebrig > 0) { + console.warn(`[termine-entwuerfe] Löschung endet am Deckel von ${MAX_GELOESCHT} je Lauf, ${erg.uebrig} Entwürfe offen, der Rest folgt an den nächsten Tagen`) + } + if (erg.fehler > MAX_FEHLER_IM_LOG) console.error(`[termine-entwuerfe] ${erg.fehler - MAX_FEHLER_IM_LOG} weitere Fehler (nicht einzeln protokolliert)`) + return erg +} + +/** + * Kunde wird geloescht: alle seine Entwuerfe (Terminplaner im Zustand `off`) vollstaendig entfernen, Funktionsdokument und + * Einstellung, ohne Frist. Andere Zustaende bleiben (disableCustomerFeatures schaltet sie ab, die Loeschfrist von 30 Tagen + * entscheidet), ebenso andere Funktionen und andere Kunden. Scheitert ein Entwurf, werden die uebrigen trotzdem entfernt und danach der + * erste Fehler weitergereicht (der Aufrufer protokolliert ihn und loescht den Kunden trotzdem). + * @param {string} customerId + * @returns {Promise<{ geloescht: number }>} + */ +export async function loescheEntwuerfeVonKunde(customerId) { + pruefeText(customerId, 'customerId') + const entwuerfe = (await listCustomerFeatureDocs(customerId)).filter( + (doc) => doc.featureKey === FUNKTION && doc.state === 'off' && doc.customerId === customerId, + ) + let geloescht = 0 + let ersterFehler = null + for (const entwurf of entwuerfe) { + try { + if (await entferne(entwurf, null)) geloescht += 1 + } catch (err) { + ersterFehler ??= err + } + } + if (ersterFehler) throw ersterFehler + return { geloescht } +} diff --git a/server/services/termine/jobs.js b/server/services/termine/jobs.js index 9a884af..4bc634b 100644 --- a/server/services/termine/jobs.js +++ b/server/services/termine/jobs.js @@ -18,6 +18,7 @@ import { vergangeneAnfragen, zuErledigen, } from './buchungen.js' +import { loescheEntwuerfe } from './entwuerfe.js' import { LOESCHFRIST_TAGE } from './frist.js' import { kontextPerFeature, ladeEinstellung } from './kontext.js' import { anGast } from './mails.js' @@ -38,7 +39,9 @@ import { gibZellenFrei, raeumeVerwaisteZellen } from './zellen.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), Abo-Abgleich (aboAbgleich.js: der Terminplaner endet mit dem Wartungs-Abo), - * abgeschaltete Funktionen, festgefahrene und alte Mails, verwaiste Zellen; der Abgleich laeuft VOR + * abgeschaltete Funktionen, Entwuerfe (entwuerfe.js: Terminplaner im Zustand off, 90 Tage nach der + * letzten Aenderung; nur im vollen Lauf, Entwuerfe sind nie bezahlt), 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) @@ -759,6 +762,19 @@ const buchungenGeloescht = (loeschfristen, abgeschaltet) => Object.values(loesch /** 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 }) +/** Zaehler der Entwurfs-Loeschung, wenn der Schritt selbst scheitert (loescheEntwuerfe wirft nur bei ungueltiger Uhrzeit). */ +const entwuerfeErsatz = () => ({ geprueft: 0, geloescht: 0, uebrig: 0, fehler: 0 }) + +/** + * Entwuerfe (entwuerfe.js) als Schritt des Tagesjobs: ein Fehler stoppt den Rest nicht. Die Einzelfehler der Loeschung (schon im Log) + * kommen in lauf.fehler, damit der Tagesbericht und die Meldung ans Team sie nennen. => Zaehler { geprueft, geloescht, uebrig, fehler } + */ +async function entwuerfeSchritt(lauf, now) { + const entwuerfe = await schritt(lauf, 'Entwürfe', () => loescheEntwuerfe(now), entwuerfeErsatz()) + lauf.fehler += entwuerfe.fehler + return entwuerfe +} + /** * 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. @@ -785,15 +801,17 @@ async function aboAbgleichSchritt(lauf, now) { /** * Taeglich: festgefahrene Mails beenden (vor dem Aufraeumen, damit sie 48 Stunden lang als Zustellfehler sichtbar bleiben), * 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 + * zurueckkehrt, nicht mehr geloescht wird), abgeschaltete Funktionen, Entwuerfe (entwuerfe.js; die Einzelfehler zaehlen in `fehler`), + * 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 }. + * `aboAbgleich` = Zaehler des Abgleichs { geprueft, abgeschaltet, wiederEin, mails, ausgenommen, fehler }, + * `entwuerfe` = Zaehler der Entwurfs-Loeschung { geprueft, geloescht, uebrig, fehler } (geloescht zaehlt Entwuerfe, nicht Buchungen). * 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 + * Buchungen, Entwuerfe, 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 @@ -816,6 +834,7 @@ export async function taeglicherJob(now) { buchungen: 0, gedeckelt: 0, }) + const entwuerfe = await entwuerfeSchritt(lauf, now) 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) { @@ -830,6 +849,7 @@ export async function taeglicherJob(now) { gedeckelt, abgeschaltet, aboAbgleich, + entwuerfe, aufgeraeumt, zellen: zellenErgebnis.geloescht, geloescht: buchungenGeloescht(loeschung, abgeschaltet), @@ -881,10 +901,10 @@ 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). 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. + * Deckel: n, Rest folgt an den nächsten Tagen, ]Entwürfe n gelöscht (m offen), 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“ und „Entwürfe“ fehlen, 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 Entwürfe, 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} @@ -894,6 +914,8 @@ export function tagesbericht(ergebnis) { const a = ergebnis.abgeschaltet 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 e = ergebnis.entwuerfe + const entwuerfeSegment = e === null || typeof e !== 'object' ? '' : `Entwürfe ${zahl(e.geloescht)} gelöscht (${zahl(e.uebrig)} offen), ` const abo = ergebnis.aboAbgleich const aboSegment = abo === null || typeof abo !== 'object' @@ -912,7 +934,7 @@ export function tagesbericht(ergebnis) { `[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}${aboSegment}${ergebnis.fehler} Fehler${betriebe}` + `${ergebnis.festgefahren} Mails nicht zugestellt, ${deckel}${entwuerfeSegment}${aboSegment}${ergebnis.fehler} Fehler${betriebe}` ) } diff --git a/test/funktionenTermine.test.js b/test/funktionenTermine.test.js index 5fd4cd2..59469f0 100644 --- a/test/funktionenTermine.test.js +++ b/test/funktionenTermine.test.js @@ -6,9 +6,9 @@ import { useTestEnv } from './support/testEnv.js' import { schemaKeys } from './support/schema.js' // Terminplaner als zweite Funktion (Aufgabe 17): Team-Route /:customerId/:key, GET /api/funktionen mit Schalter, -// Kunde loeschen schaltet den Terminplaner ab. News verhaelt sich wie bisher (test/funktionenRoutes.test.js). +// Kunde loeschen schaltet den Terminplaner ab und entfernt seine Entwuerfe (C6). News verhaelt sich wie bisher (test/funktionenRoutes.test.js). const aw = await startAppwriteMock({ - schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions') }, + schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions'), termineConfig: schemaKeys('termineConfig'), termineBookings: schemaKeys('termineBookings') }, }) const st = await startFakeStripe() useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) @@ -226,6 +226,13 @@ test('Abgeschaltetes News hat Grund und Zeitpunkt, aber kein Loeschdatum; die Te }) // --------------------------------------------------------- Kunde loeschen --- +/** Entwurf: Terminplaner im Zustand off samt Einstellung (termineConfig), nie bestellt. */ +function legeEntwurf(customerId, projectId) { + const id = `f_${projectId}_termine` + aw.seed('portalFeatures', { $id: id, customerId, projectId, featureKey: 'termine', siteKey: 'abcdefghijklmnop', state: 'off', enabled: false }) + aw.seed('termineConfig', { $id: id, customerId, projectId, einstellung: '{}', version: 1, updatedBy: 'kunde' }) +} + test('Kunde loeschen schaltet seine Terminplaner ab (Notiz Kunde geloescht, Grund kunde_geloescht), nur seine', async () => { aw.seed('customers', { $id: 'kt1', name: 'Weg Salon', email: 'weg@example.invalid' }) aw.seed('websiteProjects', { $id: 'ktp1', customerId: 'kt1', projectName: 'Weg Eins' }) @@ -237,9 +244,18 @@ test('Kunde loeschen schaltet seine Terminplaner ab (Notiz Kunde geloescht, Grun const schonAus = await sf.adminActivate('kt1', 'ktp3', 'termine', { by: 'test' }) const frueher = (await sf.disable(schonAus.$id, { by: 'test' })).disabledAt const fremd = await sf.getFeatureDoc('c1', 'pt1', 'termine') + // Entwuerfe (off): der des Kunden samt Einstellung geht mit ihm, der eines anderen Kunden bleibt + aw.seed('websiteProjects', { $id: 'ktp5', customerId: 'kt1', projectName: 'Weg Fuenf' }) + legeEntwurf('kt1', 'ktp5') + legeEntwurf('kt9', 'ktp9') await deleteCustomerWithPortalAccess('kt1') + assert.equal(aw.docs('portalFeatures').has('f_ktp5_termine'), false, 'Entwurf des Kunden: Funktionsdokument weg') + assert.equal(aw.docs('termineConfig').has('f_ktp5_termine'), false, 'Entwurf des Kunden: Einstellung weg') + assert.equal(aw.docs('portalFeatures').has('f_ktp9_termine'), true, 'Entwurf eines anderen Kunden bleibt') + assert.equal(aw.docs('termineConfig').has('f_ktp9_termine'), true) + assert.equal(aw.docs('customers').has('kt1'), false) for (const projekt of ['ktp1', 'ktp2']) { const doc = await sf.getFeatureDoc('kt1', projekt, 'termine') @@ -264,6 +280,33 @@ test('Kunde loeschen gelingt auch, wenn das Abschalten scheitert (Fehler im Log) assert.ok(log.mock.calls.some((c) => /Terminplaner von Kunde kt2 nicht abgeschaltet/.test(String(c.arguments[0])))) }) +test('Kunde loeschen gelingt auch, wenn das Loeschen der Entwuerfe scheitert (Fehler im Log, die uebrigen Entwuerfe gehen trotzdem)', async (t) => { + const log = t.mock.method(console, 'error', () => {}) + aw.seed('customers', { $id: 'kt3', name: 'Weg Drei', email: 'weg3@example.invalid' }) + aw.seed('websiteProjects', { $id: 'ktp6', customerId: 'kt3', projectName: 'Weg Sechs' }) + aw.seed('websiteProjects', { $id: 'ktp7', customerId: 'kt3', projectName: 'Weg Sieben' }) + legeEntwurf('kt3', 'ktp6') + legeEntwurf('kt3', 'ktp7') + aw.failNext({ collection: 'termineConfig', method: 'DELETE', status: 500, message: 'kaputt' }) + await deleteCustomerWithPortalAccess('kt3') + assert.equal(aw.docs('customers').has('kt3'), false, 'der Kunde ist geloescht') + assert.ok(log.mock.calls.some((c) => /Entwürfe von Kunde kt3 nicht gelöscht/.test(String(c.arguments[0]))), 'Logzeile') + assert.equal(aw.docs('portalFeatures').has('f_ktp6_termine'), true, 'der Entwurf mit Fehler bleibt (ein spaeterer Lauf nach 90 Tagen holt ihn nach)') + assert.equal(aw.docs('portalFeatures').has('f_ktp7_termine'), false, 'der andere Entwurf ist weg') + assert.equal(aw.docs('termineConfig').has('f_ktp7_termine'), false) +}) + +test('Kunde loeschen laesst Terminplaner, die nicht off sind, den Abschaltweg gehen (Frist von 30 Tagen) und loescht ihre Einstellung nicht', async () => { + aw.seed('customers', { $id: 'kt4', name: 'Weg Vier', email: 'weg4@example.invalid' }) + aw.seed('websiteProjects', { $id: 'ktp8', customerId: 'kt4', projectName: 'Weg Acht' }) + await sf.adminActivate('kt4', 'ktp8', 'termine', { by: 'test' }) + aw.seed('termineConfig', { $id: 'f_ktp8_termine', customerId: 'kt4', projectId: 'ktp8', einstellung: '{}', version: 1, updatedBy: 'kunde' }) + await deleteCustomerWithPortalAccess('kt4') + const doc = await sf.getFeatureDoc('kt4', 'ktp8', 'termine') + assert.equal(doc.state, 'disabled') + assert.equal(aw.docs('termineConfig').has('f_ktp8_termine'), true, 'die Einstellung eines aktiven Betriebs loescht erst der Tageslauf nach 30 Tagen') +}) + // ------------------------------------------------ Team-Aktivierung nur mit laufendem Abo (E9) --- // Ein bezahlter Terminplaner (unlockedByPurchase) darf das Team nur einschalten, solange das Wartungs-Abo laeuft: sonst nimmt der // naechste Abgleichslauf die Kulanz zurueck und K22 ginge ein zweites Mal. Pilot und Kulanz ohne Kauf sowie News sind nicht betroffen. diff --git a/test/termineEntwuerfe.test.js b/test/termineEntwuerfe.test.js new file mode 100644 index 0000000..0bd74f7 --- /dev/null +++ b/test/termineEntwuerfe.test.js @@ -0,0 +1,427 @@ +import { test, after, beforeEach, afterEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' +import { TERMINE_DATETIMES } from '../server/services/termine/schema.js' + +// Entwuerfe des Terminplaners (server/services/termine/entwuerfe.js, Terminplaner TP2 C6): ein Funktionsdokument im Zustand `off` +// samt Einstellung (termineConfig) ist ein Entwurf des Fragebogens. Er wird 90 Tage nach der letzten Aenderung geloescht, bei der +// Kundenloeschung sofort. Aufbau wie termineJobs.test.js: Nachbau von Appwrite mit Schema und Standard-Limit 25, feste Zeit JETZT. +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + termineConfig: schemaKeys('termineConfig'), + termineBookings: schemaKeys('termineBookings'), + }, + datetimes: TERMINE_DATETIMES, + defaultLimit: 25, +}) +after(() => aw.close()) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) + +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { withLocks } = await import('../server/services/keyedLock.js') +const { speichereFragebogen, resetKontextForTests } = await import('../server/services/termine/kontext.js') +const { ENTWURF_TAGE, MAX_GEPRUEFT, MAX_GELOESCHT, loescheEntwuerfe, loescheEntwuerfeVonKunde } = await import('../server/services/termine/entwuerfe.js') + +const TAG = 24 * 60 * 60 * 1000 +const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin +const KUNDE = 'c_kunde01' +const ANDERER = 'c_kunde02' +const SITE_KEY = 'abcdefghijklmnop' + +const vor = (tage) => new Date(JETZT.getTime() - tage * TAG).toISOString() +const fid = (nr) => `f_p_e${nr}_termine` +const pid = (nr) => `p_e${nr}` +const dok = (nr) => aw.docs('portalFeatures').get(fid(nr)) +const einstellung = (nr) => aw.docs('termineConfig').get(fid(nr)) + +/** Entwurf: Funktionsdokument im Zustand off (`tage` seit der letzten Aenderung) und, wenn config, die Einstellung (`cfgTage`). */ +function legeEntwurf(nr, { tage = 91, cfgTage = tage, config = true, kunde = KUNDE, key = 'termine', doc = {} } = {}) { + aw.seed('portalFeatures', { + $id: key === 'termine' ? fid(nr) : `f_p_e${nr}_${key}`, + customerId: kunde, + projectId: pid(nr), + featureKey: key, + state: 'off', + enabled: false, + siteKey: SITE_KEY, + $updatedAt: vor(tage), + ...doc, + }) + if (config) { + aw.seed('termineConfig', { + $id: key === 'termine' ? fid(nr) : `f_p_e${nr}_${key}`, + customerId: kunde, + projectId: pid(nr), + einstellung: '{}', + version: 1, + updatedAt: vor(cfgTage), + updatedBy: 'kunde', + $updatedAt: vor(cfgTage), + }) + } +} + +/** n Entwuerfe mit den Nummern von..bis (nach $id sortiert und damit in dieser Reihenfolge gelesen: Nummern mit gleicher Stellenzahl). */ +function legeViele(von, bis, optionen) { + for (let nr = von; nr <= bis; nr += 1) legeEntwurf(nr, optionen) +} + +const warteBis = async (bedingung) => { + for (let i = 0; i < 400 && !bedingung(); i += 1) await new Promise((fertig) => setTimeout(fertig, 5)) + assert.ok(bedingung(), 'Bedingung wurde nicht erfuellt') +} + +let log +const logText = () => [...log.error.mock.calls, ...log.warn.mock.calls].map((aufruf) => aufruf.arguments.map(String).join(' ')).join('\n') + +beforeEach(async () => { + for (const collection of ['portalFeatures', 'termineConfig', 'termineBookings']) { + const ids = [...aw.docs(collection).keys()] + for (let i = 0; i < ids.length; i += 50) await Promise.all(ids.slice(i, i + 50).map((id) => deleteDocument(collection, id))) + } + resetKontextForTests() + aw.resetListCalls() + log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}) } +}) +afterEach(() => { + // das Protokoll nennt nie Kunden-, Projekt- oder Funktions-IDs und keinen siteKey + const text = logText() + mock.restoreAll() + assert.doesNotMatch(text, /f_p_e|p_e\d|c_kunde|abcdefghijklmnop/, 'keine Kennungen im Log') +}) + +// ---------------------------------------------------------------- Konstanten + +test('Konstanten: 90 Tage, höchstens 2000 geprüft, höchstens 100 gelöscht', () => { + assert.deepEqual([ENTWURF_TAGE, MAX_GEPRUEFT, MAX_GELOESCHT], [90, 2000, 100]) +}) + +// ---------------------------------------------------------------- Frist + +test('Frist: 89 und 90 Tage alt bleiben, 91 Tage: Einstellung und Funktionsdokument sind weg', async () => { + legeEntwurf(1, { tage: 89 }) + legeEntwurf(2, { tage: 90 }) + legeEntwurf(3, { tage: 91 }) + // dieselbe Grenze für die Einstellung allein (das Funktionsdokument ist längst älter) + legeEntwurf(4, { tage: 300, cfgTage: 89 }) + legeEntwurf(5, { tage: 300, cfgTage: 90 }) + legeEntwurf(6, { tage: 300, cfgTage: 91 }) + const erg = await loescheEntwuerfe(JETZT) + assert.deepEqual(erg, { geprueft: 6, geloescht: 2, uebrig: 0, fehler: 0 }) + for (const nr of [1, 2, 4, 5]) { + assert.ok(dok(nr), `Funktionsdokument ${nr} bleibt`) + assert.ok(einstellung(nr), `Einstellung ${nr} bleibt`) + } + for (const nr of [3, 6]) { + assert.equal(dok(nr), undefined, `Funktionsdokument ${nr}`) + assert.equal(einstellung(nr), undefined, `Einstellung ${nr}`) + } +}) + +test('Frist: der jüngere Zeitpunkt zählt – ein Entwurf, den der Kunde frisch bearbeitet hat (alte $updatedAt, neues termineConfig.updatedAt), bleibt', async () => { + legeEntwurf(1, { tage: 200, cfgTage: 200 }) + // der Schreibweg von PUT /api/termine/fragebogen: aendert nur das termineConfig-Dokument, nie das Funktionsdokument + await speichereFragebogen(dok(1), (bisher) => bisher, { now: new Date(JETZT.getTime() - 10 * TAG), by: 'kunde' }) + assert.equal(Date.parse(dok(1).$updatedAt), Date.parse(vor(200)), 'das Funktionsdokument blieb unberührt') + assert.equal(Date.parse(einstellung(1).updatedAt), Date.parse(vor(10))) + + const erg = await loescheEntwuerfe(JETZT) + assert.deepEqual(erg, { geprueft: 1, geloescht: 0, uebrig: 0, fehler: 0 }) + assert.ok(dok(1)) + assert.ok(einstellung(1)) + // 91 Tage nach der Bearbeitung ist auch dieser Entwurf fällig + const spaeter = await loescheEntwuerfe(new Date(JETZT.getTime() + 81 * TAG + 1000)) + assert.deepEqual(spaeter, { geprueft: 1, geloescht: 1, uebrig: 0, fehler: 0 }) + assert.equal(dok(1), undefined) +}) + +test('Frist: ein frisch geändertes Funktionsdokument (z. B. nach abgebrochener Zahlung wieder auf off) bleibt, auch wenn die Einstellung alt ist', async () => { + legeEntwurf(1, { tage: 3, cfgTage: 200, doc: { purchaseStatus: 'failed' } }) + const erg = await loescheEntwuerfe(JETZT) + assert.deepEqual(erg, { geprueft: 1, geloescht: 0, uebrig: 0, fehler: 0 }) + assert.ok(dok(1)) + assert.ok(einstellung(1)) +}) + +test('Funktionsdokument ohne Einstellung und älter als 90 Tage: das Dokument geht, ohne Einstellung wird nichts gelesen oder gelöscht', async () => { + legeEntwurf(1, { tage: 100, config: false }) + legeEntwurf(2, { tage: 20, config: false }) + const erg = await loescheEntwuerfe(JETZT) + assert.deepEqual(erg, { geprueft: 2, geloescht: 1, uebrig: 0, fehler: 0 }) + assert.equal(dok(1), undefined) + assert.ok(dok(2)) +}) + +test('Nur Termine im Zustand off: ordered, setup, active und disabled bleiben unabhängig vom Alter, ebenso andere Funktionen', async () => { + for (const [nr, state] of [[1, 'ordered'], [2, 'setup'], [3, 'active'], [4, 'disabled']]) { + legeEntwurf(nr, { tage: 400, doc: { state, enabled: state === 'active', ...(state === 'disabled' ? { disabledAt: vor(400), disabledReason: 'team' } : {}) } }) + } + legeEntwurf(5, { tage: 400, key: 'news' }) + legeEntwurf(6, { tage: 400, key: 'analyse', config: false }) + const vorher = [...aw.docs('portalFeatures').values()].map((d) => ({ ...d })) + const erg = await loescheEntwuerfe(JETZT) + assert.deepEqual(erg, { geprueft: 0, geloescht: 0, uebrig: 0, fehler: 0 }) + assert.deepEqual([...aw.docs('portalFeatures').values()], vorher) + assert.equal(aw.docs('termineConfig').size, 5, 'keine Einstellung wurde gelöscht') + // die Abfrage nennt Funktion und Zustand + const abfrage = aw.listCalls('portalFeatures')[0].queries + assert.deepEqual(abfrage.filter((q) => q.method === 'equal').map((q) => [q.attribute, q.values]), [['featureKey', ['termine']], ['state', ['off']]]) +}) + +// ---------------------------------------------------------------- Spuren von Betrieb und Zahlung + +const SPUREN = [ + ['activatedAt', { activatedAt: vor(200) }], + ['ticketId', { ticketId: 'wo_12345' }], + ['disabledAt', { disabledAt: vor(150) }], + ['unlockedByPurchase', { unlockedByPurchase: true }], + ['purchaseStatus paid', { purchaseStatus: 'paid' }], +] + +test('Ein Funktionsdokument, das in Betrieb oder bezahlt war, verliert nur die Einstellung (der siteKey bleibt gültig)', async () => { + SPUREN.forEach(([, felder], i) => legeEntwurf(i + 1, { doc: felder })) + const vorher = SPUREN.map((_, i) => ({ ...dok(i + 1) })) + const erg = await loescheEntwuerfe(JETZT) + assert.deepEqual(erg, { geprueft: 5, geloescht: 5, uebrig: 0, fehler: 0 }, 'die Einstellung ist gelöscht, das zählt als Löschung') + SPUREN.forEach(([name], i) => { + assert.deepEqual(dok(i + 1), vorher[i], `${name}: Funktionsdokument unverändert`) + assert.equal(dok(i + 1).siteKey, SITE_KEY) + assert.equal(einstellung(i + 1), undefined, `${name}: Einstellung gelöscht`) + }) + // am nächsten Tag ist nichts mehr zu tun + assert.deepEqual(await loescheEntwuerfe(new Date(JETZT.getTime() + TAG)), { geprueft: 5, geloescht: 0, uebrig: 0, fehler: 0 }) +}) + +test('Von E8 zurückgesetztes Dokument (off, purchaseStatus zurueckgesetzt): nach 91 Tagen bleiben Dokument und siteKey unverändert, eine vorhandene Einstellung wird gelöscht', async () => { + const zurueckgesetzt = { purchaseStatus: 'zurueckgesetzt', unlockedByPurchase: false, ticketId: '', activatedAt: null, disabledAt: null, disabledReason: null, zahlungMeldung: '', adminNote: 'Daten gelöscht (Löschfrist abgelaufen)' } + legeEntwurf(1, { tage: 91, doc: zurueckgesetzt }) // mit neuem Entwurf + legeEntwurf(2, { tage: 400, config: false, doc: zurueckgesetzt }) // ohne Einstellung + const vorher = [{ ...dok(1) }, { ...dok(2) }] + const erg = await loescheEntwuerfe(JETZT) + assert.deepEqual(erg, { geprueft: 2, geloescht: 1, uebrig: 0, fehler: 0 }) + assert.deepEqual(dok(1), vorher[0]) + assert.deepEqual(dok(2), vorher[1]) + assert.equal(dok(1).siteKey, SITE_KEY) + assert.equal(einstellung(1), undefined) +}) + +test('Nach fehlgeschlagener Zahlung (purchaseStatus failed) ist der Entwurf nach 90 Tagen weg, davor nicht', async () => { + legeEntwurf(1, { tage: 89, doc: { purchaseStatus: 'failed', stripeSessionId: 'cs_test_1', orderedAt: vor(89) } }) + legeEntwurf(2, { tage: 91, doc: { purchaseStatus: 'failed', stripeSessionId: 'cs_test_2', orderedAt: vor(91) } }) + const erg = await loescheEntwuerfe(JETZT) + assert.deepEqual(erg, { geprueft: 2, geloescht: 1, uebrig: 0, fehler: 0 }) + assert.ok(dok(1)) + assert.equal(dok(2), undefined) + assert.equal(einstellung(2), undefined) +}) + +// ---------------------------------------------------------------- frisch lesen unter den Sperren + +test('Rennen: wird der Entwurf bestellt, während der Lauf auf die Sperre feature-order wartet, liest der Lauf frisch und löscht nichts', async () => { + legeEntwurf(1) + let freigeben + const gehalten = new Promise((fertig) => (freigeben = fertig)) + const sperre = withLocks([`feature-order:${KUNDE}:${pid(1)}`], () => gehalten) + const lauf = loescheEntwuerfe(JETZT) + await warteBis(() => aw.listCalls('portalFeatures').length > 0) // der Kandidat steht in der Liste, jetzt wartet der Lauf + // gleich alt wie vorher (seed behält $updatedAt): nur das frische Lesen des Zustands kann das Löschen noch verhindern + aw.seed('portalFeatures', { ...dok(1), state: 'ordered', stripeSessionId: 'cs_test_9' }) + freigeben() + await sperre + const erg = await lauf + assert.deepEqual(erg, { geprueft: 1, geloescht: 0, uebrig: 0, fehler: 0 }) + assert.equal(dok(1).state, 'ordered') + assert.ok(einstellung(1), 'die Einstellung der Bestellung bleibt') +}) + +test('Rennen: dieselbe Lage unter feature-admin und termine-cfg (Team schaltet ein, Kunde speichert)', async () => { + legeEntwurf(1) + legeEntwurf(2) + let freigeben + const gehalten = new Promise((fertig) => (freigeben = fertig)) + const sperre = withLocks([`feature-admin:${pid(1)}`, `termine-cfg:${fid(2)}`], () => gehalten) + const lauf = loescheEntwuerfe(JETZT) + await warteBis(() => aw.listCalls('portalFeatures').length > 0) + aw.seed('portalFeatures', { ...dok(1), state: 'active', enabled: true }) + aw.seed('termineConfig', { ...einstellung(2), updatedAt: vor(1) }) // der Kunde hat den Entwurf gerade bearbeitet + freigeben() + await sperre + const erg = await lauf + assert.deepEqual(erg, { geprueft: 2, geloescht: 0, uebrig: 0, fehler: 0 }) + assert.equal(dok(1).state, 'active') + assert.ok(einstellung(1)) + assert.ok(dok(2)) + assert.ok(einstellung(2)) +}) + +test('Buchungen zur Funktion: nichts wird gelöscht, weder Einstellung noch Dokument', async () => { + legeEntwurf(1) + legeEntwurf(2) + aw.seed('termineBookings', { $id: 'b_aaaaaaaa1', featureId: fid(1), status: 'bestaetigt' }) + const erg = await loescheEntwuerfe(JETZT) + assert.deepEqual(erg, { geprueft: 2, geloescht: 1, uebrig: 0, fehler: 0 }) + assert.ok(dok(1)) + assert.ok(einstellung(1)) + assert.equal(dok(2), undefined) +}) + +// ---------------------------------------------------------------- Deckel + +test('130 Kandidaten: der erste Lauf löscht 100 und lässt 30 übrig, der zweite löscht die 30; die Liste wird seitenweise gelesen', async () => { + legeViele(100, 229) + let erg = await loescheEntwuerfe(JETZT) + assert.deepEqual(erg, { geprueft: 130, geloescht: 100, uebrig: 30, fehler: 0 }) + assert.equal(aw.docs('portalFeatures').size, 30) + assert.equal(aw.docs('termineConfig').size, 30) + const abfragen = aw.listCalls('portalFeatures') + assert.equal(abfragen.length, 2, 'zwei Seiten') + for (const abfrage of abfragen) { + assert.ok(abfrage.queries.some((q) => q.method === 'limit' && q.values[0] === 100), 'Seite zu 100') + assert.ok(abfrage.queries.some((q) => q.method === 'orderAsc' && q.attribute === '$id')) + } + assert.equal(abfragen[0].queries.some((q) => q.method === 'cursorAfter'), false) + assert.equal(abfragen[1].queries.find((q) => q.method === 'cursorAfter').values[0], fid(199), 'die zweite Seite beginnt nach dem 100. Dokument') + assert.match(logText(), /Rest folgt/, 'der Deckel steht im Log') + + erg = await loescheEntwuerfe(new Date(JETZT.getTime() + TAG)) + assert.deepEqual(erg, { geprueft: 30, geloescht: 30, uebrig: 0, fehler: 0 }) + assert.equal(aw.docs('portalFeatures').size, 0) + assert.equal(aw.docs('termineConfig').size, 0) +}) + +test('Übrig zählt nur Kandidaten, die der Deckel aufhält: junge Entwürfe in der Liste sind nie „offen“', async () => { + legeViele(100, 219) // 120 fällige + legeViele(220, 239, { tage: 10 }) // 20 junge + const erg = await loescheEntwuerfe(JETZT) + assert.deepEqual(erg, { geprueft: 140, geloescht: 100, uebrig: 20, fehler: 0 }) +}) + +test('Nach 2000 geprüften Dokumenten endet der Lauf mit einer Logzeile ohne Kennungen; genau 2000 enden ohne', async () => { + legeViele(1000, 2999, { tage: 10 }) // 2000 junge: nichts zu tun + let erg = await loescheEntwuerfe(JETZT) + assert.deepEqual(erg, { geprueft: 2000, geloescht: 0, uebrig: 0, fehler: 0 }) + assert.equal(log.warn.mock.calls.length, 0, 'genau 2000: der Lauf ist vollständig') + + legeEntwurf(3000, { tage: 10 }) + aw.resetListCalls() + erg = await loescheEntwuerfe(JETZT) + assert.deepEqual(erg, { geprueft: 2000, geloescht: 0, uebrig: 0, fehler: 0 }) + assert.equal(log.warn.mock.calls.length, 1) + assert.match(String(log.warn.mock.calls[0].arguments[0]), /^\[termine-entwuerfe\] .*2000.*nächsten Tagen/) + assert.equal(aw.listCalls('portalFeatures').length, 21, '21 Seiten: die 21. zeigt, dass es weitergeht') +}) + +// ---------------------------------------------------------------- Fehler + +test('Einzelfehler beim Löschen: fehler 1, die übrigen werden gelöscht, der Rest folgt am nächsten Tag', async () => { + legeViele(1, 3) + aw.failNext({ collection: 'termineConfig', method: 'DELETE', status: 500, message: 'Nachbau kaputt' }) // trifft den ersten Kandidaten + const erg = await loescheEntwuerfe(JETZT) + assert.deepEqual(erg, { geprueft: 3, geloescht: 2, uebrig: 0, fehler: 1 }) + assert.ok(dok(1), 'bei einem Fehler an der Einstellung bleibt auch das Dokument') + assert.ok(einstellung(1)) + assert.equal(dok(2), undefined) + assert.equal(dok(3), undefined) + assert.equal(log.error.mock.calls.length, 1) + assert.match(String(log.error.mock.calls[0].arguments[0]), /^\[termine-entwuerfe\] .*Nachbau kaputt/) + + const naechster = await loescheEntwuerfe(new Date(JETZT.getTime() + TAG)) + assert.deepEqual(naechster, { geprueft: 1, geloescht: 1, uebrig: 0, fehler: 0 }) + assert.equal(dok(1), undefined) +}) + +test('Scheitert nur das Löschen des Funktionsdokuments, ist die Einstellung weg und der nächste Lauf löscht das Dokument', async () => { + legeEntwurf(1) + aw.failNext({ collection: 'portalFeatures', method: 'DELETE', status: 500, message: 'Nachbau kaputt' }) + const erg = await loescheEntwuerfe(JETZT) + assert.deepEqual(erg, { geprueft: 1, geloescht: 0, uebrig: 0, fehler: 1 }) + assert.ok(dok(1)) + assert.equal(einstellung(1), undefined) + + assert.deepEqual(await loescheEntwuerfe(new Date(JETZT.getTime() + TAG)), { geprueft: 1, geloescht: 1, uebrig: 0, fehler: 0 }) + assert.equal(dok(1), undefined) +}) + +test('Ein Lesefehler beim Prüfen eines Kandidaten zählt als Fehler und löscht nichts; die übrigen Kandidaten laufen weiter', async () => { + legeViele(1, 2) + // 1. GET auf portalFeatures = die Liste, 2. = frisches Lesen des ersten Kandidaten + aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau kaputt', after: 1 }) + const erg = await loescheEntwuerfe(JETZT) + assert.deepEqual(erg, { geprueft: 2, geloescht: 1, uebrig: 0, fehler: 1 }) + assert.ok(dok(1)) + assert.ok(einstellung(1)) + assert.equal(dok(2), undefined) +}) + +test('Ist die Liste nicht lesbar, gibt es keinen Wurf: fehler 1, nichts geprüft', async () => { + legeEntwurf(1) + aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau kaputt' }) + assert.deepEqual(await loescheEntwuerfe(JETZT), { geprueft: 0, geloescht: 0, uebrig: 0, fehler: 1 }) + assert.ok(dok(1)) +}) + +test('Die Zeit muss ein gültiges Date sein', async () => { + await assert.rejects(() => loescheEntwuerfe(), TypeError) + await assert.rejects(() => loescheEntwuerfe('heute'), TypeError) +}) + +// ---------------------------------------------------------------- Kundenloeschung + +test('Kunde löschen: loescheEntwuerfeVonKunde entfernt alle off-Entwürfe des Kunden vollständig, unabhängig vom Alter und von Spuren', async () => { + legeEntwurf(1, { tage: 1 }) // frisch + legeEntwurf(2, { tage: 400, doc: { purchaseStatus: 'zurueckgesetzt' } }) // zurückgesetzt, der siteKey fällt mit dem Kunden + legeEntwurf(3, { tage: 5, doc: { activatedAt: vor(300), unlockedByPurchase: true } }) + legeEntwurf(4, { tage: 5, config: false }) + // nicht betroffen: andere Zustände, News, anderer Kunde + legeEntwurf(5, { tage: 400, doc: { state: 'active', enabled: true } }) + legeEntwurf(6, { tage: 400, doc: { state: 'ordered' } }) + legeEntwurf(7, { tage: 400, doc: { state: 'disabled', disabledAt: vor(5), disabledReason: 'kunde_geloescht' } }) + legeEntwurf(8, { tage: 400, key: 'news' }) + legeEntwurf(9, { tage: 400, kunde: ANDERER }) + + assert.deepEqual(await loescheEntwuerfeVonKunde(KUNDE), { geloescht: 4 }) + for (const nr of [1, 2, 3, 4]) { + assert.equal(dok(nr), undefined, `Funktionsdokument ${nr}`) + assert.equal(einstellung(nr), undefined, `Einstellung ${nr}`) + } + for (const nr of [5, 6, 7, 9]) { + assert.ok(dok(nr), `Funktionsdokument ${nr} bleibt`) + assert.ok(einstellung(nr), `Einstellung ${nr} bleibt`) + } + assert.ok(aw.docs('portalFeatures').get('f_p_e8_news')) + assert.deepEqual(await loescheEntwuerfeVonKunde(KUNDE), { geloescht: 0 }, 'ein zweiter Aufruf findet nichts mehr') + assert.deepEqual(await loescheEntwuerfeVonKunde('c_unbekannt'), { geloescht: 0 }) +}) + +test('Kunde löschen: Buchungen zur Funktion halten den Entwurf zurück, der Zustand wird unter den Sperren frisch gelesen', async () => { + legeEntwurf(1, { tage: 1 }) + legeEntwurf(2, { tage: 1 }) + aw.seed('termineBookings', { $id: 'b_aaaaaaaa1', featureId: fid(1), status: 'bestaetigt' }) + let freigeben + const gehalten = new Promise((fertig) => (freigeben = fertig)) + const sperre = withLocks([`feature-order:${KUNDE}:${pid(2)}`], () => gehalten) + aw.resetListCalls() + const lauf = loescheEntwuerfeVonKunde(KUNDE) + await warteBis(() => aw.listCalls('portalFeatures').length > 0) + aw.seed('portalFeatures', { ...dok(2), state: 'ordered', stripeSessionId: 'cs_test_9' }) + freigeben() + await sperre + assert.deepEqual(await lauf, { geloescht: 0 }) + assert.ok(dok(1)) + assert.ok(einstellung(1)) + assert.equal(dok(2).state, 'ordered') + assert.ok(einstellung(2)) +}) + +test('Kunde löschen: ein Fehler bei einem Entwurf hält die übrigen nicht auf und wird danach weitergereicht', async () => { + legeViele(1, 3, { tage: 1 }) + aw.failNext({ collection: 'termineConfig', method: 'DELETE', status: 500, message: 'Nachbau kaputt' }) + await assert.rejects(() => loescheEntwuerfeVonKunde(KUNDE), /Nachbau kaputt/) + assert.ok(dok(1), 'der Entwurf mit Fehler bleibt') + assert.equal(dok(2), undefined) + assert.equal(dok(3), undefined) + assert.deepEqual(await loescheEntwuerfeVonKunde(KUNDE), { geloescht: 1 }, 'ein zweiter Versuch holt ihn nach') +}) diff --git a/test/termineJobs.test.js b/test/termineJobs.test.js index 68a743a..f9a70c3 100644 --- a/test/termineJobs.test.js +++ b/test/termineJobs.test.js @@ -1410,7 +1410,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, Abo-Abgleich 0 geprüft \/ 0 abgeschaltet \/ 0 wieder ein \/ 0 Mails \/ 1 ausgenommen, 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, Entwürfe 0 gelöscht \(0 offen\), Abo-Abgleich 0 geprüft \/ 0 abgeschaltet \/ 0 wieder ein \/ 0 Mails \/ 1 ausgenommen, 0 Fehler, 1 aktive Betriebe$/) assert.doesNotMatch(zeile, /\bb_|\bf_|@|Mustermann|Salon/) config.termine.loeschDeckel = DECKEL_VORHER @@ -1418,7 +1418,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, Abo-Abgleich 0 geprüft \/ 0 abgeschaltet \/ 0 wieder ein \/ 0 Mails \/ 1 ausgenommen, 0 Fehler, 1 aktive Betriebe$/) + assert.match(zeile, /, 0 Mails nicht zugestellt, Entwürfe 0 gelöscht \(0 offen\), Abo-Abgleich 0 geprüft \/ 0 abgeschaltet \/ 0 wieder ein \/ 0 Mails \/ 1 ausgenommen, 0 Fehler, 1 aktive Betriebe$/) }) test('tagesbericht: das Segment zählt die Schritte aller Bereiche und fehlt bei gedeckelt 0', () => { @@ -2105,6 +2105,7 @@ test('Zurücksetzen: nach dem Zurücksetzen steht das Dokument auf off und ist w test('taeglicherJob: Löschfristen, Warteschlange, festgefahrene Mails, verwaiste Zellen und abgeschaltete Funktionen in einem Lauf', async () => { await richteFunktionEin(F1) await richteFunktionEin(F2, { state: 'disabled', disabledAt: iso(plus(JETZT, -45 * TAG)) }) + await legeEntwurf(F3, 91) // Entwurf: Terminplaner im Zustand off, seit 91 Tagen unverändert await legeBuchung('b_verfallen', { status: 'verfallen', heldUntil: iso(plus(JETZT, -3 * TAG)) }) await legeBuchung('b_erledigt', { status: 'erledigt', ende: plus(JETZT, -100 * TAG) }) await legeBuchung('b_zukunft', { start: plus(JETZT, 5 * TAG) }) @@ -2125,6 +2126,11 @@ test('taeglicherJob: Löschfristen, Warteschlange, festgefahrene Mails, verwaist assert.equal(erg.geloescht, 3, 'verfallen 1 + erledigt 1 + abgeschaltete Funktion 1') assert.equal(erg.aktiveBetriebe, 1, 'F1 ist aktiv, F2 abgeschaltet') assert.deepEqual(erg.aboAbgleich, { geprueft: 0, abgeschaltet: 0, wiederEin: 0, mails: 0, ausgenommen: 1, fehler: 0 }, 'F1 ist aktiv ohne Kauf (ausgenommen), F2 abgeschaltet ohne Grund (bleibt unberührt)') + // geprüft werden auch F2 (die Löschung hat es eben auf off zurückgesetzt: purchaseStatus zurueckgesetzt, bleibt) und F3 (Entwurf, weg) + assert.deepEqual(erg.entwuerfe, { geprueft: 2, geloescht: 1, uebrig: 0, fehler: 0 }, 'der Entwurf F3 ist nach 90 Tagen weg') + assert.equal(aw.docs('portalFeatures').get(F2).purchaseStatus, 'zurueckgesetzt', 'das zurückgesetzte Dokument F2 bleibt samt siteKey') + assert.equal(aw.docs('portalFeatures').get(F3), undefined) + assert.equal(aw.docs('termineConfig').get(F3), undefined) assert.equal(erg.aufgeraeumt, 1) assert.ok(erg.zellen >= 1) @@ -2143,7 +2149,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, Abo-Abgleich 0 geprüft / 0 abgeschaltet / 0 wieder ein / 0 Mails / 1 ausgenommen, 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, Entwürfe 1 gelöscht (0 offen), Abo-Abgleich 0 geprüft / 0 abgeschaltet / 0 wieder ein / 0 Mails / 1 ausgenommen, 0 Fehler, 1 aktive Betriebe`, ) assert.doesNotMatch(zeile, /\bb_|\bf_|@|Mustermann|Salon/) }) @@ -2158,7 +2164,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, Abo-Abgleich 0 geprüft \/ 0 abgeschaltet \/ 0 wieder ein \/ 0 Mails \/ 0 ausgenommen, 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, Entwürfe 0 gelöscht \(0 offen\), Abo-Abgleich 0 geprüft \/ 0 abgeschaltet \/ 0 wieder ein \/ 0 Mails \/ 0 ausgenommen, 0 Fehler, \d+ aktive Betriebe$/) assert.doesNotMatch(zeile, /\bb_|\bf_|@|Mustermann|Salon/) erg = await taeglicherJob(JETZT) @@ -2199,7 +2205,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, Abo-Abgleich 0 geprüft \/ 0 abgeschaltet \/ 0 wieder ein \/ 0 Mails \/ 0 ausgenommen, 0 Fehler, 0 aktive Betriebe$/) + assert.match(zeile, /Löschläufe am Deckel: 1, Rest folgt an den nächsten Tagen, Entwürfe 0 gelöscht \(0 offen\), Abo-Abgleich 0 geprüft \/ 0 abgeschaltet \/ 0 wieder ein \/ 0 Mails \/ 0 ausgenommen, 0 Fehler, 0 aktive Betriebe$/) erg = await taeglicherJob(JETZT) assert.equal(erg.zellen, 250) @@ -2242,7 +2248,7 @@ test('taeglicherJob: ist die Liste der abgeschalteten Funktionen nicht lesbar, z 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, Abo-Abgleich 0 geprüft \/ 0 abgeschaltet \/ 0 wieder ein \/ 0 Mails \/ 0 ausgenommen, 1 Fehler, \d+ aktive Betriebe$/) + assert.match(tagesbericht(erg), /, 0 Mails nicht zugestellt, Entwürfe 0 gelöscht \(0 offen\), Abo-Abgleich 0 geprüft \/ 0 abgeschaltet \/ 0 wieder ein \/ 0 Mails \/ 0 ausgenommen, 1 Fehler, \d+ aktive Betriebe$/) }) test('tagesbericht: keine Zeile, wenn der Job nicht gelaufen ist', () => { @@ -2285,7 +2291,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: 3 }) // 1. GET = Funktionen der Löschfristen, 2. = Abo-Abgleich, 3. = abgeschaltete, 4. = Zählung + aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau kaputt', after: 4 }) // 1. GET = Funktionen der Löschfristen, 2. = Abo-Abgleich, 3. = abgeschaltete, 4. = Entwürfe, 5. = Zählung const erg = await taeglicherJob(JETZT) assert.equal(erg.aktiveBetriebe, null) assert.equal(erg.fehler, 1) @@ -2302,7 +2308,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: 3 }) // die Zählung der aktiven Betriebe + aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau kaputt', after: 4 }) // die Zählung der aktiven Betriebe (5. GET: nach Löschfristen, Abo-Abgleich, abgeschaltete Funktionen, Entwürfe) const erg = await taeglicherJob(JETZT) assert.equal(erg.fehler, 1) assert.equal(team20().length, 1) @@ -2432,7 +2438,7 @@ test('taeglicherJob: der Abo-Abgleich steht im Ergebnis mit seinen Zählern; ein 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$/) + assert.match(tagesbericht(erg), /, 0 Mails nicht zugestellt, Entwürfe 0 gelöscht \(0 offen\), Abo-Abgleich 1 geprüft \/ 1 abgeschaltet \/ 0 wieder ein \/ 1 Mails \/ 1 ausgenommen, 0 Fehler, 1 aktive Betriebe$/) }) test('taeglicherJob: der Abgleich läuft vor der Löschung – ein Terminplaner, dessen Abo heute zurückkehrt, wird nicht gelöscht', async () => { @@ -2477,7 +2483,7 @@ test('taeglicherJob: ist die Funktionsliste des Abgleichs nicht lesbar, läuft d 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$/) + assert.match(tagesbericht(erg), /, 0 Mails nicht zugestellt, Entwürfe 0 gelöscht \(0 offen\), Abo-Abgleich 0 geprüft \/ 0 abgeschaltet \/ 0 wieder ein \/ 0 Mails \/ 0 ausgenommen, 1 Fehler, \d+ aktive Betriebe$/) }) test('tagesbericht: die vollständige Zeile gleicht gegen das Literal (Abo-Abgleich vor den Fehlern, aktive Betriebe zuletzt), ohne Kennungen und Adressen', async () => { @@ -2485,7 +2491,7 @@ test('tagesbericht: die vollständige Zeile gleicht gegen das Literal (Abo-Abgle 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', + '[termine] Tagesjob: 0 Buchungen gelöscht (verfallen 0, abgesagt 0, abgelehnt 0, erledigt 0, nichterschienen 0, abgeschaltete Funktionen 0), 0 Funktionen bereinigt, 0 Zellen, 0 Outbox, 0 Mails nicht zugestellt, Entwürfe 0 gelöscht (0 offen), Abo-Abgleich 0 geprüft / 0 abgeschaltet / 0 wieder ein / 0 Mails / 0 ausgenommen, 0 Fehler, 0 aktive Betriebe', ) assert.doesNotMatch(zeile, /\bb_|\bf_|@/) }) @@ -2498,20 +2504,93 @@ test('tagesbericht: mit Löschdeckel steht dessen Segment zwischen „Mails nich aufgeraeumt: 7, zellen: 8, aboAbgleich: { geprueft: 5, abgeschaltet: 1, wiederEin: 2, mails: 3, ausgenommen: 4, fehler: 0 }, + entwuerfe: { geprueft: 12, geloescht: 3, uebrig: 2, fehler: 0 }, fehler: 9, aktiveBetriebe: 12, } const ohne = tagesbericht({ ...basis, gedeckelt: { loeschfristen: 0, abgeschaltet: 0, zellen: 0 } }) assert.equal( ohne, - '[termine] Tagesjob: 21 Buchungen gelöscht (verfallen 1, abgesagt 2, abgelehnt 3, erledigt 4, nichterschienen 5, abgeschaltete Funktionen 6), 1 Funktionen bereinigt, 8 Zellen, 7 Outbox, 3 Mails nicht zugestellt, Abo-Abgleich 5 geprüft / 1 abgeschaltet / 2 wieder ein / 3 Mails / 4 ausgenommen, 9 Fehler, 12 aktive Betriebe', + '[termine] Tagesjob: 21 Buchungen gelöscht (verfallen 1, abgesagt 2, abgelehnt 3, erledigt 4, nichterschienen 5, abgeschaltete Funktionen 6), 1 Funktionen bereinigt, 8 Zellen, 7 Outbox, 3 Mails nicht zugestellt, Entwürfe 3 gelöscht (2 offen), Abo-Abgleich 5 geprüft / 1 abgeschaltet / 2 wieder ein / 3 Mails / 4 ausgenommen, 9 Fehler, 12 aktive Betriebe', ) const mit = tagesbericht({ ...basis, gedeckelt: { loeschfristen: 2, abgeschaltet: 1, zellen: 1 } }) assert.equal( mit, - '[termine] Tagesjob: 21 Buchungen gelöscht (verfallen 1, abgesagt 2, abgelehnt 3, erledigt 4, nichterschienen 5, abgeschaltete Funktionen 6), 1 Funktionen bereinigt, 8 Zellen, 7 Outbox, 3 Mails nicht zugestellt, Löschläufe am Deckel: 4, Rest folgt an den nächsten Tagen, Abo-Abgleich 5 geprüft / 1 abgeschaltet / 2 wieder ein / 3 Mails / 4 ausgenommen, 9 Fehler, 12 aktive Betriebe', + '[termine] Tagesjob: 21 Buchungen gelöscht (verfallen 1, abgesagt 2, abgelehnt 3, erledigt 4, nichterschienen 5, abgeschaltete Funktionen 6), 1 Funktionen bereinigt, 8 Zellen, 7 Outbox, 3 Mails nicht zugestellt, Löschläufe am Deckel: 4, Rest folgt an den nächsten Tagen, Entwürfe 3 gelöscht (2 offen), Abo-Abgleich 5 geprüft / 1 abgeschaltet / 2 wieder ein / 3 Mails / 4 ausgenommen, 9 Fehler, 12 aktive Betriebe', ) assert.doesNotMatch(mit, /\bb_|\bf_|@/) + // ohne Zähler der Entwürfe (ein Ergebnis aus einem früheren Lauf) fehlt das Segment, die übrige Zeile bleibt gleich + assert.equal(tagesbericht({ ...basis, entwuerfe: undefined, gedeckelt: { loeschfristen: 0, abgeschaltet: 0, zellen: 0 } }), ohne.replace('Entwürfe 3 gelöscht (2 offen), ', '')) + // unbrauchbare Zähler zählen als 0 + assert.match(tagesbericht({ ...basis, entwuerfe: { geloescht: 'x', uebrig: -1 }, gedeckelt: { loeschfristen: 0, abgeschaltet: 0, zellen: 0 } }), /Mails nicht zugestellt, Entwürfe 0 gelöscht \(0 offen\), Abo-Abgleich/) +}) + +// ---------------------------------------------------------------- Tagesjob: Entwürfe (C6) + +/** Entwurf: Terminplaner im Zustand off und seine Einstellung, beide seit `tage` Tagen unverändert. */ +async function legeEntwurf(id, tage, over = {}) { + const seit = iso(plus(JETZT, -tage * TAG)) + aw.seed('portalFeatures', { ...feature(id, { state: 'off', enabled: false, siteKey: 'entwurfsitekey23', ...over }), $updatedAt: seit }) + aw.seed('termineConfig', { $id: id, customerId: KUNDE, projectId: id === F1 ? PROJEKT : `p_${id}`, einstellung: '{}', version: 1, updatedAt: seit, updatedBy: 'kunde' }) +} + +test('taeglicherJob: Entwürfe nach 90 Tagen gelöscht (nach den abgeschalteten Funktionen), jüngere bleiben; Ergebnis und Berichtszeile tragen nur Zahlen', async () => { + await richteFunktionEin(F1) + await legeEntwurf(F2, 91) + await legeEntwurf(F3, 89) + const erg = await taeglicherJob(JETZT) + assert.deepEqual(erg.entwuerfe, { geprueft: 2, geloescht: 1, uebrig: 0, fehler: 0 }) + assert.equal(erg.fehler, 0) + assert.equal(aw.docs('portalFeatures').get(F2), undefined) + assert.equal(aw.docs('termineConfig').get(F2), undefined) + assert.ok(aw.docs('portalFeatures').get(F3)) + assert.ok(aw.docs('termineConfig').get(F3)) + assert.ok(aw.docs('portalFeatures').get(F1), 'die aktive Funktion bleibt') + assert.equal(erg.geloescht, 0, 'geloescht zählt Buchungen, keine Entwürfe') + const zeile = tagesbericht(erg) + assert.match(zeile, /, 0 Mails nicht zugestellt, Entwürfe 1 gelöscht \(0 offen\), Abo-Abgleich /) + assert.doesNotMatch(zeile, /\bb_|\bf_|\bp_|\bc_|@|Mustermann|Salon|Müller|entwurfsitekey/) +}) + +test('taeglicherJob: Entwürfe am Deckel – 100 je Lauf, der Rest zählt als offen und folgt am nächsten Tag', async () => { + for (let i = 0; i < 130; i += 1) await legeEntwurf(`f_p_entwurf${String(100 + i)}_termine`, 120) + let erg = await taeglicherJob(JETZT) + assert.deepEqual(erg.entwuerfe, { geprueft: 130, geloescht: 100, uebrig: 30, fehler: 0 }) + assert.match(tagesbericht(erg), /Entwürfe 100 gelöscht \(30 offen\), Abo-Abgleich /) + erg = await taeglicherJob(plus(JETZT, TAG)) + assert.deepEqual(erg.entwuerfe, { geprueft: 30, geloescht: 30, uebrig: 0, fehler: 0 }) + assert.equal(aw.docs('portalFeatures').size, 0) + assert.equal(aw.docs('termineConfig').size, 0) +}) + +test('taeglicherJob: Fehler beim Löschen eines Entwurfs zählen in fehler und in der Berichtszeile, die übrigen Schritte laufen weiter', async () => { + await legeEntwurf(F2, 120) + await legeBuchung('b_verfallen', { status: 'verfallen', heldUntil: iso(plus(JETZT, -3 * TAG)) }) + aw.failNext({ collection: 'termineConfig', method: 'DELETE', status: 500, message: 'Nachbau kaputt' }) + const erg = await taeglicherJob(JETZT) + assert.deepEqual(erg.entwuerfe, { geprueft: 1, geloescht: 0, uebrig: 0, fehler: 1 }) + assert.equal(erg.fehler, 1, 'der Fehler steht im Ergebnis des Tagesjobs') + assert.equal(erg.loeschfristen.verfallen, 1, 'die Löschfristen liefen') + assert.ok(aw.docs('portalFeatures').get(F2), 'der Entwurf bleibt und folgt am nächsten Tag') + assert.match(tagesbericht(erg), /Entwürfe 0 gelöscht \(0 offen\), Abo-Abgleich .*, 1 Fehler, /) + assert.ok(fehlerLog().some((zeile) => /\[termine-entwuerfe\]/.test(zeile) && /Nachbau kaputt/.test(zeile))) + assert.equal(team20().length, 1, 'das Team erfährt von den Fehlern') + + const folgetag = await taeglicherJob(plus(JETZT, TAG)) + assert.deepEqual(folgetag.entwuerfe, { geprueft: 1, geloescht: 1, uebrig: 0, fehler: 0 }) + assert.equal(aw.docs('portalFeatures').get(F2), undefined) +}) + +test('Ist die Liste der Entwürfe nicht lesbar, zählt der Schritt einen Fehler und die übrigen Schritte laufen', async () => { + await legeEntwurf(F2, 120) + await legeBuchung('b_verfallen', { status: 'verfallen', heldUntil: iso(plus(JETZT, -3 * TAG)) }) + // GET auf portalFeatures: 1. Funktionen der Löschfristen, 2. Abo-Abgleich, 3. abgeschaltete Funktionen, 4. Entwürfe + aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'Nachbau kaputt', after: 3 }) + const erg = await taeglicherJob(JETZT) + assert.deepEqual(erg.entwuerfe, { geprueft: 0, geloescht: 0, uebrig: 0, fehler: 1 }) + assert.equal(erg.fehler, 1) + assert.equal(erg.loeschfristen.verfallen, 1) + assert.ok(aw.docs('portalFeatures').get(F2)) }) // ---------------------------------------------------------------- Tagesjob bei TERMINE_ENABLED=off mit bezahlten Terminplanern (eingeschränkter Lauf) @@ -2552,6 +2631,25 @@ test('Modus off, bezahlter Terminplaner abgeschaltet seit 31 Tagen: Buchungen, Z assert.doesNotMatch(zeile, /\bb_|\bf_|\bp_|\bc_|@|aktive Betriebe/) }) +test('Modus off: der Entwurfs-Schritt gehört zum stillstehenden Teil – ein alter Entwurf bleibt, das Ergebnis trägt kein entwuerfe, die Zeile kein Entwürfe-Segment', async () => { + await richteFunktionEin(F1, abgeschaltetBezahlt(31)) // ein bezahlter Terminplaner hält den eingeschränkten Lauf am Laufen + await legeEntwurf(F2, 400) + config.termine.mode = 'off' + + const erg = await taeglicherJob(JETZT) + assert.equal(erg.eingeschraenkt, true) + assert.equal('entwuerfe' in erg, false) + assert.ok(aw.docs('portalFeatures').get(F2), 'der Entwurf bleibt') + assert.ok(aw.docs('termineConfig').get(F2)) + assert.doesNotMatch(tagesbericht(erg), /Entwürfe/) + // dieselbe Lage im Modus pilot: der Entwurf geht + config.termine.mode = 'pilot' + const pilot = await taeglicherJob(plus(JETZT, TAG)) + assert.deepEqual(pilot.entwuerfe, { geprueft: 2, geloescht: 1, uebrig: 0, fehler: 0 }, 'F1 (vom eingeschränkten Lauf zurückgesetzt, bleibt) und F2 (Entwurf, weg)') + assert.equal(aw.docs('portalFeatures').get(F1).purchaseStatus, 'zurueckgesetzt') + assert.equal(aw.docs('portalFeatures').get(F2), undefined) +}) + test('Modus off: am Löschdeckel zählt gedeckelt.abgeschaltet, letzterTagesjob und die Zeile nennen den Deckel (vor dem Abo-Abgleich), der Rest folgt am nächsten Tag', async () => { config.termine.loeschDeckel = 2 await abgeschaltetMitBuchungen(5, { zellen: false })