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: '

Hallo Anna

', 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}“`) })