From a198c472bdb9914063673c9ad7c23c91856ebe7a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 03:40:07 +0000 Subject: [PATCH] =?UTF-8?q?=C3=96ffnungszeiten:=20Feiertags-Erinnerung=20u?= =?UTF-8?q?nd=20Jobs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit erinnereFeiertage(now): stündlich, nur bei Schalter pilot/on (pilot: nur freigegebene Kunden) und nur 08:00 bis 20:00 Berliner Zeit; je Funktion in setup/active zuerst der Merker sha1(featureId|datum) je Tag (409 = schon erinnert), danach eine Mail K25 mit allen neuen Tagen ohne BCC ans Team; Merker älter als 30 Tage werden gelöscht. zeitenAboJob (täglich) und zeitenErinnerungsJob (stündlich) mit eigener Überlappsperre, ohne Wurf; in server/index.js nach 240 s bzw. 300 s gestartet, mit unref. Co-Authored-By: Claude Opus 5.5 --- server/index.js | 24 ++ server/services/zeiten/erinnerung.js | 181 +++++++++++ server/services/zeiten/jobs.js | 61 ++++ test/zeitenErinnerung.test.js | 466 +++++++++++++++++++++++++++ test/zeitenJobs.test.js | 280 ++++++++++++++++ 5 files changed, 1012 insertions(+) create mode 100644 server/services/zeiten/erinnerung.js create mode 100644 server/services/zeiten/jobs.js create mode 100644 test/zeitenErinnerung.test.js create mode 100644 test/zeitenJobs.test.js diff --git a/server/index.js b/server/index.js index 72f0fa5..2462b31 100644 --- a/server/index.js +++ b/server/index.js @@ -26,6 +26,7 @@ import { purgeUnclaimedNewsImages } from './services/newsImages.js' import { runActivation } from './services/siteFeatures.js' import { runAboCheck, runAnalyseReconcile } from './services/analyse.js' import { fuenfMinutenJob, minutenJob, tagesbericht, taeglicherJob } from './services/termine/jobs.js' +import { zeitenAboJob, zeitenErinnerungsJob } from './services/zeiten/jobs.js' import giteaWebhookRoutes from './routes/webhook/gitea.js' import pushTimeRoutes from './routes/pushTime.js' import adminGiteaRoutes from './routes/admin/gitea.js' @@ -224,6 +225,29 @@ const server = app.listen(config.port, '0.0.0.0', () => { setTimeout(termineTagesJob, 180 * 1000).unref() setInterval(termineTagesJob, 24 * 60 * 60 * 1000).unref() + // Oeffnungszeiten live: taeglich der Abo-Abgleich (4 Minuten nach dem Start beginnend; laeuft unabhaengig vom Schalter ZEITEN_ENABLED, + // ohne Zeiten-Dokumente kostet er eine Abfrage) und stuendlich die Feiertags-Erinnerung (5 Minuten nach dem Start beginnend; nur bei + // Schalter pilot/on und nur 08:00 bis 20:00 Berliner Zeit, sonst kehrt jeder Lauf sofort zurueck). Beide Jobs ueberlappen nicht und + // werfen nie; hier faengt der Rahmen trotzdem alles Unerwartete ab. Im Log stehen keine Kundendaten. + const zeitenAbo = () => + zeitenAboJob(new Date()) + .then((r) => { + if (r.aboEnde || r.abgeschaltet || r.wiederEin || r.geloescht || r.mails || r.fehler) { + console.log(`[zeiten] Abo-Abgleich: ${r.aboEnde ?? 0} Abo-Ende erkannt, ${r.abgeschaltet ?? 0} abgeschaltet, ${r.wiederEin ?? 0} wieder eingeschaltet, ${r.geloescht ?? 0} gelöscht, ${r.mails ?? 0} Mail(s), ${r.fehler ?? 0} Fehler`) + } + }) + .catch((err) => console.warn('[zeiten] Abo-Abgleich fehlgeschlagen:', err.message)) + setTimeout(zeitenAbo, 240 * 1000).unref() + setInterval(zeitenAbo, 24 * 60 * 60 * 1000).unref() + const zeitenErinnerung = () => + zeitenErinnerungsJob(new Date()) + .then((r) => { + if (r.merker || r.mails || r.fehler) console.log(`[zeiten] Feiertags-Erinnerung: ${r.merker ?? 0} Tag(e) vorgemerkt, ${r.mails ?? 0} Mail(s), ${r.fehler ?? 0} Fehler`) + }) + .catch((err) => console.warn('[zeiten] Feiertags-Erinnerung fehlgeschlagen:', err.message)) + setTimeout(zeitenErinnerung, 300 * 1000).unref() + setInterval(zeitenErinnerung, 60 * 60 * 1000).unref() + // Neue Gitea-Repos erkennen, Zuordnungs-Mail senden, Erinnerungen/Wiederholungen (alle 10 Minuten) const repoAssign = () => runRepoAssignJob({ now: new Date() }).catch((err) => diff --git a/server/services/zeiten/erinnerung.js b/server/services/zeiten/erinnerung.js new file mode 100644 index 0000000..7a2e7dd --- /dev/null +++ b/server/services/zeiten/erinnerung.js @@ -0,0 +1,181 @@ +import { createHash } from 'node:crypto' +import { config } from '../../config.js' +import { createDocument, deleteDocument, getDocument, listAllDocuments, Query } from '../appwriteAdmin.js' +import { customerOf } from '../siteFeatures.js' +import { heute, plusTage } from '../termine/zeit.js' +import { pruefeNow } from '../termine/util.js' +import { k25Mail, sendeZeitenMail } from './mails.js' +import { zeitenFuerKunde, zeitenModus } from './schalter.js' +import { ladeZeiten } from './speicher.js' +import { erinnerungsTage, imErinnerungsfenster } from './zeitlogik.js' + +/** + * Feiertags-Erinnerung von Öffnungszeiten live (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, Abschnitt 7 und Z18). + * erinnereFeiertage(now) ist der Baustein des Stundenjobs (zeitenErinnerungsJob in jobs.js, angeschlossen in server/index.js); er kennt + * keine Überlappungssperre: der Aufrufer lässt nie zwei Läufe gleichzeitig laufen (der Merker verhindert trotzdem jede doppelte Mail). + * + * Wann: nur bei Schalter `pilot` oder `on` (bei `pilot` nur für Kunden der Pilotliste, zeitenFuerKunde) und nur zwischen 08:00 und + * 20:00 Berliner Zeit. Sonst kehrt der Lauf sofort zurück, ohne eine einzige Abfrage. + * + * Für welche: jedes portalFeatures-Dokument mit featureKey 'zeiten' im Zustand setup oder active (seitenweise gelesen), das gespeicherte + * Zeiten hat. Welche Tage: zeitlogik.erinnerungsTage (1 bis 7 Berliner Tage, Feiertage und „bitte prüfen“-Tage des Landes, ohne Tage, + * an denen der Hauptplan zu ist, eine Ausnahme gilt oder der Kunde schon eine Abweichung gespeichert hat). + * + * Höchstens einmal: je Tag zuerst der Merker (zeitenErinnerungen, Dokument-ID = sha1(`featureId|datum`), 36 Zeichen); 409 = der Tag wurde + * schon erinnert, er fehlt in der Mail. Danach EINE Mail K25 je Dokument mit allen neu vorgemerkten Tagen, ohne Kopie ans Team + * (bccTeam false, R17c). Der Merker steht VOR der Mail: Stirbt der Server dazwischen oder scheitert der Versand, geht für diese Tage nie + * eine Mail raus ("lieber einmal zu wenig als doppelt"); die Mail wird nicht wiederholt, der Fehler steht im Log und in `fehler`. + * Hat der Kunde keine Adresse (Kundendokument fehlt oder ohne E-Mail), wird gar kein Merker angelegt: sobald es eine Adresse gibt, + * erinnert der nächste Lauf noch an die Tage, die noch im Fenster liegen. + * + * Aufräumen (im selben Lauf, nach den Erinnerungen): Merker, deren Datum mehr als 30 Berliner Tage zurückliegt, werden gelöscht + * (Spezifikation 2.2; das Datum ist der Tag des Feiertags, nach dem Fenster von 7 Tagen sind die Merker längst wirkungslos). + * + * Fehler: je Dokument gefangen (Lesen, Merker, Mailbau und Versand): ein kaputtes Dokument stoppt weder die anderen noch den Lauf. Das Log + * nennt weder Namen noch Adressen, nur Funktions-IDs. + */ + +const KEY = 'zeiten' +/** Zustände, in denen die Funktion läuft und erinnert wird. */ +const LAUFENDE_ZUSTAENDE = ['setup', 'active'] +/** Merker, deren Datum länger als so viele Berliner Tage zurückliegt, werden gelöscht. */ +const MERKER_AUFBEWAHRUNG_TAGE = 30 +const SEITE = 100 + +const ADRESSE = /[^\s<>"',;()]+@[^\s<>"',;()]+/g +/** Fehlertext fürs Log: Name und gekürzte Meldung, E-Mail-Adressen unkenntlich. */ +const fehlerText = (err) => `${err?.name ?? 'Fehler'} ${String(err?.message ?? '').replace(ADRESSE, '[Adresse]').slice(0, 200)}` + +const portalFeatures = () => config.collections.portalFeatures +const merkerCollection = () => config.collections.zeitenErinnerungen + +/** Dokument-ID des Merkers: sha1(featureId|datum) in Hex, die ersten 36 Zeichen (Appwrite-Grenze, beginnt mit Ziffer oder Buchstabe). */ +export const merkerId = (featureId, datum) => createHash('sha1').update(`${featureId}|${datum}`).digest('hex').slice(0, 36) + +const hatAdresse = (kunde) => typeof kunde?.email === 'string' && kunde.email.trim() !== '' + +/** Projektname für die Mail; ein Lesefehler ergibt '' (die Mail geht trotzdem raus, der Merker steht schon). */ +async function projektName(projectId) { + try { + const projekt = await getDocument(config.collections.websiteProjects, projectId) + return projekt?.projectName || projekt?.subdomain || '' + } catch (err) { + if (err?.status !== 404) console.error(`[zeiten-erinnerung] Projekt ${projectId} nicht lesbar: ${fehlerText(err)}`) + return '' + } +} + +/** Je Tag den Merker anlegen; => die Einträge, die neu vorgemerkt wurden (409 = schon erinnert, fehlt). Andere Fehler zählen und überspringen den Tag. */ +async function merkeVor(doc, tage, ctx) { + const neu = [] + for (const eintrag of tage) { + try { + await createDocument(merkerCollection(), { featureId: doc.$id, datum: eintrag.tag, createdAt: ctx.jetzt }, merkerId(doc.$id, eintrag.tag)) + ctx.erg.merker += 1 + neu.push(eintrag) + } catch (err) { + if (err?.status === 409) continue + ctx.erg.fehler += 1 + console.error(`[zeiten-erinnerung] Merker für Funktion ${doc.$id} nicht angelegt: ${fehlerText(err)}`) + } + } + return neu +} + +/** Ein Dokument: Zeiten lesen, Tage bestimmen, Merker anlegen, K25 senden. Wirft bei Fehlern (der Aufrufer zählt fehler). */ +async function bearbeiteDokument(doc, ctx) { + let kunde // undefined = noch nicht gelesen + const holeKunde = async () => { + if (kunde === undefined) kunde = doc.customerId ? await customerOf(doc.customerId) : null + return kunde + } + const ohneAdresse = () => console.warn(`[zeiten-erinnerung] Funktion ${doc.$id}: Kunde nicht mehr vorhanden oder ohne E-Mail-Adresse, keine Erinnerung`) + + // Pilot: nur freigegebene Kunden (vor dem Lesen der Zeiten) + if (ctx.modus === 'pilot') { + const k = await holeKunde() + if (!hatAdresse(k)) return ohneAdresse() + if (!zeitenFuerKunde(k.email)) return undefined + } + + const zeiten = await ladeZeiten(doc.$id) + if (zeiten?.daten == null) return undefined // der Kunde hat noch nichts eingetragen + ctx.erg.geprueft += 1 + const tage = erinnerungsTage(zeiten.daten, ctx.now) + if (tage.length === 0) return undefined + + const k = await holeKunde() + if (!hatAdresse(k)) return ohneAdresse() + + const neu = await merkeVor(doc, tage, ctx) + if (neu.length === 0) return undefined // alles schon erinnert + + const projekt = await projektName(doc.projectId) + const { sent, reason } = await sendeZeitenMail(k, k25Mail({ kunde: k, projekt, tage: neu }), { bccTeam: false }) + if (sent) ctx.erg.mails += 1 + else if (reason !== 'keine_adresse') ctx.erg.fehler += 1 // der Merker bleibt: keine Wiederholung + return undefined +} + +/** Merker mit Datum vor dem Stichtag löschen. Ein Fehler bricht das Aufräumen ab (der nächste Lauf macht weiter). */ +async function raeumeAuf(ctx) { + const stichtag = plusTage(heute(ctx.now), -MERKER_AUFBEWAHRUNG_TAGE) + let geloescht = 0 + try { + const alte = await listAllDocuments(merkerCollection(), [Query.lessThan('datum', stichtag), Query.select(['datum'])], { pageSize: SEITE }) + for (const merker of alte) { + try { + await deleteDocument(merkerCollection(), merker.$id) + geloescht += 1 + } catch (err) { + if (err?.status === 404) continue // schon weg + throw err + } + } + } catch (err) { + ctx.erg.fehler += 1 + console.error(`[zeiten-erinnerung] Aufräumen alter Merker abgebrochen: ${fehlerText(err)}`) + } + if (geloescht > 0) console.log(`[zeiten-erinnerung] ${geloescht} alte Merker gelöscht`) +} + +/** + * Stündliche Feiertags-Erinnerung (siehe Kopf der Datei). Wirft nie (außer bei fehlendem oder ungültigem `now`, wie gleicheZeitenAboAb); + * jedes Problem steht im Log und in `fehler`. + * @param {Date} now Zeitpunkt des Laufs (Fenster, Tage und Aufräumen zählen nach der Berliner Zeit von now) + * @returns {Promise<{ geprueft: number, mails: number, merker: number, fehler: number }>} + * geprueft = Funktionen mit gespeicherten Zeiten, die in diesem Lauf bewertet wurden (freigegebener Kunde, Zustand setup/active); + * mails = versendete K25; merker = in diesem Lauf neu vorgemerkte Tage; fehler = Dokumente und Schritte, die scheiterten, sowie + * gescheiterte Mails + */ +export async function erinnereFeiertage(now) { + pruefeNow(now, 'erinnereFeiertage') + const erg = { geprueft: 0, mails: 0, merker: 0, fehler: 0 } + const modus = zeitenModus() + if (modus === 'off' || !imErinnerungsfenster(now)) return erg + const ctx = { erg, now, jetzt: now.toISOString(), modus } + + try { + let dokumente + try { + dokumente = await listAllDocuments(portalFeatures(), [Query.equal('featureKey', KEY), Query.equal('state', LAUFENDE_ZUSTAENDE)]) + } catch (err) { + erg.fehler += 1 + console.error(`[zeiten-erinnerung] Funktionen nicht lesbar, die Erinnerung entfällt: ${fehlerText(err)}`) + dokumente = [] + } + for (const doc of dokumente) { + try { + await bearbeiteDokument(doc, ctx) + } catch (err) { + erg.fehler += 1 + console.error(`[zeiten-erinnerung] Funktion ${doc.$id} nicht bearbeitet: ${fehlerText(err)}`) + } + } + await raeumeAuf(ctx) + } catch (err) { + erg.fehler += 1 + console.error(`[zeiten-erinnerung] Lauf abgebrochen: ${fehlerText(err)}`) + } + return erg +} diff --git a/server/services/zeiten/jobs.js b/server/services/zeiten/jobs.js new file mode 100644 index 0000000..1870fa8 --- /dev/null +++ b/server/services/zeiten/jobs.js @@ -0,0 +1,61 @@ +import { gleicheZeitenAboAb } from './aboAbgleich.js' +import { erinnereFeiertage } from './erinnerung.js' + +/** + * Hintergrund-Jobs von Öffnungszeiten live (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, Abschnitte 5 und 7). + * server/index.js ruft sie mit new Date() auf; `now` ist hier immer ein Parameter. + * + * zeitenAboJob täglich (240 s nach dem Start beginnend) Abo-Abgleich (aboAbgleich.js): Abo-Ende erkennen, nach 30 Tagen + * abschalten, bei Rückkehr wieder einschalten, nach weiteren 30 Tagen + * löschen. Eigener Tagesjob, nicht an den Schalter ZEITEN_ENABLED + * gekoppelt (wie beim Terminplaner): ein beendetes Abo muss auch bei + * Schalter off zum Abschalten und zur Löschung führen. Ohne Zeiten- + * Dokumente kostet er eine Abfrage. + * zeitenErinnerungsJob stündlich (300 s nach dem Start beginnend) Feiertags-Erinnerung (erinnerung.js): nur bei Schalter pilot/on, + * nur 08:00 bis 20:00 Berliner Zeit, Merker älter als 30 Tage löschen + * + * Gemeinsame Regeln: + * - Keine überlappenden Läufe, je Job: läuft ein Job noch, kehrt der nächste Takt sofort mit { uebersprungen: true } zurück (einmal + * im Log je Überlappung). Die Sperre ist ein Merker im Speicher des Prozesses (ein Server, ein Prozess); die Jobs sperren einander nicht. + * - Ein Job wirft nie: ein unerwarteter Fehler (etwa ein ungültiges `now`) steht im Log, das Ergebnis ist { fehler: 1 }. Die Bausteine + * melden einzelne Fehler selbst im Ergebnis (`fehler`). + * - Das Log nennt weder Namen noch Adressen von Kunden. + */ + +const laufend = new Set() +const ueberlappGemeldet = new Set() + +const fehlerText = (err) => `${err?.name ?? 'Fehler'} ${String(err?.message ?? '').slice(0, 200)}` + +/** Führt arbeit() aus, sofern dieser Job nicht schon läuft; fängt jeden Fehler. */ +async function fuehreAus(job, arbeit) { + if (laufend.has(job)) { + if (!ueberlappGemeldet.has(job)) { + ueberlappGemeldet.add(job) + console.warn(`[zeiten-jobs] ${job}: der vorige Lauf ist noch nicht fertig, dieser Takt wird übersprungen`) + } + return { uebersprungen: true } + } + laufend.add(job) + try { + return await arbeit() + } catch (err) { + console.error(`[zeiten-jobs] ${job} fehlgeschlagen: ${fehlerText(err)}`) + return { fehler: 1 } + } finally { + laufend.delete(job) + ueberlappGemeldet.delete(job) + } +} + +/** Täglicher Abo-Abgleich. => Ergebnis von gleicheZeitenAboAb | { uebersprungen: true } | { fehler: 1 } */ +export const zeitenAboJob = (now) => fuehreAus('zeitenAboJob', () => gleicheZeitenAboAb(now)) + +/** Stündliche Feiertags-Erinnerung. => Ergebnis von erinnereFeiertage | { uebersprungen: true } | { fehler: 1 } */ +export const zeitenErinnerungsJob = (now) => fuehreAus('zeitenErinnerungsJob', () => erinnereFeiertage(now)) + +/** Nur für Tests: alle Sperren freigeben. */ +export function resetZeitenJobsForTests() { + laufend.clear() + ueberlappGemeldet.clear() +} diff --git a/test/zeitenErinnerung.test.js b/test/zeitenErinnerung.test.js new file mode 100644 index 0000000..08e19e5 --- /dev/null +++ b/test/zeitenErinnerung.test.js @@ -0,0 +1,466 @@ +import { test, after, beforeEach, afterEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { startAppwriteMock } from './support/appwriteMock.js' +import { mailSink } from './support/mailSink.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' +import { ZEITEN_DATETIMES } from '../server/services/zeiten/schema.js' + +// Feiertags-Erinnerung von Öffnungszeiten live (Aufgabe 23, Spec 7 "Feiertags-Erinnerung", Z18): erinnereFeiertage(now) schickt je Funktion +// (setup/active) eine Mail K25 mit allen neuen Tagen in 1–7 Tagen, die Feiertag oder "bitte prüfen" sind. Je Tag zuerst der Merker +// sha1(featureId|datum) (409 = schon erinnert), danach erst die Mail: lieber einmal zu wenig als doppelt. Nur Schalter pilot/on (pilot: nur +// freigegebene Kunden), nur 08:00 bis 20:00 Berliner Zeit, kein BCC ans Team, Merker älter als 30 Tage werden gelöscht. +// Aufbau wie zeitenAboAbgleich.test.js: Appwrite-Nachbau mit Schema, Mails in der Senke, feste Zeit als Parameter. +const DATETIMES = { + ...ZEITEN_DATETIMES, + portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'k24At'], +} +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + oeffnungszeiten: schemaKeys('oeffnungszeiten'), + zeitenErinnerungen: schemaKeys('zeitenErinnerungen'), + }, + datetimes: DATETIMES, + defaultLimit: 25, +}) +after(() => aw.close()) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) + +const { config } = await import('../server/config.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { speichereZeiten } = await import('../server/services/zeiten/speicher.js') +const { resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js') +const { erinnereFeiertage } = await import('../server/services/zeiten/erinnerung.js') +after(() => setMailTransportForTests(null)) + +const EIN = new Date('2026-09-28T07:00:00.000Z') // Montag 28.09.2026, 09:00 Berlin (Sommerzeit): in 7 Tagen liegt Sa 03.10. (Einheit) +const ZWEI = new Date('2027-03-22T08:00:00.000Z') // Montag 22.03.2027, 09:00 Berlin: Karfreitag 26.03. und Ostermontag 29.03. +const KUNDE = 'c1' +const MODUS_VORHER = config.zeiten.mode +const PILOT_VORHER = [...config.zeiten.pilotEmails] +const BASIS_VORHER = config.publicBaseUrl + +const plusMs = (von, ms) => new Date(von.getTime() + ms) +const STUNDE = 3600 * 1000 +const sha36 = (featureId, datum) => createHash('sha1').update(`${featureId}|${datum}`).digest('hex').slice(0, 36) + +let sink +let log + +async function raeumeAuf() { + for (const collection of ['portalFeatures', 'oeffnungszeiten', 'zeitenErinnerungen', 'customers', 'websiteProjects']) { + for (const id of [...aw.docs(collection).keys()]) await deleteDocument(collection, id) + } + resetZeitenCacheForTests() + aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: 'silke@example.com' }) +} + +beforeEach(async () => { + await raeumeAuf() + aw.resetListCalls() + config.zeiten.mode = 'on' + config.zeiten.pilotEmails = [] + config.publicBaseUrl = 'https://project.webklar.com' + sink = mailSink() + setMailTransportForTests(sink) + log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}), log: mock.method(console, 'log', () => {}) } +}) +afterEach(() => { + // Das Log enthält nie Adresse oder Namen des Kunden + const text = Object.values(log).flatMap((m) => m.mock.calls.map((aufruf) => aufruf.arguments.map(String).join(' '))).join('\n') + mock.restoreAll() + config.zeiten.mode = MODUS_VORHER + config.zeiten.pilotEmails = PILOT_VORHER + config.publicBaseUrl = BASIS_VORHER + assert.doesNotMatch(text, /silke@example\.com|Silke|Müller|salon\.example/i, 'keine Adresse und kein Name des Kunden im Log') +}) + +// ---------------------------------------------------------------- Hilfen + +const funktionsId = (nr = 1) => `f_p${nr}_zeiten` + +/** Wochenplan: jeden Tag (auch Samstag und Sonntag) 09:00–18:00, damit jeder Feiertag eine Erinnerung auslöst. */ +const OFFEN = () => { + const t = {} + for (let d = 1; d <= 7; d += 1) t[d] = { modus: 'zeiten', fenster: [['09:00', '18:00']] } + return t +} +const eingabe = (tage = OFFEN()) => ({ + land: 'RP', + plaene: [{ id: 'p_aaaaaaa1', name: 'Standard', haupt: true, tage }], + feiertage: {}, + ausnahmen: [], + hinweise: [], + kontakt: { an: false, telefon: '', adresse: '', whatsapp: null }, +}) + +/** Funktion samt gespeicherten Zeiten (zeiten false: Dokument ohne Eingaben des Kunden). */ +async function legeFunktion(nr = 1, over = {}, { kunde = KUNDE, zeiten = true, tage = OFFEN() } = {}) { + aw.seed('websiteProjects', { $id: `p${nr}`, customerId: kunde, projectName: `Praxis ${nr}`, subdomain: `praxis${nr}` }) + aw.seed('portalFeatures', { + $id: funktionsId(nr), + customerId: kunde, + projectId: `p${nr}`, + featureKey: 'zeiten', + state: 'active', + enabled: true, + unlockedBy: 'abo', + siteKey: `abcdefghijklmno${'pqrstuv'[nr]}`, + ...over, + }) + const doc = aw.docs('portalFeatures').get(funktionsId(nr)) + if (zeiten) await speichereZeiten({ feature: doc, eingabe: eingabe(tage), version: 0, now: new Date('2026-09-20T10:00:00.000Z') }) + return doc +} + +const merker = () => [...aw.docs('zeitenErinnerungen').values()] +const mails = () => sink.sent +const logText = () => Object.values(log).flatMap((m) => m.mock.calls.map((aufruf) => aufruf.arguments.map(String).join(' '))).join('\n') + +/** Ein Merker wie ihn ein früherer Lauf angelegt hat. */ +const legeMerker = (featureId, datum, createdAt = '2026-09-27T10:00:00.000Z') => + aw.seed('zeitenErinnerungen', { $id: sha36(featureId, datum), featureId, datum, createdAt }) + +const ergebnis = (over) => ({ geprueft: 0, mails: 0, merker: 0, fehler: 0, ...over }) + +// ---------------------------------------------------------------- Mail und Merker + +test('eine Mail für zwei Feiertage im Fenster', async () => { + await legeFunktion(1) + + assert.deepEqual(await erinnereFeiertage(ZWEI), ergebnis({ geprueft: 1, mails: 1, merker: 2 })) + + assert.equal(mails().length, 1, 'eine Mail, nicht eine je Tag') + const mail = mails()[0] + assert.deepEqual(mail.to.map((a) => a.address), ['silke@example.com']) + assert.equal(mail.subject, 'Feiertage stehen an – stimmen Deine Zeiten?') + assert.match(mail.text, /Hallo Silke Müller,/) + assert.match(mail.text, /Fr 26\.03\. – Karfreitag\nMo 29\.03\. – Ostermontag/, 'beide Tage, in Datumsfolge') + assert.match(mail.text, /Praxis 1/, 'der Projektname steht in der Mail') + assert.match(mail.text, /https:\/\/project\.webklar\.com\/dashboard\.html#zeiten/, 'Knopf zum Editor') + assert.ok(!mail.bcc?.length, 'K25 geht ohne Kopie ans Team (R17c)') + + assert.deepEqual(merker().map((m) => m.datum).sort(), ['2027-03-26', '2027-03-29']) +}) + +test('Merker: Dokument-ID sha1(featureId|datum), 36 Zeichen, mit featureId, Datum und createdAt', async () => { + await legeFunktion(1) + await erinnereFeiertage(EIN) + + assert.equal(merker().length, 1) + const m = merker()[0] + assert.equal(m.$id, sha36(funktionsId(1), '2026-10-03')) + assert.match(m.$id, /^[0-9a-f]{36}$/) + assert.equal(m.featureId, funktionsId(1)) + assert.equal(m.datum, '2026-10-03') + assert.equal(new Date(m.createdAt).getTime(), EIN.getTime(), 'createdAt = Zeitpunkt des Laufs') +}) + +test('Sa 03.10. wird erinnert, wenn der Samstag im Hauptplan offen ist (Tag der Deutschen Einheit)', async () => { + await legeFunktion(1) + + assert.deepEqual(await erinnereFeiertage(EIN), ergebnis({ geprueft: 1, mails: 1, merker: 1 })) + assert.match(mails()[0].text, /Sa 03\.10\. – Tag der Deutschen Einheit/) + assert.doesNotMatch(mails()[0].text, /mehrere Tage/, 'ein Tag: Einzahl') +}) + +test('Tag, an dem der Hauptplan ohnehin geschlossen ist: keine Mail, kein Merker', async () => { + const tage = OFFEN() + tage[6] = { modus: 'zu', fenster: [] } // Samstag zu + await legeFunktion(1, {}, { tage }) + + assert.deepEqual(await erinnereFeiertage(EIN), ergebnis({ geprueft: 1 })) + assert.equal(mails().length, 0) + assert.equal(merker().length, 0) +}) + +test('zweiter Lauf: keine zweite Mail (Merker), auch eine Stunde und einen Tag später', async () => { + await legeFunktion(1) + assert.equal((await erinnereFeiertage(EIN)).mails, 1) + + assert.deepEqual(await erinnereFeiertage(EIN), ergebnis({ geprueft: 1 })) + assert.deepEqual(await erinnereFeiertage(plusMs(EIN, STUNDE)), ergebnis({ geprueft: 1 })) + assert.deepEqual(await erinnereFeiertage(plusMs(EIN, 24 * STUNDE)), ergebnis({ geprueft: 1 })) + assert.equal(mails().length, 1) + assert.equal(merker().length, 1) +}) + +test('Neustart zwischen Merker und Mail: Merker existiert, Mail fehlt ⇒ nichts senden', async () => { + await legeFunktion(1) + legeMerker(funktionsId(1), '2026-10-03') + + assert.deepEqual(await erinnereFeiertage(EIN), ergebnis({ geprueft: 1 })) + assert.equal(mails().length, 0, 'lieber einmal zu wenig als doppelt') + assert.equal(merker().length, 1) +}) + +test('nur die neuen Tage stehen in der Mail: ein Tag war schon vorgemerkt', async () => { + await legeFunktion(1) + legeMerker(funktionsId(1), '2027-03-26') // Karfreitag war schon dran + + assert.deepEqual(await erinnereFeiertage(ZWEI), ergebnis({ geprueft: 1, mails: 1, merker: 1 })) + assert.equal(mails().length, 1) + assert.match(mails()[0].text, /Mo 29\.03\. – Ostermontag/) + assert.doesNotMatch(mails()[0].text, /Karfreitag/) + assert.deepEqual(merker().map((m) => m.datum).sort(), ['2027-03-26', '2027-03-29']) +}) + +test('Heiligabend ist „bitte prüfen“: mit Hinweis in der Mail, alle drei Tage in einer Mail', async () => { + await legeFunktion(1) + + assert.deepEqual(await erinnereFeiertage(new Date('2026-12-22T08:00:00.000Z')), ergebnis({ geprueft: 1, mails: 1, merker: 3 })) + assert.match(mails()[0].text, /Do 24\.12\. – Heiligabend \(bitte prüfen\)\nFr 25\.12\. – 1\. Weihnachtstag\nSa 26\.12\. – 2\. Weihnachtstag/) +}) + +test('zwei Läufe gleichzeitig: genau eine Mail (der Merker entscheidet, wer sendet)', async () => { + await legeFunktion(1) + + const [a, b] = await Promise.all([erinnereFeiertage(EIN), erinnereFeiertage(EIN)]) + assert.equal(a.mails + b.mails, 1) + assert.equal(a.merker + b.merker, 1) + assert.equal(mails().length, 1) +}) + +test('zwei Funktionen eines Kunden: je Dokument eine Mail mit dem eigenen Projekt', async () => { + await legeFunktion(1) + await legeFunktion(2) + + assert.deepEqual(await erinnereFeiertage(EIN), ergebnis({ geprueft: 2, mails: 2, merker: 2 })) + assert.equal(mails().length, 2) + assert.deepEqual(mails().map((m) => /Praxis (\d)/.exec(m.text)[1]).sort(), ['1', '2']) + assert.deepEqual(merker().map((m) => m.featureId).sort(), [funktionsId(1), funktionsId(2)]) +}) + +// ---------------------------------------------------------------- Fenster und Schalter + +test('außerhalb 08–20 Uhr Berliner Zeit: nichts (keine Abfrage, kein Merker, keine Mail)', async () => { + await legeFunktion(1) + for (const [uhr, utc] of [['07:59', '05:59'], ['20:00', '18:00'], ['23:30', '21:30'], ['03:00', '01:00']]) { + aw.resetListCalls() + const jetzt = new Date(`2026-09-28T${utc}:00.000Z`) + assert.deepEqual(await erinnereFeiertage(jetzt), ergebnis(), `${uhr} Berlin`) + assert.equal(aw.listCalls().length, 0, `${uhr}: nicht einmal eine Abfrage`) + } + assert.equal(mails().length, 0) + assert.equal(merker().length, 0) + + // Grenzen: 08:00 und 19:59 gehören noch zum Fenster + assert.equal((await erinnereFeiertage(new Date('2026-09-28T06:00:00.000Z'))).mails, 1, '08:00 Berlin') + await raeumeAuf() + await legeFunktion(1) + assert.equal((await erinnereFeiertage(new Date('2026-09-28T17:59:00.000Z'))).mails, 1, '19:59 Berlin') +}) + +test('Schalter off: nichts, auch keine Abfrage', async () => { + await legeFunktion(1) + config.zeiten.mode = 'off' + + assert.deepEqual(await erinnereFeiertage(EIN), ergebnis()) + assert.equal(aw.listCalls().length, 0) + assert.equal(mails().length, 0) + assert.equal(merker().length, 0) + + config.zeiten.mode = 'unbekannt' + assert.deepEqual(await erinnereFeiertage(EIN), ergebnis(), 'ein unbekannter Wert gilt als off') + assert.equal(mails().length, 0) +}) + +test('pilot: nur Kunden der Pilotliste; die anderen bekommen weder Mail noch Merker', async () => { + aw.seed('customers', { $id: 'c2', name: 'Peter Fremd', email: 'peter@salon.example' }) + await legeFunktion(1) + await legeFunktion(2, {}, { kunde: 'c2' }) + config.zeiten.mode = 'pilot' + config.zeiten.pilotEmails = ['silke@example.com'] + + assert.deepEqual(await erinnereFeiertage(EIN), ergebnis({ geprueft: 1, mails: 1, merker: 1 })) + assert.deepEqual(mails()[0].to.map((a) => a.address), ['silke@example.com']) + assert.deepEqual(merker().map((m) => m.featureId), [funktionsId(1)]) + + // Pilotliste leer: niemand + await raeumeAuf() + aw.seed('customers', { $id: 'c2', name: 'Peter Fremd', email: 'peter@salon.example' }) + await legeFunktion(1) + config.zeiten.pilotEmails = [] + const vorher = mails().length + assert.deepEqual(await erinnereFeiertage(EIN), ergebnis()) + assert.equal(mails().length, vorher) + + // '*' = alle + config.zeiten.pilotEmails = ['*'] + assert.equal((await erinnereFeiertage(EIN)).mails, 1) +}) + +test('Schalter on: alle Kunden', async () => { + aw.seed('customers', { $id: 'c2', name: 'Peter Fremd', email: 'peter@salon.example' }) + await legeFunktion(1) + await legeFunktion(2, {}, { kunde: 'c2' }) + config.zeiten.pilotEmails = [] // die Pilotliste zählt bei on nicht + + assert.deepEqual(await erinnereFeiertage(EIN), ergebnis({ geprueft: 2, mails: 2, merker: 2 })) + assert.deepEqual(mails().flatMap((m) => m.to.map((a) => a.address)).sort(), ['peter@salon.example', 'silke@example.com']) +}) + +// ---------------------------------------------------------------- Welche Funktionen + +test('nur Zustand setup und active; setup zählt, andere Zustände und andere Funktionen nicht', async () => { + await legeFunktion(1, { state: 'setup' }) + await legeFunktion(2, { state: 'active' }) + await legeFunktion(3, { state: 'disabled', disabledReason: 'abo_ende' }) + await legeFunktion(4, { state: 'ordered' }) + await legeFunktion(5, { state: 'off' }) + await legeFunktion(6, { featureKey: 'termine' }) + await legeFunktion(7, { featureKey: 'news' }) + + assert.deepEqual(await erinnereFeiertage(EIN), ergebnis({ geprueft: 2, mails: 2, merker: 2 })) + assert.deepEqual(merker().map((m) => m.featureId).sort(), [funktionsId(1), funktionsId(2)]) +}) + +test('Funktion ohne gespeicherte Zeiten: übersprungen (nichts zu erinnern)', async () => { + await legeFunktion(1, {}, { zeiten: false }) + assert.deepEqual(await erinnereFeiertage(EIN), ergebnis()) + + // Dokument vom Team angelegt, aber noch ohne Eingaben des Kunden (daten null) + aw.seed('oeffnungszeiten', { $id: funktionsId(1), customerId: KUNDE, projectId: 'p1', featureId: funktionsId(1), version: 0, teamVersion: 1, team: '{}', updatedAt: EIN.toISOString(), updatedBy: 'team:s1' }) + assert.deepEqual(await erinnereFeiertage(EIN), ergebnis()) + assert.equal(mails().length, 0) + assert.equal(merker().length, 0) +}) + +test('Kunde fehlt oder ohne E-Mail-Adresse: übersprungen, kein Merker, Log ohne Daten', async () => { + await legeFunktion(1, {}, { kunde: 'c_weg' }) // Kundendokument fehlt + aw.seed('customers', { $id: 'c_leer', name: 'Ohne Adresse', email: '' }) + await legeFunktion(2, {}, { kunde: 'c_leer' }) + + const erg = await erinnereFeiertage(EIN) + assert.equal(erg.mails, 0) + assert.equal(erg.merker, 0, 'ohne Adresse wird kein Merker verbraucht: der Tag kann später erinnert werden') + assert.equal(erg.fehler, 0, 'kein Fehler des Laufs') + assert.equal(mails().length, 0) + assert.equal(merker().length, 0) + assert.match(logText(), /Funktion f_p1_zeiten/) + assert.match(logText(), /Funktion f_p2_zeiten/) + + // Adresse nachgetragen: der nächste Lauf erinnert + aw.seed('customers', { $id: 'c_leer', name: 'Ohne Adresse', email: 'neu@example.com' }) + assert.deepEqual(await erinnereFeiertage(EIN), ergebnis({ geprueft: 2, mails: 1, merker: 1 })) + assert.deepEqual(mails()[0].to.map((a) => a.address), ['neu@example.com']) +}) + +// ---------------------------------------------------------------- Fehler + +test('Mail scheitert: Merker bleibt (höchstens einmal), Fehler gezählt, der Lauf geht weiter', async () => { + aw.seed('customers', { $id: 'c2', name: 'Peter Fremd', email: 'peter@salon.example' }) + await legeFunktion(1) + await legeFunktion(2, {}, { kunde: 'c2' }) + // der erste Versand scheitert, der zweite geht durch + const roh = sink.sendMail.bind(sink) + let aufrufe = 0 + sink.sendMail = async (mail) => { + aufrufe += 1 + if (aufrufe === 1) throw new Error('SMTP down für silke@example.com') + return roh(mail) + } + + assert.deepEqual(await erinnereFeiertage(EIN), ergebnis({ geprueft: 2, mails: 1, merker: 2, fehler: 1 })) + assert.equal(mails().length, 1) + assert.equal(merker().length, 2, 'beide Merker stehen, auch der der gescheiterten Mail') + + // der nächste Lauf wiederholt nicht + assert.deepEqual(await erinnereFeiertage(EIN), ergebnis({ geprueft: 2 })) + assert.equal(mails().length, 1) +}) + +test('Lesefehler bei einem Dokument stoppt weder die anderen noch den Lauf', async () => { + await legeFunktion(1) + await legeFunktion(2) + // die erste Abfrage der Zeiten (Dokument f_p1_zeiten, Reihenfolge nach $id) scheitert + aw.failNext({ collection: 'oeffnungszeiten', method: 'GET', status: 500, message: 'kaputt' }) + + const erg = await erinnereFeiertage(EIN) + assert.equal(erg.fehler, 1) + assert.equal(erg.mails, 1, 'das zweite Dokument wird trotzdem erinnert') + assert.equal(merker().length, 1) +}) + +test('Merker lässt sich nicht anlegen (500): keine Mail für den Tag, Fehler gezählt, nächster Lauf versucht es wieder', async () => { + await legeFunktion(1) + aw.failNext({ collection: 'zeitenErinnerungen', method: 'POST', status: 500, message: 'db down' }) + + assert.deepEqual(await erinnereFeiertage(EIN), ergebnis({ geprueft: 1, fehler: 1 })) + assert.equal(mails().length, 0, 'ohne Merker keine Mail') + assert.equal(merker().length, 0) + + assert.deepEqual(await erinnereFeiertage(plusMs(EIN, STUNDE)), ergebnis({ geprueft: 1, mails: 1, merker: 1 })) +}) + +test('Funktionen nicht lesbar: kein Wurf, Fehler gezählt', async () => { + await legeFunktion(1) + aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'db down' }) + + assert.deepEqual(await erinnereFeiertage(EIN), ergebnis({ fehler: 1 })) + assert.equal(mails().length, 0) +}) + +test('ohne gültiges now: TypeError (wie der Abo-Abgleich, die Jobs fangen ihn)', async () => { + await assert.rejects(() => erinnereFeiertage(undefined), TypeError) + await assert.rejects(() => erinnereFeiertage(new Date('kaputt')), TypeError) +}) + +// ---------------------------------------------------------------- Aufräumen + +test('alte Merker werden aufgeräumt: Datum mehr als 30 Tage her', async () => { + await legeFunktion(1) + // heute (Berlin) = 2026-09-28; Grenze: 2026-08-29 bleibt (genau 30 Tage), 2026-08-28 und älter geht + legeMerker('f_alt', '2026-08-28') + legeMerker('f_alt', '2026-01-02') + legeMerker('f_alt', '2026-08-29') + legeMerker('f_alt', '2026-09-01') + legeMerker('f_neu', '2026-10-03') + + await erinnereFeiertage(EIN) + + assert.deepEqual(merker().map((m) => m.datum).sort(), ['2026-08-29', '2026-09-01', '2026-10-03', '2026-10-03']) + assert.ok(!aw.docs('zeitenErinnerungen').has(sha36('f_alt', '2026-08-28'))) + assert.ok(!aw.docs('zeitenErinnerungen').has(sha36('f_alt', '2026-01-02'))) + assert.ok(aw.docs('zeitenErinnerungen').has(sha36('f_alt', '2026-08-29'))) +}) + +test('Aufräumen auch ohne Funktionen, aber nur im Fenster und nur bei Schalter pilot/on', async () => { + legeMerker('f_alt', '2026-01-02') + + config.zeiten.mode = 'off' + await erinnereFeiertage(EIN) + assert.equal(merker().length, 1, 'Schalter off: nichts angefasst') + + config.zeiten.mode = 'on' + await erinnereFeiertage(new Date('2026-09-28T05:00:00.000Z')) // 07:00 Berlin + assert.equal(merker().length, 1, 'außerhalb des Fensters: nichts angefasst') + + await erinnereFeiertage(EIN) + assert.equal(merker().length, 0) +}) + +test('mehr alte Merker als eine Seite: alle werden gelöscht', async () => { + for (let i = 0; i < 130; i += 1) legeMerker(`f_alt${i}`, '2026-01-02') + legeMerker('f_neu', '2026-10-03') + + await erinnereFeiertage(EIN) + assert.deepEqual(merker().map((m) => m.datum), ['2026-10-03']) +}) + +test('Löschen eines alten Merkers scheitert: Fehler gezählt, die Erinnerung läuft trotzdem', async () => { + await legeFunktion(1) + legeMerker('f_alt', '2026-01-02') + aw.failNext({ collection: 'zeitenErinnerungen', method: 'DELETE', status: 500, message: 'db down' }) + + const erg = await erinnereFeiertage(EIN) + assert.equal(erg.mails, 1) + assert.equal(erg.fehler, 1) + assert.ok(aw.docs('zeitenErinnerungen').has(sha36('f_alt', '2026-01-02')), 'bleibt für den nächsten Lauf') + + assert.equal((await erinnereFeiertage(plusMs(EIN, STUNDE))).fehler, 0) + assert.ok(!aw.docs('zeitenErinnerungen').has(sha36('f_alt', '2026-01-02'))) +}) diff --git a/test/zeitenJobs.test.js b/test/zeitenJobs.test.js new file mode 100644 index 0000000..519d7e9 --- /dev/null +++ b/test/zeitenJobs.test.js @@ -0,0 +1,280 @@ +import { test, after, beforeEach, afterEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { startAppwriteMock } from './support/appwriteMock.js' +import { mailSink } from './support/mailSink.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' +import { ZEITEN_DATETIMES } from '../server/services/zeiten/schema.js' + +// Hintergrund-Jobs von Öffnungszeiten live (Aufgabe 23): zeitenAboJob (täglich, Abo-Abgleich) und zeitenErinnerungsJob (stündlich, +// Feiertags-Erinnerung) mit eigener Überlappsperre je Job, ohne Wurf, plus Quelltext-Pin der Einbindung in server/index.js. +const DATETIMES = { + ...ZEITEN_DATETIMES, + portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'k24At'], +} +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + subscriptions: schemaKeys('subscriptions'), + contractEvents: schemaKeys('contractEvents'), + oeffnungszeiten: schemaKeys('oeffnungszeiten'), + zeitenErinnerungen: schemaKeys('zeitenErinnerungen'), + }, + datetimes: DATETIMES, + defaultLimit: 25, +}) +after(() => aw.close()) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) + +const { config } = await import('../server/config.js') +const { subscriptionDoc, wartungDoc } = await import('./support/fixtures.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { speichereZeiten } = await import('../server/services/zeiten/speicher.js') +const { resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js') +const { zeitenAboJob, zeitenErinnerungsJob, resetZeitenJobsForTests } = await import('../server/services/zeiten/jobs.js') +after(() => setMailTransportForTests(null)) + +const JETZT = new Date('2026-09-28T07:00:00.000Z') // Montag 28.09.2026, 09:00 Berlin: Sa 03.10. (Einheit) liegt in 5 Tagen +const KUNDE = 'c1' +const MODUS_VORHER = config.zeiten.mode +const PILOT_VORHER = [...config.zeiten.pilotEmails] +const BASIS_VORHER = config.publicBaseUrl +const ZUVOR = '2026-09-20T10:00:00.000Z' + +let sink +let log + +const OFFEN = () => { + const t = {} + for (let d = 1; d <= 7; d += 1) t[d] = { modus: 'zeiten', fenster: [['09:00', '18:00']] } + return t +} +const EINGABE = () => ({ + land: 'RP', + plaene: [{ id: 'p_aaaaaaa1', name: 'Standard', haupt: true, tage: OFFEN() }], + feiertage: {}, + ausnahmen: [], + hinweise: [], + kontakt: { an: false, telefon: '', adresse: '', whatsapp: null }, +}) + +/** Funktion "Öffnungszeiten live" im Wartungs-Abo (aktiv, Zeiten gespeichert). */ +async function legeFunktion() { + aw.seed('websiteProjects', { $id: 'p1', customerId: KUNDE, projectName: 'Praxis Muster', subdomain: 'praxis1' }) + aw.seed('portalFeatures', { + $id: 'f_p1_zeiten', + customerId: KUNDE, + projectId: 'p1', + featureKey: 'zeiten', + state: 'active', + enabled: true, + unlockedBy: 'abo', + unlockedByPurchase: false, + siteKey: 'abcdefghijklmnop', + ticketId: 't_1', + einbau: 'angestossen', + k24At: ZUVOR, + activatedAt: ZUVOR, + }) + const doc = aw.docs('portalFeatures').get('f_p1_zeiten') + await speichereZeiten({ feature: doc, eingabe: EINGABE(), version: 0, now: new Date(ZUVOR) }) + return doc +} +const abgelaufen = () => aw.seed('subscriptions', subscriptionDoc(`sub_${KUNDE}`, { ...wartungDoc, customerId: KUNDE, status: 'canceled', endedAt: ZUVOR, canceledAt: ZUVOR })) + +async function raeumeAuf() { + for (const collection of ['portalFeatures', 'subscriptions', 'contractEvents', 'oeffnungszeiten', 'zeitenErinnerungen', 'customers', 'websiteProjects']) { + for (const id of [...aw.docs(collection).keys()]) await deleteDocument(collection, id) + } + resetZeitenCacheForTests() + aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: 'silke@example.com' }) +} + +beforeEach(async () => { + await raeumeAuf() + resetZeitenJobsForTests() + config.zeiten.mode = 'on' + config.zeiten.pilotEmails = [] + config.publicBaseUrl = 'https://project.webklar.com' + sink = mailSink() + setMailTransportForTests(sink) + log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}), log: mock.method(console, 'log', () => {}) } +}) +afterEach(() => { + const text = Object.values(log).flatMap((m) => m.mock.calls.map((aufruf) => aufruf.arguments.map(String).join(' '))).join('\n') + mock.restoreAll() + config.zeiten.mode = MODUS_VORHER + config.zeiten.pilotEmails = PILOT_VORHER + config.publicBaseUrl = BASIS_VORHER + resetZeitenJobsForTests() + assert.doesNotMatch(text, /silke@example\.com|Silke|Müller/i, 'keine Adresse und kein Name des Kunden im Log') +}) + +const warnLog = () => log.warn.mock.calls.map((aufruf) => aufruf.arguments.map(String).join(' ')) +const errorLog = () => log.error.mock.calls.map((aufruf) => aufruf.arguments.map(String).join(' ')) + +/** + * Hält die erste Listenabfrage auf portalFeatures an, bis freigeben() gerufen wird: der Job "hängt", ein zweiter Takt trifft auf den + * laufenden. Gibt { freigeben, erreicht } zurück (erreicht: Promise, sobald die Abfrage angehalten wurde). + */ +function haengeAn() { + const echt = globalThis.fetch + let freigeben + const tor = new Promise((resolve) => { freigeben = resolve }) + let erreicht + const angehalten = new Promise((resolve) => { erreicht = resolve }) + let offen = true + globalThis.fetch = async (url, optionen) => { + if (offen && (optionen?.method ?? 'GET') === 'GET' && /\/collections\/portalFeatures\/documents(\?|$)/.test(decodeURIComponent(String(url)))) { + offen = false + erreicht() + await tor + } + return echt(url, optionen) + } + return { + erreicht: angehalten, + freigeben: () => { + freigeben() + globalThis.fetch = echt + }, + } +} + +// ---------------------------------------------------------------- Abo-Job + +test('zeitenAboJob: führt den Abo-Abgleich aus (Abo-Ende erkannt, K26) und gibt dessen Ergebnis zurück', async () => { + await legeFunktion() + abgelaufen() + + const erg = await zeitenAboJob(JETZT) + assert.deepEqual(erg, { geprueft: 1, aboEnde: 1, abgeschaltet: 0, wiederEin: 0, geloescht: 0, mails: 1, fehler: 0 }) + assert.equal(sink.sent.length, 1) + assert.match(sink.sent[0].subject, /^Dein Wartungs-Abo ist beendet/) +}) + +test('zeitenAboJob: läuft auch bei ZEITEN_ENABLED=off (ein beendetes Abo muss auch dann abgeschaltet werden, wie beim Terminplaner)', async () => { + await legeFunktion() + abgelaufen() + config.zeiten.mode = 'off' + + assert.equal((await zeitenAboJob(JETZT)).aboEnde, 1) +}) + +test('zeitenAboJob: ohne Funktionen kostet er eine Abfrage und ist wirkungslos', async () => { + aw.resetListCalls() + assert.deepEqual(await zeitenAboJob(JETZT), { geprueft: 0, aboEnde: 0, abgeschaltet: 0, wiederEin: 0, geloescht: 0, mails: 0, fehler: 0 }) + assert.deepEqual(aw.listCalls().map((a) => a.collection), ['portalFeatures']) +}) + +// ---------------------------------------------------------------- Erinnerungs-Job + +test('zeitenErinnerungsJob: führt die Feiertags-Erinnerung aus und gibt deren Ergebnis zurück', async () => { + await legeFunktion() + + assert.deepEqual(await zeitenErinnerungsJob(JETZT), { geprueft: 1, mails: 1, merker: 1, fehler: 0 }) + assert.equal(sink.sent.length, 1) + assert.equal(sink.sent[0].subject, 'Feiertage stehen an – stimmen Deine Zeiten?') + assert.deepEqual(await zeitenErinnerungsJob(JETZT), { geprueft: 1, mails: 0, merker: 0, fehler: 0 }, 'zweiter Takt: nichts mehr') +}) + +test('zeitenErinnerungsJob: Schalter off oder außerhalb des Fensters: nichts', async () => { + await legeFunktion() + config.zeiten.mode = 'off' + assert.deepEqual(await zeitenErinnerungsJob(JETZT), { geprueft: 0, mails: 0, merker: 0, fehler: 0 }) + config.zeiten.mode = 'on' + assert.deepEqual(await zeitenErinnerungsJob(new Date('2026-09-28T18:00:00.000Z')), { geprueft: 0, mails: 0, merker: 0, fehler: 0 }, '20:00 Berlin') + assert.equal(sink.sent.length, 0) +}) + +// ---------------------------------------------------------------- Überlappung und Fehler + +test('Jobs überlappen nicht: ein zweiter Takt, solange der erste läuft, kehrt sofort mit uebersprungen zurück', async () => { + for (const [name, job, folge] of [ + ['zeitenAboJob', zeitenAboJob, (erg) => assert.equal(erg.geprueft, 0)], + ['zeitenErinnerungsJob', zeitenErinnerungsJob, (erg) => assert.equal(erg.mails, 0)], + ]) { + await raeumeAuf() + resetZeitenJobsForTests() + log.warn.mock.resetCalls() + const tor = haengeAn() + const erster = job(JETZT) + await tor.erreicht + + assert.deepEqual(await job(JETZT), { uebersprungen: true }, `${name}: zweiter Takt`) + assert.deepEqual(await job(JETZT), { uebersprungen: true }, `${name}: dritter Takt`) + assert.equal(warnLog().filter((zeile) => new RegExp(`${name}.*übersprungen`).test(zeile)).length, 1, `${name}: einmal im Log je Überlappung`) + + tor.freigeben() + const erg = await erster + assert.equal(erg.uebersprungen, undefined, `${name}: der erste Lauf endet normal`) + folge(erg) + + // danach ist die Sperre frei, und die Warnung gilt wieder für die nächste Überlappung + const nochmal = await job(JETZT) + assert.equal(nochmal.uebersprungen, undefined, `${name}: der nächste Takt läuft wieder`) + } +}) + +test('die beiden Jobs sperren sich nicht gegenseitig', async () => { + const tor = haengeAn() + const abo = zeitenAboJob(JETZT) + await tor.erreicht + // der Abo-Abgleich hängt, die Erinnerung (andere Abfrage, nur die erste wird angehalten) läuft trotzdem + const erinnerung = await zeitenErinnerungsJob(JETZT) + assert.equal(erinnerung.uebersprungen, undefined) + tor.freigeben() + assert.equal((await abo).uebersprungen, undefined) +}) + +test('die Sperre wird auch nach einem Fehler frei', async () => { + aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'db down' }) + const erg = await zeitenErinnerungsJob(JETZT) + assert.equal(erg.fehler, 1) + assert.equal((await zeitenErinnerungsJob(JETZT)).uebersprungen, undefined) +}) + +test('Jobs werfen nie: ungültiges now und Abfragefehler stehen im Log und im Ergebnis', async () => { + assert.deepEqual(await zeitenErinnerungsJob(undefined), { fehler: 1 }) + assert.deepEqual(await zeitenAboJob(new Date('kaputt')), { fehler: 1 }) + assert.equal(errorLog().filter((zeile) => /zeitenErinnerungsJob|zeitenAboJob/.test(zeile)).length, 2) + + aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'db down' }) + const abo = await zeitenAboJob(JETZT) + assert.equal(abo.fehler, 1, 'der Abgleich meldet den Lesefehler selbst') + assert.equal((await zeitenAboJob(JETZT)).uebersprungen, undefined, 'und die Sperre ist danach frei') +}) + +test('resetZeitenJobsForTests gibt die Sperren frei', async () => { + const tor = haengeAn() + const alt = zeitenErinnerungsJob(JETZT) + await tor.erreicht + assert.deepEqual(await zeitenErinnerungsJob(JETZT), { uebersprungen: true }) + + resetZeitenJobsForTests() + // die Abfrage ist nur beim ersten Mal angehalten: der neue Lauf geht durch + assert.equal((await zeitenErinnerungsJob(JETZT)).uebersprungen, undefined) + + tor.freigeben() + await alt +}) + +// ---------------------------------------------------------------- Einbindung + +test('server/index.js: Abo-Job nach 240 s und alle 24 h, Erinnerung nach 300 s und stündlich, beide mit unref und eigener Fehlerbehandlung', () => { + const index = readFileSync(new URL('../server/index.js', import.meta.url), 'utf8') + assert.match(index, /import \{[^}]*\bzeitenAboJob\b[^}]*\} from '\.\/services\/zeiten\/jobs\.js'/) + assert.match(index, /import \{[^}]*\bzeitenErinnerungsJob\b[^}]*\} from '\.\/services\/zeiten\/jobs\.js'/) + for (const [job, verzug, takt] of [ + ['zeitenAboJob', '240 \\* 1000', '24 \\* 60 \\* 60 \\* 1000'], + ['zeitenErinnerungsJob', '300 \\* 1000', '60 \\* 60 \\* 1000'], + ]) { + const lauf = new RegExp(`const (\\w+) = \\(\\) =>\\s*${job}\\(new Date\\(\\)\\)([\\s\\S]*?)\\.catch\\(`) + const treffer = lauf.exec(index) + assert.ok(treffer, `${job} mit .catch eingebunden`) + assert.match(index, new RegExp(`setTimeout\\(${treffer[1]}, ${verzug}\\)\\.unref\\(\\)`), `${job}: erster Lauf nach dem Start`) + assert.match(index, new RegExp(`setInterval\\(${treffer[1]}, ${takt}\\)\\.unref\\(\\)`), `${job}: Intervall`) + } +})