Öffnungszeiten: Feiertags-Erinnerung und Jobs
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
9a14e874c0
commit
a198c472bd
5 files changed
+1012
No files matched your search
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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')))
|
||||
})
|
||||
@@ -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`)
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user