Terminplaner: Status-Route fuer das Team

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 11:24:02 +00:00
1 parent 4601c5ef3b
commit 39225594e5
4 files changed
+477 -1

No files matched your search

+3
View File
@@ -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
+24
View File
@@ -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
+49 -1
View File
@@ -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-<wert>`), 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()
+401
View File
@@ -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: '<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}“`)
})