Files
Webklar-Kundenbereich/test/zeitenJobs.test.js
T
ClaudeandClaude Opus 5.5 c7cd436ec2 Öffnungszeiten: Mails K24–K27 heißen jetzt K27–K30, Feld k24At heißt einschaltMailAt
K24–K26 sind seit dem Webshop-Livegang (main) vergeben; der Mail-Lader sucht die Vorlage über den
Dateianfang, doppelte Nummern hätten die falsche Mail geschickt. Das Feld ist noch nicht in der
Produktion angelegt (zeiten-setup.mjs lief noch nicht), daher jetzt ein sprechender Name.
Spec und Plan behalten die alten Nummern (Hinweis oben im Dokument).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 06:00:17 +00:00

281 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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', 'einschaltMailAt'],
}
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',
einschaltMailAt: 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, K29) 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`)
}
})