Files
2026-10-04 11:24:02 +00:00

402 lines
18 KiB
JavaScript

import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
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 { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
// Status-Route des Terminplaners fuers Team (GET /api/team/termine/status) und statusDaten: Team-Eingang mit Appwrite-JWT wie in
// teamAdminRoutes.test.js, Nachbau von Appwrite mit Schema, Mails in der Senke. Die Route rechnet mit der echten Uhr, statusDaten
// mit einem festen Zeitpunkt.
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
termineConfig: schemaKeys('termineConfig'),
termineBookings: schemaKeys('termineBookings'),
termineClaims: schemaKeys('termineClaims'),
termineOutbox: schemaKeys('termineOutbox'),
},
datetimes: TERMINE_DATETIMES,
defaultLimit: 25,
})
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { startTestApp, rawGet, send, post, sessionCookie } = await import('./support/testApp.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { loadTemplate } = await import('../server/services/mailTemplates.js')
const { einreihen, warteAufVersand, mailBudget, verworfeneG01, resetOutboxForTests } = await import('../server/services/termine/outbox.js')
const { statusDaten, resetBetriebForTests } = await import('../server/services/termine/betrieb.js')
const { taeglicherJob, resetJobsForTests } = await import('../server/services/termine/jobs.js')
const { default: teamRoutes } = await import('../server/routes/team/index.js')
const app = await startTestApp({ '/api/team': teamRoutes })
after(() => Promise.all([app.close(), aw.close()]))
after(() => setMailTransportForTests(null))
const MIN = 60000
const STUNDE = 60 * MIN
const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin
const plus = (ms, von = JETZT) => new Date(von.getTime() + ms)
const iso = (wert) => new Date(wert).toISOString()
const EAUTH = Object.assign(new Error('Invalid login: 535 Authentication failed'), { code: 'EAUTH', responseCode: 535 })
aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' })
const staff = { headers: { authorization: 'Bearer jwt-staff' } }
const STATUS = '/api/team/termine/status'
const SCHLUESSEL = ['aktiveBetriebe', 'budget', 'letzterTagesjob', 'pause', 'queue']
const VORHER = { modus: config.termine.mode, absender: config.smtp.from, empfaenger: config.chatNotify.emails }
let sink
let log
beforeEach(async () => {
for (const collection of ['termineOutbox', 'termineBookings', 'termineClaims', 'portalFeatures', 'termineConfig']) {
for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id)
}
resetOutboxForTests()
resetBetriebForTests()
resetJobsForTests()
config.termine.mode = 'pilot'
config.termine.mailsProStunde = 200
config.termine.mailsProFunktion = 40
config.smtp.from = 'noreply@webklar.com'
config.chatNotify.emails = ['kenso@example.test']
sink = mailSink()
setMailTransportForTests(sink)
log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}) }
})
afterEach(async () => {
await warteAufVersand()
const zeilen = [...log.error.mock.calls, ...log.warn.mock.calls].map((aufruf) => aufruf.arguments.map(String).join(' '))
mock.restoreAll()
config.termine.mode = VORHER.modus
config.smtp.from = VORHER.absender
config.chatNotify.emails = VORHER.empfaenger
// Logs enthalten nie Adressen oder Namen der Gaeste
assert.doesNotMatch(zeilen.join('\n'), /anna@example\.com|Anna/, 'keine Gastadresse und kein Gastname im Log')
})
// ---------------------------------------------------------------- Hilfen
const werte = (id, extra = {}) => ({ ...Object.fromEntries(loadTemplate(id).placeholders.map((p) => [p, `Wert-${p}`])), ...extra })
let nummer = 0
/** Mail an einen Gast (Vorrang-Art G02, Vorlage G01): belegt beim Senden einen Platz im Stundenbudget. */
const gastMail = (featureId = 'f_A') => ({
featureId,
bookingId: `b_${featureId}_${++nummer}`,
art: 'G02',
an: 'anna@example.com',
fromName: 'Salon Anna Muster',
replyTo: 'info@salon-muster.de',
template: 'G01',
values: werte('G01', { betreff: 'Bitte bestätigen Sie Ihren Termin bei Salon Anna Muster' }),
blocks: { sie: true, bestaetigen: true },
})
/** Eintrag direkt in der Datenbank (ohne Sendeversuch). */
function legeEintrag(id, over = {}) {
aw.seed('termineOutbox', {
$id: id,
featureId: 'f_A',
bookingId: 'b_direkt',
art: 'G05',
an: 'anna@example.com',
fromName: 'Salon Anna Muster',
replyTo: 'info@salon-muster.de',
subject: 'Erinnerung: Ihr Termin',
html: '<p>Hallo Anna</p>',
text: 'Hallo Anna',
ics: '',
icsMethod: '',
status: 'wartet',
versuche: 0,
naechsterVersuch: iso(JETZT),
fehler: '',
createdAt: iso(JETZT),
...over,
})
}
const funktion = (id, over = {}) =>
aw.seed('portalFeatures', {
$id: id,
customerId: 'c_kunde01',
projectId: `p_${id}`,
featureKey: 'termine',
state: 'active',
enabled: true,
siteKey: 'abcdefghijklmnop',
...over,
})
/** Alle Lese-Anfragen an die Collection scheitern mit 500, solange fn laeuft. */
async function mitLesefehler(collection, fn) {
const echt = globalThis.fetch
globalThis.fetch = async (url, optionen) => {
if (new RegExp(`/collections/${collection}/documents`).test(String(url)) && (optionen?.method ?? 'GET') === 'GET') {
return new Response(JSON.stringify({ message: 'Nachbau kaputt', code: 500, type: 'general_server_error' }), {
status: 500,
headers: { 'content-type': 'application/json' },
})
}
return echt(url, optionen)
}
try {
return await fn()
} finally {
globalThis.fetch = echt
}
}
/** Alle Zeichenketten (Werte und Schluessel) eines JSON-Werts, rekursiv. */
function alleTexte(wert, sammlung = []) {
if (typeof wert === 'string') sammlung.push(wert)
else if (Array.isArray(wert)) wert.forEach((eintrag) => alleTexte(eintrag, sammlung))
else if (wert !== null && typeof wert === 'object') {
for (const [schluessel, eintrag] of Object.entries(wert)) {
sammlung.push(schluessel)
alleTexte(eintrag, sammlung)
}
}
return sammlung
}
const betriebLog = () =>
[...log.warn.mock.calls, ...log.error.mock.calls]
.map((aufruf) => aufruf.arguments.map(String).join(' '))
.filter((zeile) => zeile.startsWith('[termine-betrieb]'))
// ---------------------------------------------------------------- Eingang
test('Eingang: ohne und mit ungueltigem JWT 401, Kunden-JWT 403, Mitarbeiter-JWT 200', async () => {
assert.equal((await rawGet(STATUS)).status, 401, 'ohne JWT')
assert.equal((await rawGet(STATUS, { headers: { authorization: 'Bearer jwt-falsch' } })).status, 401, 'ungueltiges JWT')
assert.equal((await rawGet(STATUS, { headers: { authorization: 'Bearer jwt-halb' } })).status, 401, 'JWT ohne zweiten Faktor')
const kunde = await rawGet(STATUS, { headers: { authorization: 'Bearer jwt-kunde' } })
assert.equal(kunde.status, 403)
assert.equal(kunde.body.error, 'Kein Mitarbeiterkonto.')
assert.equal((await rawGet(STATUS, staff)).status, 200)
})
test('Eingang: ein Kunden-Cookie (und ein Admin-Cookie des Portals) erreicht die Route nicht, nur das JWT zaehlt', async () => {
const kunde = sessionCookie({ customerId: 'c1', appwriteUserId: 'u_kunde' })
const admin = sessionCookie({ role: 'admin', isAdmin: true, appwriteUserId: 'u_staff2', name: 'Andrej', email: 'andrej@webklar.com' })
for (const cookie of [kunde, admin]) {
const res = await rawGet(STATUS, { cookie })
assert.equal(res.status, 401)
assert.deepEqual(Object.keys(res.body), ['error'], 'kein Status im Body')
}
// Cookie plus Kunden-JWT: weiter 403
assert.equal((await rawGet(STATUS, { cookie: kunde, headers: { authorization: 'Bearer jwt-kunde' } })).status, 403)
})
test('Eingang: nur GET, andere Methoden liefern keinen Status', async () => {
for (const methode of ['POST', 'PUT', 'DELETE']) {
const res = await send(methode, STATUS, { ...staff, body: {} })
assert.notEqual(res.status, 200, methode)
assert.equal(res.body?.budget, undefined, methode)
}
assert.equal((await post(STATUS, {}, staff)).status, 404)
})
// ---------------------------------------------------------------- Form und Werte
test('GET: Form { budget, queue, pause, letzterTagesjob, aktiveBetriebe }, Cache-Control no-store, leerer Betrieb', async () => {
const res = await rawGet(STATUS, staff)
assert.equal(res.status, 200)
assert.equal(res.headers.get('cache-control'), 'no-store')
assert.match(res.headers.get('content-type'), /application\/json/)
assert.deepEqual(Object.keys(res.body).sort(), SCHLUESSEL)
assert.deepEqual(Object.keys(res.body.budget).sort(), ['jeFunktionMax', 'nachrangig', 'spitzen', 'stunde', 'verworfeneG01'])
assert.deepEqual(res.body.budget.stunde, { benutzt: 0, max: 200 })
assert.deepEqual(res.body.budget.nachrangig.benutzt, 0)
assert.equal(res.body.budget.jeFunktionMax, 40)
assert.deepEqual(res.body.budget.spitzen, [])
assert.equal(res.body.budget.verworfeneG01, 0)
assert.deepEqual(res.body.queue, { wartet: 0, aeltesteMin: 0 })
assert.deepEqual(res.body.pause, { aktiv: false, bis: null })
assert.equal(res.body.letzterTagesjob, null)
assert.equal(res.body.aktiveBetriebe, 0)
})
test('GET: Budget, Warteschlange und Anmeldepause kommen aus dem laufenden Betrieb', async () => {
const jetzt = new Date()
// zwei gesendete Mails belegen das Stundenbudget, eine Anmeldung scheitert und startet die Pause (15 Minuten)
await einreihen(gastMail('f_A'), jetzt)
await einreihen(gastMail('f_A'), jetzt)
await warteAufVersand()
const gesendet = sink.sent.length
assert.equal(gesendet, 2)
const scheitert = {
async sendMail() {
throw EAUTH
},
}
setMailTransportForTests(scheitert)
const pausenDoc = await einreihen(gastMail('f_B'), jetzt)
await warteAufVersand()
await deleteDocument('termineOutbox', pausenDoc.$id)
// zwei wartende Eintraege: einer seit 12 Minuten faellig, einer erst in der Zukunft
legeEintrag('o_faellig', { naechsterVersuch: iso(Date.now() - 12 * MIN - 5000) })
legeEintrag('o_spaeter', { naechsterVersuch: iso(Date.now() + 30 * MIN) })
const res = await rawGet(STATUS, staff)
assert.equal(res.status, 200)
assert.equal(res.body.budget.stunde.benutzt, 2, 'nur die zwei gesendeten Mails belegen Plaetze, der gescheiterte Versuch gibt seinen frei')
assert.equal(res.body.budget.spitzen[0].featureId, 'f_A')
assert.equal(res.body.budget.spitzen[0].benutzt, 2)
assert.equal(res.body.queue.wartet, 2)
assert.equal(res.body.queue.aeltesteMin, 12)
assert.equal(res.body.pause.aktiv, true)
const bis = new Date(res.body.pause.bis).getTime()
assert.ok(bis > Date.now() && bis <= Date.now() + 15 * MIN, `Pause laeuft noch bis ${res.body.pause.bis}`)
})
test('GET: letzterTagesjob ist null, bis der Tagesjob gelaufen ist, danach { at, fehler, gedeckelt, geloescht } als reine Zaehlungen', async () => {
assert.equal((await rawGet(STATUS, staff)).body.letzterTagesjob, null)
const vor = Date.now()
const erg = await taeglicherJob(new Date())
assert.equal(erg.fehler, 0)
const letzter = (await rawGet(STATUS, staff)).body.letzterTagesjob
assert.deepEqual(Object.keys(letzter).sort(), ['at', 'fehler', 'gedeckelt', 'geloescht'])
assert.ok(new Date(letzter.at).getTime() >= vor - 1000, 'Zeitpunkt des Laufs')
assert.match(letzter.at, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/)
assert.deepEqual({ fehler: letzter.fehler, gedeckelt: letzter.gedeckelt, geloescht: letzter.geloescht }, { fehler: 0, gedeckelt: 0, geloescht: 0 })
})
test('GET: aktiveBetriebe zaehlt nur aktive Termine-Dokumente (zwei aktive, ein abgeschaltetes, dazu Entwurf, bestellt und News)', async () => {
funktion('f_aktiv1')
funktion('f_aktiv2')
funktion('f_abgeschaltet', { state: 'disabled', enabled: false, disabledAt: iso(JETZT) })
funktion('f_entwurf', { state: 'off', enabled: false })
funktion('f_bestellt', { state: 'ordered', enabled: false })
funktion('f_news', { featureKey: 'news' })
assert.equal((await rawGet(STATUS, staff)).body.aktiveBetriebe, 2)
})
// ---------------------------------------------------------------- Lesefehler
test('Lesefehler der Warteschlange (failNext auf termineOutbox): 200, queue aus null-Werten, die uebrigen Werte bleiben, ein Log', async () => {
funktion('f_aktiv1')
aw.failNext({ collection: 'termineOutbox', method: 'GET', status: 500, message: 'Nachbau: Outbox kaputt' })
const res = await rawGet(STATUS, staff)
assert.equal(res.status, 200)
assert.deepEqual(res.body.queue, { wartet: null, aeltesteMin: null })
assert.equal(res.body.aktiveBetriebe, 1, 'andere Werte sind nicht betroffen')
assert.deepEqual(res.body.pause, { aktiv: false, bis: null })
assert.equal(res.body.budget.stunde.max, 200)
const zeilen = betriebLog()
assert.equal(zeilen.length, 1, zeilen.join('\n'))
assert.match(zeilen[0], /Warteschlange|queue/i)
// der naechste Aufruf liest wieder
assert.deepEqual((await rawGet(STATUS, staff)).body.queue, { wartet: 0, aeltesteMin: 0 })
})
test('Lesefehler der Betriebszaehlung (portalFeatures): aktiveBetriebe null, die Warteschlange bleibt lesbar', async () => {
funktion('f_aktiv1')
legeEintrag('o_1', { naechsterVersuch: iso(Date.now() - 3 * MIN) })
const res = await mitLesefehler('portalFeatures', () => rawGet(STATUS, staff))
assert.equal(res.status, 200)
assert.equal(res.body.aktiveBetriebe, null)
assert.deepEqual(res.body.queue, { wartet: 1, aeltesteMin: 3 })
assert.equal(betriebLog().length, 1)
})
test('Lesefehler ueberall: weiter 200 (nie ein 500), alles Lesbare null, nur Zahlen im Log', async () => {
funktion('f_aktiv1')
const res = await mitLesefehler('(termineOutbox|portalFeatures)', () => rawGet(STATUS, staff))
assert.equal(res.status, 200)
assert.equal(res.body.aktiveBetriebe, null)
assert.deepEqual(res.body.queue, { wartet: null, aeltesteMin: null })
assert.deepEqual(Object.keys(res.body).sort(), SCHLUESSEL)
assert.equal(res.body.budget.stunde.max, 200, 'das Budget liegt im Speicher und bleibt')
assert.equal(betriebLog().length, 2, 'ein Log je Lesefehler')
})
test('Lesefehler: das Log einer Pruefung folgt hoechstens stuendlich, statusDaten selbst liefert jedes Mal null', async () => {
const lese = () => mitLesefehler('termineOutbox', () => statusDaten(JETZT))
assert.deepEqual((await lese()).queue, { wartet: null, aeltesteMin: null })
assert.deepEqual((await lese()).queue, { wartet: null, aeltesteMin: null })
assert.equal(betriebLog().length, 1, 'zweimal gelesen, einmal geloggt')
const spaeter = await mitLesefehler('termineOutbox', () => statusDaten(plus(STUNDE + MIN)))
assert.equal(spaeter.queue.wartet, null)
assert.equal(betriebLog().length, 2, 'nach einer Stunde wieder')
})
// ---------------------------------------------------------------- statusDaten
test('statusDaten: Form und Werte zu einem festen Zeitpunkt, budget = mailBudget plus verworfeneG01', async () => {
funktion('f_aktiv1')
legeEintrag('o_alt', { naechsterVersuch: iso(plus(-25 * MIN)) })
legeEintrag('o_neu', { naechsterVersuch: iso(plus(-5 * MIN)) })
legeEintrag('o_gesendet', { status: 'gesendet', naechsterVersuch: iso(plus(-90 * MIN)) })
const daten = await statusDaten(JETZT)
assert.deepEqual(Object.keys(daten).sort(), SCHLUESSEL)
assert.deepEqual(daten.queue, { wartet: 2, aeltesteMin: 25 })
assert.deepEqual(daten.pause, { aktiv: false, bis: null })
assert.equal(daten.letzterTagesjob, null)
assert.equal(daten.aktiveBetriebe, 1)
assert.deepEqual(daten.budget, { ...mailBudget(JETZT), verworfeneG01: verworfeneG01() })
assert.equal(typeof daten.budget.verworfeneG01, 'number')
})
test('statusDaten: die Anmeldepause ist aktiv, solange ihr Ende nach now liegt, danach aktiv false und bis null', async () => {
const scheitert = {
async sendMail() {
throw EAUTH
},
}
setMailTransportForTests(scheitert)
const doc = await einreihen(gastMail('f_P'), JETZT)
await warteAufVersand()
await deleteDocument('termineOutbox', doc.$id)
const waehrend = await statusDaten(plus(4 * MIN))
assert.deepEqual(waehrend.pause, { aktiv: true, bis: plus(15 * MIN).toISOString() })
const danach = await statusDaten(plus(15 * MIN + 1))
assert.deepEqual(danach.pause, { aktiv: false, bis: null })
})
test('statusDaten: nach dem Tagesjob steht dessen Stand da, aktiveBetriebe zaehlt der Aufruf neu', async () => {
funktion('f_aktiv1')
await taeglicherJob(JETZT)
funktion('f_aktiv2')
const daten = await statusDaten(plus(MIN))
assert.deepEqual(daten.letzterTagesjob, { at: JETZT.toISOString(), fehler: 0, gedeckelt: 0, geloescht: 0 })
assert.equal(daten.aktiveBetriebe, 2)
})
test('statusDaten: ungueltiges now ist ein TypeError wie bei allen Termine-Bausteinen', async () => {
await assert.rejects(() => statusDaten(), TypeError)
await assert.rejects(() => statusDaten('heute'), TypeError)
await assert.rejects(() => statusDaten(new Date('x')), TypeError)
})
// ---------------------------------------------------------------- Datenschutz
test('Body: rekursiv keine Adresse und kein Gastname, auch nicht mit Mails, Eintraegen und Betrieben im System', async () => {
funktion('f_aktiv1')
await einreihen(gastMail('f_A'), new Date())
await warteAufVersand()
legeEintrag('o_wartet', { naechsterVersuch: iso(Date.now() - 11 * MIN), fehler: 'Anna Muster anna@example.com abgelehnt' })
aw.seed('termineBookings', {
$id: 'b_geheim',
featureId: 'f_A',
status: 'bestaetigt',
gastName: 'Anna Muster',
gastEmail: 'anna@example.com',
})
const res = await rawGet(STATUS, staff)
assert.equal(res.status, 200)
const texte = alleTexte(res.body)
assert.ok(texte.length > 5, 'der Body hat Inhalt')
for (const text of texte) {
assert.doesNotMatch(text, /@/, `Adresse in „${text}“`)
assert.doesNotMatch(text, /Anna|Muster|Salon/i, `Name in „${text}“`)
}
assert.doesNotMatch(JSON.stringify(res.body), /example\.com|salon-muster|Anna/i)
// nur Zaehlungen und Zeitpunkte: Zahlen, Wahrheitswerte, ISO-Zeiten und Funktions-IDs
for (const text of texte) assert.match(text, /^([a-zA-Z0-9_]+|\d{4}-\d{2}-\d{2}T[\d:.]+Z)$/, `unerwarteter Text „${text}“`)
})