From 39225594e5394605e64dfb7f13cb73bb09f0c70a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 11:24:02 +0000 Subject: [PATCH] Terminplaner: Status-Route fuer das Team Co-Authored-By: Claude Sonnet 5.5 --- server/routes/team/index.js | 3 + server/routes/team/termine.js | 24 ++ server/services/termine/betrieb.js | 50 +++- test/termineStatus.test.js | 401 +++++++++++++++++++++++++++++ 4 files changed, 477 insertions(+), 1 deletion(-) create mode 100644 server/routes/team/termine.js create mode 100644 test/termineStatus.test.js diff --git a/server/routes/team/index.js b/server/routes/team/index.js index 2134cac..9c38be4 100644 --- a/server/routes/team/index.js +++ b/server/routes/team/index.js @@ -8,6 +8,7 @@ import { takeoverAdminRouter } from '../takeoverCredits.js' import customersRoutes from './customers.js' import filesRoutes from './files.js' import previewRoutes from './preview.js' +import termineTeamRoutes from './termine.js' // Team-Eingang fuer das Ticketsystem: alle Mitarbeiter, Appwrite-JWT (Spec 2026-10-01 Admin-Panel ins Ticketsystem) const router = Router() @@ -22,5 +23,7 @@ router.use('/takeover-credits', takeoverAdminRouter) router.use('/offers', offersAdminRouter) router.use('/storage-tasks', storageTasksAdminRouter) router.use('/funktionen', funktionenAdminRouter) +// Terminplaner-Betrieb (Status fuer Budget, Warteschlange, Anmeldepause, Tagesjob) +router.use('/termine', termineTeamRoutes) export default router diff --git a/server/routes/team/termine.js b/server/routes/team/termine.js new file mode 100644 index 0000000..ccd2e45 --- /dev/null +++ b/server/routes/team/termine.js @@ -0,0 +1,24 @@ +import { Router } from 'express' +import { statusDaten } from '../../services/termine/betrieb.js' + +/** + * Terminplaner fuers Team (Ticketsystem). Eingehaengt in routes/team/index.js unter /api/team/termine, dort hinter requireStaffJwt: + * ohne Mitarbeiter-JWT kommt niemand hierher, ein Kunden-Cookie des Portals zaehlt nicht. + */ +const router = Router() + +/** + * Betriebsstand: Mailbudget, Warteschlange, Anmeldepause, letzter Tagesjob, Zahl der aktiven Betriebe (nur Zaehlungen, nie Gast-Daten). + * Lesefehler ergeben null-Werte und ein Log (statusDaten), nie einen 500; nie zwischenspeichern. + */ +router.get('/status', async (_req, res, next) => { + try { + const daten = await statusDaten(new Date()) + res.set('Cache-Control', 'no-store') + res.json(daten) + } catch (err) { + next(err) + } +}) + +export default router diff --git a/server/services/termine/betrieb.js b/server/services/termine/betrieb.js index 6143944..d5ec4d9 100644 --- a/server/services/termine/betrieb.js +++ b/server/services/termine/betrieb.js @@ -1,7 +1,7 @@ import { config } from '../../config.js' import { Query, countDocuments } from '../appwriteAdmin.js' import { sendAdminNotice } from '../mailer.js' -import { mailBudget, pausiertBis, warteschlangeAlter } from './outbox.js' +import { mailBudget, pausiertBis, verworfeneG01, warteschlangeAlter } from './outbox.js' import { pruefeNow, pruefeText } from './util.js' import { datumKurz, uhrzeit } from './zeit.js' @@ -31,6 +31,9 @@ import { datumKurz, uhrzeit } from './zeit.js' * * Lesefehler (Appwrite) ergeben keinen Vorfall und keine Mail, nur ein Log (ebenfalls hoechstens stuendlich je Pruefung). * `now` ist immer ein Parameter (feste Uhr in Tests). + * + * Statusroute (statusDaten, GET /api/team/termine/status): derselbe Stand auf Abruf fuers Team, ohne Vorfall, Mail oder + * Abkuehlzeit; Lesefehler ergeben null-Werte und ein Log, nie einen Fehler. */ const LOG = '[termine-betrieb]' @@ -253,6 +256,51 @@ export async function meldeTagesjob(ergebnis, now) { return gemeldet } +// ---------------------------------------------------------------- Statusroute + +/** + * Liest einen Wert fuer die Statusroute: ein Lesefehler ergibt `ersatz` und ein Log (hoechstens stuendlich je Wert, eigener + * Merkzettel `status-`), nie einen Fehler. + */ +async function leseOderErsatz(wert, nowMs, lese, ersatz) { + try { + return await lese() + } catch (err) { + meldeFehler(`status-${wert}`, err, nowMs) + return ersatz + } +} + +/** + * Stand des Terminplaner-Betriebs fuer die Team-Route GET /api/team/termine/status: nur Zahlen, Wahrheitswerte, Zeitpunkte und + * Funktions-IDs, nie eine Adresse oder ein Name eines Gastes. + * budget mailBudget(now) plus verworfeneG01 (veraltete G01, die dieser Prozess seit dem Start verworfen hat) + * queue { wartet, aeltesteMin } wie warteschlangeAlter; bei einem Lesefehler beide null + * pause { aktiv, bis }: aktiv, solange das Ende der Anmeldepause nach `now` liegt, dann ist bis dessen Zeitpunkt (ISO), sonst null + * letzterTagesjob null (seit dem Start lief noch kein Tagesjob) oder { at (ISO), fehler, gedeckelt, geloescht } + * aktiveBetriebe Zahl der aktiven Termine-Dokumente (zaehleAktiveBetriebe); null bei einem Lesefehler + * Wirft nie (ausser mit TypeError bei ungueltigem `now`, wie alle Termine-Bausteine): was nicht lesbar war, ist null und steht im Log. + * @param {Date} now + * @returns {Promise<{ budget: object, queue: { wartet: number|null, aeltesteMin: number|null }, pause: { aktiv: boolean, bis: string|null }, + * letzterTagesjob: { at: string, fehler: number, gedeckelt: number, geloescht: number }|null, aktiveBetriebe: number|null }>} + */ +export async function statusDaten(now) { + const nowMs = pruefeNow(now, 'statusDaten') + const [queue, aktiveBetriebe] = await Promise.all([ + leseOderErsatz('queue', nowMs, () => warteschlangeAlter(now), { wartet: null, aeltesteMin: null }), + leseOderErsatz('betriebe', nowMs, zaehleAktiveBetriebe, null), + ]) + const bis = pausiertBis() + const pauseAktiv = bis !== null && bis.getTime() > nowMs + return { + budget: { ...mailBudget(now), verworfeneG01: verworfeneG01() }, + queue: { wartet: queue.wartet, aeltesteMin: queue.aeltesteMin }, + pause: { aktiv: pauseAktiv, bis: pauseAktiv ? bis.toISOString() : null }, + letzterTagesjob: letzterTagesjob(), + aktiveBetriebe, + } +} + /** Nur fuer Tests: den gemerkten Zustand (Zeit der letzten Warnung und Mail je Schluessel, letzter Tagesjob, Deckel-Folge) zuruecksetzen. */ export function resetBetriebForTests() { gemerkt.clear() diff --git a/test/termineStatus.test.js b/test/termineStatus.test.js new file mode 100644 index 0000000..33710d4 --- /dev/null +++ b/test/termineStatus.test.js @@ -0,0 +1,401 @@ +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}“`) +})