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>
281 lines
12 KiB
JavaScript
281 lines
12 KiB
JavaScript
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`)
|
||
}
|
||
})
|