Terminplaner: Status-Route fuer das Team
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4601c5ef3b
commit
39225594e5
4 files changed
+477
-1
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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}“`)
|
||||
})
|
||||
Reference in new issue
Block a user