Files

1126 lines
62 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
// Oeffentliche Schnittstelle des Terminplaners (/api/public/termine/v1): Router im Nachbau von Appwrite, Mails in der Senke,
// feste Uhr (setzeUhrFuerTests), je Test eine eigene Client-Adresse (X-Forwarded-For) und eine Origin wie im Browser.
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 { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { startTestApp, send } = await import('./support/testApp.js')
const { kundenansichtCookie } = await import('./support/fixtures.js')
const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js')
const { normalisiereEinstellung } = await import('../server/services/termine/einstellungen.js')
const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js')
const { buchungsNummer, formToken, pruefeFormToken, verwaltungsToken } = await import('../server/services/termine/token.js')
const { speichereEinstellung, resetKontextForTests } = await import('../server/services/termine/kontext.js')
const { resetTermineLimitsForTests, darfHaltung } = await import('../server/services/termine/limits.js')
const { RECHTSTEXTE_VERSION, ZUSTIMMUNG_STORNO, ZUSTIMMUNG_ERINNERUNG, ZUSTIMMUNG_VORZEITIG, GESUNDHEIT_HINWEIS, formularHinweis, widerrufsbelehrung } = await import(
'../server/services/termine/rechtstexte.js'
)
const { halte } = await import('../server/services/termine/ablauf.js')
const { sperre } = await import('../server/services/termine/ablaufBetrieb.js')
const { default: router, setzeUhrFuerTests, resetPublicTermineForTests } = await import('../server/routes/publicTermine.js')
const app = await startTestApp({ '/': kundenansichtGuard, '/api/public/termine/v1': router })
after(() => Promise.all([app.close(), aw.close()]))
after(() => setMailTransportForTests(null))
const BASIS = '/api/public/termine/v1'
const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin
const TAG_NAH = '2026-10-12' // 7 Tage spaeter: vorzeitiger Beginn zustimmungspflichtig
const TAG_FERN = '2026-10-26' // 21 Tage spaeter, Montag
const KUNDE = 'c_kunde01'
const PROJEKT = 'p_projekt01'
const FEATURE = 'f_p_projekt01_termine'
const SITE_KEY = 'abcdefghijklmnop'
const R1 = 'r_aaaaaaa1'
const R2 = 'r_aaaaaaa2'
const S_SOFORT = 's_bbbbbbb1' // Damenschnitt, 45 Minuten, 45 Euro, sofort
const S_ANFRAGE = 's_bbbbbbb2' // Besichtigung, 60 Minuten, ohne Preis, Anfrage
const S_GRATIS = 's_bbbbbbb3' // Kennenlernen, 30 Minuten, ohne Preis, sofort
const O_LIVE = 'https://salon.de'
const O_PORTAL = 'https://project.webklar.com'
const ABSENDER_VORHER = config.smtp.from
const MODUS_VORHER = config.termine.mode
const BASIS_VORHER = config.publicBaseUrl
const FORM_VORHER = config.termine.formMinSekunden
const MIN_ANTWORT_VORHER = config.termine.minAntwortMs
const plus = (date, ms) => new Date(date.getTime() + ms)
const feature = (state = 'active') => ({ $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'termine', state, enabled: true, siteKey: SITE_KEY })
const eingabeEinstellung = (over = {}) => ({
betrieb: {
name: 'Salon Müller',
email: 'info@salon.de',
telefon: '06301 1234',
adresse: 'Hauptstr. 1, 67722 Winnweiler',
websiteUrl: 'https://salon.de',
impressumUrl: 'https://salon.de/impressum',
datenschutzUrl: 'https://salon.de/datenschutz',
},
anrede: 'sie',
bundesland: 'RP',
ressourcen: [
{ id: R1, name: 'Anna', plan: { 1: [['09:00', '18:00']] } },
{ id: R2, name: 'Ben', plan: { 1: [['09:00', '18:00']] } },
],
leistungen: [
{ id: S_SOFORT, name: 'Damenschnitt', dauer: 45, pufferNach: 5, preisArt: 'fest', preisCent: 4500, buchungsart: 'sofort', ressourcen: [R1, R2] },
{ id: S_ANFRAGE, name: 'Besichtigung', dauer: 60, preisArt: 'keiner', buchungsart: 'anfrage', ressourcen: [R1, R2] },
{ id: S_GRATIS, name: 'Kennenlernen', dauer: 30, preisArt: 'keiner', buchungsart: 'sofort', ressourcen: [R1, R2] },
],
regeln: { raster: 15, vorlaufMin: 120, horizontTage: 90, absageBisStd: 24, maxOffeneJeEmail: 3, personWaehlbar: true },
gastFelder: { telefon: 'optional', nachricht: 'aus', zusatz: [] },
erinnerung: { anbieten: true, stundenVorher: 24 },
storno: { text: 'Kostenlose Absage bis 24 Stunden vor dem Termin.' },
recht: { gaesteSind: 'verbraucher', freizeit: false },
zahlung: { modus: 'vorort', vorOrtArten: ['bar', 'ec'] },
...over,
})
const einstellung = (over) => {
const erg = normalisiereEinstellung(eingabeEinstellung(over), { now: JETZT })
assert.equal(erg.fehler, undefined, JSON.stringify(erg.fehler))
return erg.einstellung
}
const ctxMit = (e = einstellung()) => ({ feature: feature(), project: { $id: PROJEKT, subdomain: 'salon', liveDomain: 'salon.de' }, einstellung: e, version: 1 })
/** Formularinhalt eines Gastes mit frischem Formular-Token (Alter 0 s, mit formMinSekunden 0 gueltig). */
const buchungBody = (over = {}) => ({
leistung: S_SOFORT,
ressource: 'egal',
tag: TAG_FERN,
zeit: '10:00',
name: 'Max Mustermann',
email: 'max@example.com',
telefon: '0171 1234567',
nachricht: '',
zusatz: {},
erinnerung: false,
zustimmungen: { storno: true, vorzeitig: false },
kontakt_url: '',
formToken: formToken(SITE_KEY, JETZT),
...over,
})
let sink
let log
let clientIp = 0
let ip
// Nichts Internes in irgendeiner Antwort: weder Felder noch Werte mit IDs des Portals oder Appwrite-Metadaten.
// Erlaubt sind die Leistungs-/Ressourcen-IDs der Einstellung und die Buchungsnummer (8 Zeichen); Buchungs-IDs sind UUIDs.
const VERBOTENE_FELDER = new Set([
'customerId', 'projectId', 'featureId', 'bookingId', '$id', '$collectionId', '$databaseId', '$createdAt', '$updatedAt', '$permissions',
'verifyHash', 'manageHash', 'feedToken',
])
const INTERNE_IDS = [KUNDE, PROJEKT, FEATURE]
const UUID_RE = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i
/** Wirft, wenn irgendwo im (JSON-)Wert ein verbotenes Feld, eine interne ID oder eine Buchungs-UUID steht. */
function pruefeOhneInternes(wert, pfad = '$', { uuid = true } = {}) {
if (typeof wert === 'string') {
for (const id of INTERNE_IDS) assert.ok(!wert.includes(id), `${pfad}: interne ID ${id} in der Antwort`)
if (uuid) assert.ok(!UUID_RE.test(wert), `${pfad}: UUID (Buchungs-ID?) in der Antwort`)
} else if (Array.isArray(wert)) {
wert.forEach((w, i) => pruefeOhneInternes(w, `${pfad}[${i}]`, { uuid }))
} else if (wert !== null && typeof wert === 'object') {
for (const [feld, w] of Object.entries(wert)) {
assert.ok(!VERBOTENE_FELDER.has(feld), `${pfad}.${feld}: internes Feld in der Antwort`)
pruefeOhneInternes(w, `${pfad}.${feld}`, { uuid })
}
}
}
/**
* Anfrage an die oeffentliche Schnittstelle: Pfad ab /v1, Origin wie im Browser (Vorgabe: Seite des Betriebs), Client-Adresse je Test.
* JEDE Antwort (Erfolg wie Fehler) wird auf interne IDs geprueft; der Kalender-Text (UID = Buchungs-ID ist dort Pflicht) nur auf Portal-IDs.
*/
async function api(method, pfad, { body, origin = O_LIVE, ip: adresse = ip, headers = {}, cookie } = {}) {
const res = await send(method, BASIS + pfad, { body, cookie, headers: { 'x-forwarded-for': adresse, ...(origin ? { origin } : {}), ...headers } })
pruefeOhneInternes(res.body, `${method} ${pfad}`, { uuid: typeof res.body !== 'string' })
return res
}
const buchen = (over, opts) => api('POST', `/${SITE_KEY}/buchungen`, { body: buchungBody(over), ...opts })
const tokenAnruf = (aktion, body, opts = {}) => api('POST', `/termin/${aktion}`, { body, origin: O_PORTAL, ...opts })
/** Die Einstellung wird gespeichert, damit die Schnittstelle (kontextPerSiteKey) sie findet. */
async function speichere(e = einstellung()) {
resetKontextForTests()
const doc = aw.docs('termineConfig').get(FEATURE)
await speichereEinstellung(feature(), e, { version: doc ? doc.version : 0, now: JETZT })
return ctxMit(e)
}
beforeEach(async () => {
for (const collection of ['portalFeatures', 'termineConfig', 'termineBookings', 'termineClaims', 'termineOutbox', 'customers', 'websiteProjects']) {
for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id)
}
resetKontextForTests()
resetOutboxForTests()
resetTermineLimitsForTests()
resetPublicTermineForTests()
setzeUhrFuerTests(() => JETZT)
config.termine.mode = 'pilot'
config.termine.formMinSekunden = 0
config.termine.minAntwortMs = 0
config.smtp.from = 'noreply@webklar.com'
config.publicBaseUrl = 'https://project.webklar.com'
sink = mailSink()
setMailTransportForTests(sink)
log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}) }
aw.seed('portalFeatures', feature())
aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, subdomain: 'salon', liveDomain: 'salon.de' })
aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: 'silke@example.com' })
clientIp += 1
ip = `203.0.113.${clientIp}`
})
afterEach(async () => {
await warteAufVersand()
mock.restoreAll()
resetPublicTermineForTests()
config.termine.mode = MODUS_VORHER
config.termine.formMinSekunden = FORM_VORHER
config.termine.minAntwortMs = MIN_ANTWORT_VORHER
config.smtp.from = ABSENDER_VORHER
config.publicBaseUrl = BASIS_VORHER
})
const buchungen = () => [...aw.docs('termineBookings').values()]
const claims = (bookingId) => [...aw.docs('termineClaims').values()].filter((c) => c.bookingId === bookingId)
const mailsAn = (adresse) => sink.sent.filter((m) => m.to.some((t) => t.address === adresse))
const AKTIV_FALSE = { aktiv: false }
const NICHT_ERREICHBAR = { fehler: { _: 'Gerade nicht erreichbar' } }
/** Bestaetigungstoken (#v=) bzw. Verwaltungstoken (#m=) aus dem Text einer Mail. */
const tokenAusMail = (mail, art) => new RegExp(`termin\\.html#${art}=([A-Za-z0-9_-]{43})`).exec(mail.text)[1]
/** Buchung per HTTP halten und bestaetigen; => { id, verwaltung (Verwaltungstoken aus der Mail G02) }. */
async function gebucht(over = {}) {
const halten = await buchen(over)
assert.equal(halten.status, 201, JSON.stringify(halten.body))
await warteAufVersand()
const vorher = sink.sent.length
const g01 = mailsAn(over.email ?? 'max@example.com').at(-1)
const bestaetigt = await tokenAnruf('bestaetigen', { token: tokenAusMail(g01, 'v') })
assert.equal(bestaetigt.status, 200, JSON.stringify(bestaetigt.body))
await warteAufVersand()
const g02 = sink.sent.slice(vorher).find((m) => m.to.some((t) => t.address === (over.email ?? 'max@example.com')))
const verwaltung = tokenAusMail(g02, 'm')
const buchung = buchungen().find((b) => verwaltungsToken(b.$id) === verwaltung)
assert.ok(buchung)
return { id: buchung.$id, verwaltung }
}
// ---------------------------------------------------------------- Konfiguration
test('config: Betriebsdaten, Leistungen, Regeln, Texte und Formular-Token; keine internen IDs; kurzer Cache ohne ETag', async () => {
const ctx = await speichere()
const res = await api('GET', `/${SITE_KEY}/config`, { origin: null })
assert.equal(res.status, 200)
assert.equal(res.headers.get('cache-control'), 'public, max-age=60')
assert.equal(res.headers.get('etag'), null, 'das Formular-Token ist bei jedem Abruf neu: kein ETag')
assert.equal(res.headers.get('access-control-allow-origin'), '*')
assert.match(res.headers.get('content-type'), /^application\/json/)
const c = res.body
assert.deepEqual(Object.keys(c).sort(), [
'aktiv', 'anrede', 'betrieb', 'erinnerung', 'farbe', 'formMinSekunden', 'formToken', 'gastFelder', 'heute', 'hinweis', 'leistungen',
'personWaehlbar', 'preisHinweis', 'regeln', 'ressourcen', 'storno', 'texte', 'widerruf', 'zahlung',
])
assert.equal(c.aktiv, true)
assert.equal(c.formMinSekunden, config.termine.formMinSekunden, 'R53: das Widget wartet, bis das Formular-Token so alt ist')
assert.equal(c.heute, '2026-10-05')
assert.deepEqual(c.betrieb, {
name: 'Salon Müller',
telefon: '06301 1234',
adresse: 'Hauptstr. 1, 67722 Winnweiler',
impressumUrl: 'https://salon.de/impressum',
datenschutzUrl: 'https://salon.de/datenschutz',
agbUrl: null,
})
assert.deepEqual(c.ressourcen, [{ id: R1, name: 'Anna', typ: 'person' }, { id: R2, name: 'Ben', typ: 'person' }])
assert.deepEqual(c.regeln, { horizontTage: 90, absageBisStd: 24 })
assert.equal(c.personWaehlbar, true)
assert.deepEqual(c.zahlung, { modus: 'vorort', text: 'Bezahlung vor Ort: bar, EC-Karte' })
assert.equal(c.preisHinweis, 'inkl. USt')
assert.deepEqual(c.erinnerung, { anbieten: true, stundenVorher: 24 })
assert.equal(c.storno.text, 'Kostenlose Absage bis 24 Stunden vor dem Termin.')
assert.equal(c.widerruf, true)
assert.equal(c.hinweis, formularHinweis(ctx.einstellung))
assert.deepEqual(c.texte, {
storno: ZUSTIMMUNG_STORNO,
erinnerung: ZUSTIMMUNG_ERINNERUNG(24),
vorzeitig: ZUSTIMMUNG_VORZEITIG('Salon Müller'),
gesundheit: GESUNDHEIT_HINWEIS,
widerrufsbelehrung: widerrufsbelehrung(ctx.einstellung.betrieb),
})
// Haken Erinnerung: Wortlaut der Fassung mit dem Widerrufssatz (Art. 7 Abs. 3 DSGVO), wörtlich in der öffentlichen Konfiguration
assert.equal(c.texte.erinnerung, 'Ich möchte 24 Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen.')
assert.equal(pruefeFormToken(SITE_KEY, c.formToken, plus(JETZT, 10000)), 'ok')
assert.equal(pruefeFormToken('bcdefghijklmnopq', c.formToken, plus(JETZT, 10000)), 'ungueltig', 'das Token gilt nur fuer diesen siteKey')
// nichts Internes: keine IDs des Portals, keine Feed-Token, keine Betriebs-E-Mail (ausser im Wortlaut der Widerrufsbelehrung,
// die die Adresse fuer den Widerruf nennen muss), keine internen Regeln
assert.match(c.texte.widerrufsbelehrung, /info@salon\.de/)
const text = JSON.stringify({ ...c, texte: { ...c.texte, widerrufsbelehrung: '' } })
for (const verboten of [KUNDE, PROJEKT, FEATURE, 'customerId', 'projectId', 'featureId', '$id', 'feedToken', 'info@salon.de', 'maxOffeneJeEmail']) {
assert.ok(!text.includes(verboten), `${verboten} darf nicht in der Konfiguration stehen`)
}
for (const r of ctx.einstellung.ressourcen) assert.ok(!JSON.stringify(c).includes(r.feedToken))
})
test('config: formMinSekunden folgt der Einstellung zur Laufzeit (R53) und ist Zahl, auch 0', async () => {
await speichere()
for (const wert of [3, 0, 7.5]) {
config.termine.formMinSekunden = wert
const { body } = await api('GET', `/${SITE_KEY}/config`)
assert.equal(body.formMinSekunden, wert)
}
})
test('config: Knopf je Fall (Zahlungspflichtig buchen, Termin anfragen, Termin buchen)', async () => {
await speichere()
const { body } = await api('GET', `/${SITE_KEY}/config`)
const knopf = Object.fromEntries(body.leistungen.map((l) => [l.id, l.knopf]))
assert.deepEqual(knopf, { [S_SOFORT]: 'Zahlungspflichtig buchen', [S_ANFRAGE]: 'Termin anfragen', [S_GRATIS]: 'Termin buchen' })
const damen = body.leistungen.find((l) => l.id === S_SOFORT)
assert.deepEqual(damen, {
id: S_SOFORT, name: 'Damenschnitt', beschreibung: '', dauer: 45, preis: '45,00 €', preisArt: 'fest', buchungsart: 'sofort', knopf: 'Zahlungspflichtig buchen',
verbraucher: true, ressourcen: [R1, R2],
})
})
test('config: ohne Widerrufsrecht (nur Unternehmer) kein widerruf und keine Belehrung', async () => {
await speichere(einstellung({ recht: { gaesteSind: 'unternehmer', freizeit: false } }))
const { body } = await api('GET', `/${SITE_KEY}/config`)
assert.equal(body.widerruf, false)
assert.equal(Object.hasOwn(body.texte, 'widerrufsbelehrung'), false)
assert.deepEqual(Object.keys(body.texte).sort(), ['erinnerung', 'gesundheit', 'storno', 'vorzeitig'])
})
test('config: ohne Anschrift (Belehrung nicht zu erzeugen) oder Telefon ist die Funktion fuer Gaeste nicht aktiv (R41), nie eine Buchung ohne Belehrung', async () => {
for (const felder of [{ adresse: '' }, { telefon: '' }]) {
const e = einstellung()
Object.assign(e.betrieb, felder) // wie ein Entwurf aus einer Vorlage, gespeichert ohne strenge Pruefung
await speichere(e)
for (const [methode, pfad, body] of [
['GET', 'config'],
['GET', `tage?leistung=${S_SOFORT}&ressource=egal&monat=2026-10`],
['GET', `zeiten?leistung=${S_SOFORT}&ressource=egal&tag=${TAG_FERN}`],
['POST', 'buchungen', buchungBody()],
]) {
const res = await api(methode, `/${SITE_KEY}/${pfad}`, body ? { body } : {})
assert.equal(res.status, 200, `${JSON.stringify(felder)} ${pfad}`)
assert.deepEqual(res.body, AKTIV_FALSE, `${JSON.stringify(felder)} ${pfad}`)
}
assert.equal(aw.docs('termineBookings').size, 0, 'nichts gebucht')
}
})
// ---------------------------------------------------------------- Unbekannt, aus, abgeschaltet
test('unbekannter, ungueltiger, inaktiver und abgeschalteter siteKey sowie Modus off: immer { aktiv: false } (GET und POST), kalender 404', async () => {
await speichere()
const GET_PFADE = ['config', `tage?leistung=${S_SOFORT}&ressource=egal&monat=2026-10`, `zeiten?leistung=${S_SOFORT}&ressource=egal&tag=${TAG_FERN}`]
const POST_PFADE = ['buchungen', 'widerruf']
const pruefe = async (siteKey, was) => {
for (const pfad of GET_PFADE) {
const res = await api('GET', `/${siteKey}/${pfad}`)
assert.equal(res.status, 200, `${was} GET ${pfad}`)
assert.deepEqual(res.body, AKTIV_FALSE, `${was} GET ${pfad}`)
}
for (const pfad of POST_PFADE) {
const res = await api('POST', `/${siteKey}/${pfad}`, { body: buchungBody({ name: 'Max', nummer: 'ABCD1234' }) })
assert.equal(res.status, 200, `${was} POST ${pfad}`)
assert.deepEqual(res.body, AKTIV_FALSE, `${was} POST ${pfad}`)
}
const feed = await api('GET', `/kalender/${siteKey}/${'a'.repeat(43)}.ics`)
assert.equal(feed.status, 404, `${was} Kalender`)
}
await pruefe('zzzzzzzzzzzzzzzz', 'unbekannt')
await pruefe('x', 'ungueltiges Format')
await pruefe(SITE_KEY.toUpperCase(), 'Grossschreibung')
for (const state of ['inactive', 'disabled', 'pending']) {
aw.seed('portalFeatures', { ...feature(state) })
resetKontextForTests()
await pruefe(SITE_KEY, `Zustand ${state}`)
}
aw.seed('portalFeatures', feature())
resetKontextForTests()
assert.equal((await api('GET', `/${SITE_KEY}/config`)).body.aktiv, true, 'zurueck auf active')
config.termine.mode = 'off'
await pruefe(SITE_KEY, 'Modus off')
})
test('abgeschaltete Funktion (disabled): die Token-Seiten bleiben nutzbar (Absagen, Widerruf)', async () => {
await speichere()
const { verwaltung, id } = await gebucht()
aw.seed('portalFeatures', feature('disabled'))
resetKontextForTests()
assert.deepEqual((await api('GET', `/${SITE_KEY}/config`)).body, AKTIV_FALSE)
const vorschau = await tokenAnruf('vorschau', { token: verwaltung })
assert.equal(vorschau.status, 200)
assert.equal(vorschau.body.art, 'verwalten')
const absage = await tokenAnruf('absagen', { token: verwaltung })
assert.deepEqual(absage.body, { ok: true, status: 'abgesagt' })
assert.equal(aw.docs('termineBookings').get(id).status, 'abgesagt')
config.termine.mode = 'off'
assert.equal((await tokenAnruf('vorschau', { token: verwaltung })).status, 404, 'Modus off: auch die Token-Seiten antworten nichts')
})
test('Modus off: keine Abfrage an Appwrite - auch nicht fuer die Token-Seiten und den Kalender', async () => {
await speichere()
config.termine.mode = 'off'
aw.failNext({ method: 'GET', status: 500, message: 'Nachbau: darf nicht abgefragt werden' }) // jede Abfrage wuerde scheitern
const [config1, vorschau, absagen, tage, feed] = [
await api('GET', `/${SITE_KEY}/config`),
await tokenAnruf('vorschau', { token: 'x'.repeat(43) }),
await tokenAnruf('absagen', { token: 'x'.repeat(43) }),
await tokenAnruf('tage', { token: 'x'.repeat(43), monat: '2026-10' }),
await api('GET', `/kalender/${SITE_KEY}/${'a'.repeat(43)}.ics`),
]
assert.deepEqual([config1.status, config1.body], [200, AKTIV_FALSE])
for (const antwort of [vorschau, absagen, tage]) {
assert.deepEqual([antwort.status, antwort.body.code], [404, 'unbekannt'])
}
assert.equal(feed.status, 404)
// die Abfrage, die scheitern sollte, ist noch scharf: erst jetzt wird sie verbraucht
config.termine.mode = 'pilot'
resetKontextForTests()
assert.equal((await api('GET', `/${SITE_KEY}/config`)).status, 503)
assert.equal((await api('GET', `/${SITE_KEY}/config`)).status, 200)
})
// ---------------------------------------------------------------- CORS
test('CORS: GET fuer alle Herkunft; POST nur von Seiten des Betriebs, des Portals oder gleicher Herkunft, sonst 403', async () => {
await speichere()
const get = await api('GET', `/${SITE_KEY}/config`, { origin: 'https://fremd.example' })
assert.equal(get.status, 200)
assert.equal(get.headers.get('access-control-allow-origin'), '*')
const gleicheHerkunft = new URL(app.url).origin
const erlaubt = [O_LIVE, 'https://www.salon.de', 'https://salon.project.webklar.com', O_PORTAL, gleicheHerkunft]
for (const origin of erlaubt) {
const res = await buchen({ kontakt_url: 'x' }, { origin })
assert.equal(res.status, 201, origin)
assert.equal(res.headers.get('access-control-allow-origin'), origin, origin)
assert.match(res.headers.get('vary'), /Origin/i)
}
let fremdeIp = 0
for (const origin of ['https://fremd.example', 'https://salon.de.evil.example', 'https://evilsalon.de', 'null', 'kaputt', 'ftp://salon.de', null]) {
for (const [pfad, body] of [['buchungen', buchungBody()], ['widerruf', { name: 'Max', email: 'max@example.com', nummer: '' }]]) {
fremdeIp += 1
const res = await api('POST', `/${SITE_KEY}/${pfad}`, { body, origin, ip: `198.51.100.${fremdeIp}` })
assert.equal(res.status, 403, `${origin} ${pfad}`)
assert.deepEqual(res.body, { fehler: { _: 'Nicht erlaubt' } })
assert.equal(res.headers.get('access-control-allow-origin'), null, 'kein Zugriff fuer fremde Seiten')
}
}
assert.equal(buchungen().length, 0)
assert.equal(sink.sent.length, 0)
})
test('CORS: die Token-Seiten (/termin/*) nehmen nur das Portal und die gleiche Herkunft an, nicht die Seite des Betriebs', async () => {
await speichere()
for (const aktion of ['vorschau', 'bestaetigen', 'absagen', 'verschieben', 'widerruf', 'tage', 'zeiten']) {
const betrieb = await tokenAnruf(aktion, { token: 'x' }, { origin: O_LIVE })
assert.equal(betrieb.status, 403, aktion)
assert.deepEqual(betrieb.body, { fehler: { _: 'Nicht erlaubt' } })
const portal = await tokenAnruf(aktion, { token: 'x' })
assert.notEqual(portal.status, 403, aktion)
assert.equal(portal.headers.get('access-control-allow-origin'), O_PORTAL)
const gleich = await tokenAnruf(aktion, { token: 'x' }, { origin: new URL(app.url).origin })
assert.notEqual(gleich.status, 403, aktion)
}
})
test('Preflight: 204 mit Methoden, Content-Type und 24 Stunden fuer erlaubte Herkunft, sonst 403', async () => {
await speichere()
const preflight = (pfad, origin) =>
api('OPTIONS', pfad, { origin, headers: { 'access-control-request-method': 'POST', 'access-control-request-headers': 'content-type' } })
for (const pfad of ['/' + SITE_KEY + '/buchungen', '/' + SITE_KEY + '/widerruf']) {
const ok = await preflight(pfad, O_LIVE)
assert.equal(ok.status, 204, pfad)
assert.equal(ok.headers.get('access-control-allow-origin'), O_LIVE)
assert.equal(ok.headers.get('access-control-allow-methods'), 'GET, POST, OPTIONS')
assert.equal(ok.headers.get('access-control-allow-headers'), 'Content-Type')
assert.equal(ok.headers.get('access-control-max-age'), '86400')
assert.match(ok.headers.get('vary'), /Origin/i)
const fremd = await preflight(pfad, 'https://fremd.example')
assert.equal(fremd.status, 403)
assert.deepEqual(fremd.body, { fehler: { _: 'Nicht erlaubt' } })
}
assert.equal((await preflight('/termin/vorschau', O_PORTAL)).status, 204)
assert.equal((await preflight('/termin/vorschau', O_LIVE)).status, 403)
assert.equal((await preflight('/termin/bestaetigen', new URL(app.url).origin)).status, 204)
// ohne Funktion gibt es nichts zu pruefen: die Antwort { aktiv: false } soll das Widget trotzdem lesen koennen
const unbekannt = await preflight('/zzzzzzzzzzzzzzzz/buchungen', 'https://fremd.example')
assert.equal(unbekannt.status, 204)
assert.equal(unbekannt.headers.get('access-control-allow-origin'), '*')
})
// ---------------------------------------------------------------- Buchen: Schutz
test('Honeypot und zu schnell: dieselbe Erfolgsantwort wie eine echte Buchung, nichts gespeichert, keine Mail', async () => {
await speichere()
const erwartet = { ok: true, email: 'm***@example.com', minuten: 30 }
const honig = await buchen({ kontakt_url: 'https://spam.example' })
assert.equal(honig.status, 201)
assert.deepEqual(honig.body, erwartet)
const honigListe = await buchen({ kontakt_url: ['x'] })
assert.deepEqual([honigListe.status, honigListe.body], [201, erwartet])
config.termine.formMinSekunden = 3
const schnell = await buchen({ formToken: formToken(SITE_KEY, JETZT) }) // Alter 0 s < 3 s
assert.equal(schnell.status, 201)
assert.deepEqual(schnell.body, erwartet)
await warteAufVersand()
assert.equal(buchungen().length, 0)
assert.equal(aw.docs('termineClaims').size, 0)
assert.equal(sink.sent.length, 0)
assert.equal(aw.docs('termineOutbox').size, 0)
// drei Sekunden spaeter ist es eine echte Buchung mit derselben Antwort
setzeUhrFuerTests(() => plus(JETZT, 4000))
const echt = await buchen({ formToken: formToken(SITE_KEY, JETZT) })
assert.equal(echt.status, 201)
assert.deepEqual(echt.body, erwartet)
await warteAufVersand()
assert.equal(buchungen().length, 1)
assert.equal(sink.sent.length, 1)
})
test('Honeypot ohne gueltige Adresse: Erfolgsantwort mit neutraler Adresse', async () => {
await speichere()
const res = await buchen({ kontakt_url: 'x', email: 'kein-postfach' })
assert.deepEqual([res.status, res.body], [201, { ok: true, email: '***', minuten: 30 }])
})
test('Formular-Token zu alt, fehlend oder fremd: 400 "Bitte laden Sie die Seite neu." ohne Buchung', async () => {
await speichere()
const melde = { fehler: { _: 'Bitte laden Sie die Seite neu.' }, code: 'neu_laden' }
for (const token of [formToken(SITE_KEY, plus(JETZT, -3 * 3600 * 1000)), formToken('bcdefghijklmnopq', JETZT), 'unsinn', '', undefined, 42]) {
const res = await buchen({ formToken: token })
assert.equal(res.status, 400, String(token))
assert.deepEqual(res.body, melde, String(token))
}
assert.equal((await buchen({ formToken: formToken(SITE_KEY, plus(JETZT, -3600 * 1000)) })).status, 201, 'eine Stunde alt gilt')
assert.equal(buchungen().length, 1)
})
test('Eingabefehler: 400 mit Feldfehlern, Zeit nicht mehr frei: 409 mit Feld zeit; nichts ausser dem Fehlertext in der Antwort', async () => {
await speichere()
const leer = await buchen({ name: '', email: 'kaputt', zeit: '' })
assert.equal(leer.status, 400)
assert.deepEqual(Object.keys(leer.body), ['fehler'])
assert.deepEqual(Object.keys(leer.body.fehler).sort(), ['email', 'name', 'zeit'])
assert.ok(Object.values(leer.body.fehler).every((t) => typeof t === 'string'))
const array = await api('POST', `/${SITE_KEY}/buchungen`, { body: [] })
assert.equal(array.status, 400)
assert.deepEqual(Object.keys(array.body.fehler), ['_'])
assert.equal((await buchen({ zeit: '10:00', ressource: R1 })).status, 201)
const doppelt = await buchen({ zeit: '10:00', ressource: R1, email: 'anna@example.com' })
assert.equal(doppelt.status, 409)
assert.deepEqual(Object.keys(doppelt.body.fehler), ['zeit'])
assert.equal(doppelt.body.code, 'SLOT_VERGEBEN')
assert.equal(buchungen().length, 1)
})
test('Buchung: 201 mit maskierter Adresse und Haltezeit; Buchung gehalten, Mail G01 geht an die Adresse', async () => {
await speichere()
const res = await buchen({ email: ' Max@GMail.com ' })
assert.equal(res.status, 201)
assert.deepEqual(res.body, { ok: true, email: 'm***@gmail.com', minuten: 30 })
assert.equal(res.headers.get('cache-control'), 'no-store')
await warteAufVersand()
const [b] = buchungen()
assert.equal(b.status, 'gehalten')
assert.equal(b.gastEmail, 'max@gmail.com')
assert.equal(mailsAn('max@gmail.com').length, 1)
assert.ok(!JSON.stringify(res.body).includes(b.$id))
})
test('nachweis_fassung: eine neue Buchung trägt die aktuelle Fassung der Gästetexte, eine Altbuchung behält 2026-10-02 (der Nachweis wird nie umgeschrieben)', async () => {
await speichere()
const { id: neu, verwaltung } = await gebucht({ erinnerung: true, zustimmungen: { storno: true, vorzeitig: false } })
const nachweisNeu = JSON.parse(aw.docs('termineBookings').get(neu).nachweis)
assert.equal(nachweisNeu.fassung, RECHTSTEXTE_VERSION)
assert.notEqual(nachweisNeu.fassung, '2026-10-02')
assert.equal(nachweisNeu.erinnerung.hash.length, 64)
// Altbuchung: Nachweis der Fassung 2026-10-02; Bestätigen, Verschieben und Absagen schreiben den Nachweis nicht um
const alt = await buchen({ email: 'alt@example.com', zeit: '11:00', ressource: R1 })
assert.equal(alt.status, 201)
await warteAufVersand()
const buchungAlt = buchungen().find((b) => b.gastEmail === 'alt@example.com')
const nachweisAlt = JSON.stringify({ ...JSON.parse(buchungAlt.nachweis), fassung: '2026-10-02' })
await updateDocument('termineBookings', buchungAlt.$id, { nachweis: nachweisAlt })
const vToken = tokenAusMail(mailsAn('alt@example.com')[0], 'v')
assert.equal((await tokenAnruf('bestaetigen', { token: vToken })).status, 200)
await warteAufVersand()
const mToken = tokenAusMail(mailsAn('alt@example.com').find((m) => /#m=/.test(m.text)), 'm')
assert.equal((await tokenAnruf('verschieben', { token: mToken, tag: TAG_FERN, zeit: '11:30' })).status, 200)
assert.equal((await tokenAnruf('absagen', { token: mToken })).status, 200)
const danach = aw.docs('termineBookings').get(buchungAlt.$id)
assert.equal(danach.status, 'abgesagt')
assert.equal(danach.nachweis, nachweisAlt, 'der Nachweis der Altbuchung bleibt byte-gleich')
assert.equal(JSON.parse(danach.nachweis).fassung, '2026-10-02')
// die neue Buchung ist davon unberührt
assert.equal(JSON.parse(aw.docs('termineBookings').get(neu).nachweis).fassung, RECHTSTEXTE_VERSION)
assert.ok(verwaltung)
})
test('zu viele offene Termine: dieselbe 201-Antwort, keine neue Buchung, statt der Bestaetigung geht G01 "zu viele"', async () => {
await speichere()
// drei bestaetigte Termine belegen die drei offenen Plaetze; die Kappe der Adresse (3 Mails je Stunde) ist damit ausgeschoepft,
// darum kommt die vierte Anfrage erst eine Stunde spaeter bis zu "zu viele" (in der Stunde selbst bliebe sie still, siehe oben)
for (const zeit of ['10:00', '11:00', '12:00']) await gebucht({ zeit })
setzeUhrFuerTests(() => plus(JETZT, 61 * 60000))
await warteAufVersand()
const vorher = sink.sent.length
const vierte = await buchen({ zeit: '13:00' })
assert.deepEqual([vierte.status, vierte.body], [201, { ok: true, email: 'm***@example.com', minuten: 30 }])
await warteAufVersand()
assert.equal(buchungen().length, 3)
assert.equal(sink.sent.length, vorher + 1)
assert.doesNotMatch(sink.sent.at(-1).text, /termin\.html#v=/, 'keine Bestaetigung')
})
// ---------------------------------------------------------------- Buchen: Grenzen
test('11. Buchungsversuch je IP in 10 Minuten: 429; andere IPs bleiben frei', async () => {
await speichere()
for (let i = 0; i < 10; i += 1) assert.equal((await buchen({ kontakt_url: 'x' })).status, 201, `Versuch ${i + 1}`)
const elfte = await buchen({ zeit: '10:00' })
assert.equal(elfte.status, 429)
assert.deepEqual(Object.keys(elfte.body), ['fehler'])
assert.deepEqual(Object.keys(elfte.body.fehler), ['_'])
assert.ok(Number(elfte.headers.get('retry-after')) > 0)
assert.equal(buchungen().length, 0, 'auch ein gueltiges Formular wird nicht mehr angenommen')
assert.equal((await buchen({ kontakt_url: 'x' }, { ip: '198.51.100.9' })).status, 201, 'andere IP')
setzeUhrFuerTests(() => plus(JETZT, 10 * 60000))
assert.equal((await buchen({ kontakt_url: 'x' })).status, 201, 'nach 10 Minuten wieder')
})
test('IPv6: alle Adressen eines /64 teilen sich die 10 Buchungsversuche, ein anderes /64 nicht', async () => {
await speichere()
for (let i = 0; i < 5; i += 1) assert.equal((await buchen({ kontakt_url: 'x' }, { ip: `2001:db8:1:2::${i + 1}` })).status, 201)
for (let i = 0; i < 5; i += 1) assert.equal((await buchen({ kontakt_url: 'x' }, { ip: `2001:db8:1:2:ffff::${i + 1}` })).status, 201)
assert.equal((await buchen({ kontakt_url: 'x' }, { ip: '2001:0db8:0001:0002:0000:0000:0000:0099' })).status, 429)
assert.equal((await buchen({ kontakt_url: 'x' }, { ip: '2001:db8:1:3::1' })).status, 201)
assert.equal((await buchen({ kontakt_url: 'x' }, { ip: '::ffff:203.0.113.200' })).status, 201, 'IPv4 in IPv6 zaehlt als IPv4')
})
test('die IP kommt vom Proxy: ein selbst gesetzter erster Eintrag in X-Forwarded-For zaehlt nicht', async () => {
await speichere()
for (let i = 0; i < 10; i += 1) {
assert.equal((await buchen({ kontakt_url: 'x' }, { ip: `192.0.2.${i + 1}, ${ip}` })).status, 201)
}
assert.equal((await buchen({ kontakt_url: 'x' }, { ip: `192.0.2.99, ${ip}` })).status, 429, 'gezaehlt wird der letzte Eintrag (trust proxy 1)')
})
test('je siteKey 25 gehaltene Termine pro Stunde: danach 429, ohne dass eine Buchung entsteht; Honeypot-Versuche zaehlen nicht mit', async () => {
await speichere()
for (let i = 0; i < 25; i += 1) assert.equal(darfHaltung(SITE_KEY, JETZT), true)
const res = await buchen()
assert.equal(res.status, 429)
assert.deepEqual(Object.keys(res.body.fehler), ['_'])
assert.equal(buchungen().length, 0)
resetTermineLimitsForTests()
for (let i = 0; i < 24; i += 1) assert.equal(darfHaltung(SITE_KEY, JETZT), true)
for (let i = 0; i < 3; i += 1) assert.equal((await buchen({ kontakt_url: 'x' }, { ip: `198.51.100.${i + 1}` })).status, 201)
assert.equal((await buchen({ zeit: '10:00' })).status, 201, 'die 25. Haltung geht noch')
assert.equal((await buchen({ zeit: '11:00', email: 'anna@example.com' })).status, 429, 'die 26. nicht')
assert.equal(buchungen().length, 1)
})
test('je Adresse 3 Bestaetigungsmails pro Stunde: die vierte bis sechste Anfrage bekommt dieselbe Antwort, aber keine Haltung und keine Mail', async () => {
await speichere()
const zeiten = ['10:00', '11:00', '12:00', '13:00', '14:00', '15:00']
const antworten = []
for (const zeit of zeiten) antworten.push(await buchen({ zeit, email: 'Viel@Example.com' }))
await warteAufVersand()
assert.ok(antworten.every((a) => a.status === 201), JSON.stringify(antworten.map((a) => a.status)))
assert.ok(antworten.every((a) => JSON.stringify(a.body) === JSON.stringify(antworten[0].body)), 'alle Antworten gleich')
assert.deepEqual(antworten[0].body, { ok: true, email: 'v***@example.com', minuten: 30 })
assert.equal(buchungen().length, 3, 'nur drei offene Termine')
assert.equal(mailsAn('viel@example.com').length, 3, 'drei Bestaetigungen; die vierte bis sechste Anfrage bekommt nichts (auch keine "zu viele")')
// andere Adressen zaehlen getrennt; Fehler der Zeit kommen vor der Mailgrenze (kein Orakel, ob die Adresse an der Grenze ist)
const andere = await buchen({ zeit: '16:00', email: 'anna@example.com' })
assert.equal(andere.status, 201)
const ungueltig = await buchen({ zeit: '17:30', email: 'viel@example.com' }) // 17:30 liegt nicht im Raster der freien Zeiten
assert.equal(ungueltig.status, 409, 'Fehler der Zeit kommen vor der Mailgrenze')
})
test('Lesen: 120 Abrufe je IP und Minute (IPv6 je /64), 600 je siteKey; danach 429 mit Retry-After', async () => {
await speichere()
for (let i = 0; i < 120; i += 1) assert.equal((await api('GET', `/${SITE_KEY}/config`)).status, 200, `Abruf ${i + 1}`)
const zuviel = await api('GET', `/${SITE_KEY}/config`)
assert.equal(zuviel.status, 429)
assert.deepEqual(Object.keys(zuviel.body.fehler), ['_'])
assert.ok(Number(zuviel.headers.get('retry-after')) > 0)
assert.equal((await api('GET', `/${SITE_KEY}/config`, { ip: '198.51.100.1' })).status, 200, 'andere IP')
assert.equal((await api('GET', `/${SITE_KEY}/tage?leistung=${S_SOFORT}&ressource=egal&monat=2026-10`)).status, 429, 'tage zaehlt mit')
setzeUhrFuerTests(() => plus(JETZT, 60000))
assert.equal((await api('GET', `/${SITE_KEY}/config`)).status, 200, 'nach einer Minute wieder')
resetTermineLimitsForTests()
for (let i = 0; i < 5; i += 1) {
for (let k = 0; k < 120; k += 1) assert.equal((await api('GET', `/${SITE_KEY}/config`, { ip: `198.51.100.${i + 10}` })).status, 200)
}
assert.equal((await api('GET', `/${SITE_KEY}/config`, { ip: '198.51.100.99' })).status, 429, 'der siteKey ist ausgeschoepft')
assert.deepEqual((await api('GET', '/zzzzzzzzzzzzzzzz/config', { ip: '198.51.100.99' })).body, AKTIV_FALSE, 'unbekannte Schluessel haben kein eigenes Kontingent')
})
test('Widerruf: 10 Erklaerungen je IP und Stunde (beide Wege), danach 429', async () => {
await speichere()
for (let i = 0; i < 5; i += 1) {
assert.equal((await api('POST', `/${SITE_KEY}/widerruf`, { body: { name: 'Max', email: 'max@example.com', nummer: 'ABCD1234' } })).status, 200)
assert.equal((await tokenAnruf('widerruf', { token: 'x'.repeat(43), name: 'Max' })).status, 404)
}
const elfte = await api('POST', `/${SITE_KEY}/widerruf`, { body: { name: 'Max', email: 'max@example.com', nummer: 'ABCD1234' } })
assert.equal(elfte.status, 429)
assert.equal((await tokenAnruf('widerruf', { token: 'x'.repeat(43), name: 'Max' })).status, 429)
assert.equal((await api('POST', `/${SITE_KEY}/widerruf`, { body: { name: 'Max', email: 'max@example.com', nummer: '' }, ip: '198.51.100.7' })).status, 200)
})
test('Mindestantwortzeit: Buchung und beide Widerrufe antworten nie schneller als minAntwortMs, auch mit Fehler', async () => {
await speichere()
const { id } = await gebucht()
config.termine.minAntwortMs = 200
const dauer = async (fn) => {
const start = performance.now()
const res = await fn()
return { res, ms: performance.now() - start }
}
const nummer = buchungsNummer(id)
const treffer = await dauer(() => api('POST', `/${SITE_KEY}/widerruf`, { body: { name: 'Max', email: 'max@example.com', nummer } }))
const ohne = await dauer(() => api('POST', `/${SITE_KEY}/widerruf`, { body: { name: 'Max', email: 'max@example.com', nummer: 'AAAAAAAA' } }))
const honig = await dauer(() => buchen({ kontakt_url: 'x' }))
const fehler = await dauer(() => buchen({ name: '' }))
const token = await dauer(() => tokenAnruf('widerruf', { token: 'x'.repeat(43), name: 'Max' }))
for (const [was, d] of Object.entries({ treffer, ohne, honig, fehler, token })) assert.ok(d.ms >= 190, `${was}: ${Math.round(d.ms)} ms`)
assert.deepEqual(treffer.res.body, ohne.res.body)
assert.equal(honig.res.status, 201)
assert.equal(fehler.res.status, 400)
// lesende Abrufe sind nicht verzoegert
const lesen = await dauer(() => api('GET', `/${SITE_KEY}/config`))
assert.ok(lesen.ms < 150, `config: ${Math.round(lesen.ms)} ms`)
})
// ---------------------------------------------------------------- Tage und Zeiten
test('tage: Tage mit freien Zeiten und Monatsdaten, 15 Sekunden Cache; Eingaben werden geprueft', async () => {
await speichere()
const res = await api('GET', `/${SITE_KEY}/tage?leistung=${S_SOFORT}&ressource=egal&monat=2026-10`, { origin: null })
assert.equal(res.status, 200)
assert.equal(res.headers.get('cache-control'), 'public, max-age=15')
assert.equal(res.headers.get('access-control-allow-origin'), '*')
assert.deepEqual(res.body.tage, ['2026-10-05', '2026-10-12', '2026-10-19', '2026-10-26'], 'montags 09-18 Uhr; heute ab 12:00')
assert.deepEqual(res.body.monat, { label: 'Oktober 2026', ersterWochentag: 4, anzahlTage: 31, vorher: '2026-09', nachher: '2026-11' })
const einzeln = await api('GET', `/${SITE_KEY}/tage?leistung=${S_SOFORT}&ressource=${R1}&monat=2026-11`)
assert.deepEqual(einzeln.body.tage, ['2026-11-02', '2026-11-09', '2026-11-16', '2026-11-23', '2026-11-30'])
const fehler = [
[`leistung=${S_SOFORT}&ressource=egal&monat=2026-13`, 'monat'],
[`leistung=${S_SOFORT}&ressource=egal&monat=`, 'monat'],
[`leistung=${S_SOFORT}&ressource=egal`, 'monat'],
[`leistung=${S_SOFORT}&ressource=egal&monat=0050-05`, 'monat'],
['leistung=s_unbekannt&ressource=egal&monat=2026-10', 'leistung'],
['ressource=egal&monat=2026-10', 'leistung'],
[`leistung=${S_SOFORT}&leistung=${S_GRATIS}&ressource=egal&monat=2026-10`, 'leistung'],
[`leistung=${S_SOFORT}&ressource=r_unbekannt&monat=2026-10`, 'ressource'],
]
for (const [abfrage, feld] of fehler) {
const f = await api('GET', `/${SITE_KEY}/tage?${abfrage}`)
assert.equal(f.status, 400, abfrage)
assert.deepEqual(Object.keys(f.body), ['fehler'], abfrage)
assert.deepEqual(Object.keys(f.body.fehler), [feld], abfrage)
}
})
test('zeiten: Uhrzeiten, Datum lang und vorzeitigNoetig fuer genau diesen Tag; ohne Angabe der Person', async () => {
await speichere()
const nah = await api('GET', `/${SITE_KEY}/zeiten?leistung=${S_SOFORT}&ressource=egal&tag=${TAG_NAH}`)
assert.equal(nah.status, 200)
assert.equal(nah.headers.get('cache-control'), 'no-store')
assert.equal(nah.headers.get('access-control-allow-origin'), '*')
assert.deepEqual(Object.keys(nah.body).sort(), ['datumLang', 'vorzeitigNoetig', 'zeiten'])
assert.equal(nah.body.datumLang, 'Montag, 12.10.2026')
assert.equal(nah.body.vorzeitigNoetig, true, 'Beginn innerhalb der Widerrufsfrist einer bezahlten Verbraucherbuchung')
assert.equal(nah.body.zeiten[0], '09:00')
assert.equal(nah.body.zeiten.at(-1), '17:00', 'Block 45 + 5 Minuten muss vor 18:00 enden')
assert.ok(nah.body.zeiten.every((z) => /^\d\d:\d\d$/.test(z)))
const fern = await api('GET', `/${SITE_KEY}/zeiten?leistung=${S_SOFORT}&ressource=egal&tag=${TAG_FERN}`)
assert.equal(fern.body.vorzeitigNoetig, false)
const gratis = await api('GET', `/${SITE_KEY}/zeiten?leistung=${S_GRATIS}&ressource=egal&tag=${TAG_NAH}`)
assert.equal(gratis.body.vorzeitigNoetig, false, 'ohne Preis keine Zustimmung')
const anfrage = await api('GET', `/${SITE_KEY}/zeiten?leistung=${S_ANFRAGE}&ressource=egal&tag=${TAG_FERN}`)
assert.equal(anfrage.body.vorzeitigNoetig, false)
const frei = await api('GET', `/${SITE_KEY}/zeiten?leistung=${S_SOFORT}&ressource=egal&tag=2026-10-13`)
assert.deepEqual(frei.body, { zeiten: [], datumLang: 'Dienstag, 13.10.2026', vorzeitigNoetig: false }, 'Dienstag: geschlossen')
for (const abfrage of [`leistung=${S_SOFORT}&ressource=egal&tag=2026-02-30`, `leistung=${S_SOFORT}&ressource=egal`, `leistung=x&ressource=egal&tag=${TAG_FERN}`]) {
const f = await api('GET', `/${SITE_KEY}/zeiten?${abfrage}`)
assert.equal(f.status, 400, abfrage)
}
})
test('tage und zeiten: 10 Sekunden Speicher-Cache je siteKey und Parameter', async () => {
const ctx = await speichere()
const zeitenUrl = `/${SITE_KEY}/zeiten?leistung=${S_GRATIS}&ressource=${R1}&tag=${TAG_FERN}`
const tageUrl = `/${SITE_KEY}/tage?leistung=${S_GRATIS}&ressource=${R1}&monat=2026-10`
const vorher = await api('GET', zeitenUrl)
assert.ok(vorher.body.zeiten.includes('10:00'))
const tageVorher = await api('GET', tageUrl)
// die Zeit wird (am Cache vorbei) vergeben; zwei Termine an einem Tag: der Tag bleibt frei, die Zeit nicht
await halte(ctx, { ...buchungBody(), leistung: S_GRATIS, ressource: R1, zeit: '10:00', zustimmungen: { storno: true, vorzeitig: false } }, JETZT)
const gemerkt = await api('GET', zeitenUrl)
assert.deepEqual(gemerkt.body, vorher.body, 'innerhalb von 10 Sekunden die gemerkte Antwort')
const andereRessource = await api('GET', `/${SITE_KEY}/zeiten?leistung=${S_GRATIS}&ressource=${R2}&tag=${TAG_FERN}`)
assert.ok(andereRessource.body.zeiten.includes('10:00'), 'andere Parameter, anderer Eintrag')
setzeUhrFuerTests(() => plus(JETZT, 9999))
assert.deepEqual((await api('GET', zeitenUrl)).body, vorher.body, 'noch keine 10 Sekunden')
setzeUhrFuerTests(() => plus(JETZT, 10000))
const frisch = await api('GET', zeitenUrl)
assert.ok(!frisch.body.zeiten.includes('10:00'), 'nach 10 Sekunden neu berechnet')
assert.deepEqual(frisch.body.zeiten, vorher.body.zeiten.filter((z) => !['09:45', '10:00', '10:15'].includes(z)), 'nur die vergebene Zeit und ihre Ueberlappungen fehlen')
assert.deepEqual((await api('GET', tageUrl)).body, tageVorher.body, 'der Tag bleibt frei (andere Zeiten sind noch da)')
})
// ---------------------------------------------------------------- Ablauf ueber HTTP
test('Ablauf: halten, Vorschau, bestaetigen, Verwaltungslink, freie Zeiten, verschieben, absagen', async () => {
await speichere()
const halten = await buchen({ zeit: '10:00', ressource: R1 })
assert.equal(halten.status, 201)
await warteAufVersand()
const vToken = tokenAusMail(mailsAn('max@example.com')[0], 'v')
const vorschau = await tokenAnruf('vorschau', { token: vToken })
assert.equal(vorschau.status, 200)
assert.equal(vorschau.body.art, 'bestaetigen')
assert.equal(vorschau.body.status, 'gehalten')
assert.equal(vorschau.body.knopf, 'Zahlungspflichtig bestätigen')
assert.equal(vorschau.body.termin.leistung, 'Damenschnitt')
assert.equal(vorschau.body.termin.zeit, '10:00')
assert.equal(vorschau.body.aktionen.bestaetigen, true)
assert.equal(vorschau.body.gast, null)
assert.equal(vorschau.headers.get('cache-control'), 'no-store')
assert.equal(vorschau.headers.get('access-control-allow-origin'), O_PORTAL)
const [buchung] = buchungen()
assert.ok(!JSON.stringify(vorschau.body).includes(buchung.$id) && !JSON.stringify(vorschau.body).includes(FEATURE))
const bestaetigt = await tokenAnruf('bestaetigen', { token: vToken })
assert.deepEqual([bestaetigt.status, bestaetigt.body], [200, { ok: true, status: 'bestaetigt' }])
const nochmal = await tokenAnruf('bestaetigen', { token: vToken })
assert.deepEqual([nochmal.status, nochmal.body], [200, { ok: true, status: 'bestaetigt', schon: true }])
await warteAufVersand()
const g02 = mailsAn('max@example.com').find((m) => /#m=/.test(m.text))
const mToken = tokenAusMail(g02, 'm')
const verwalten = await tokenAnruf('vorschau', { token: mToken })
assert.equal(verwalten.body.art, 'verwalten')
assert.deepEqual(verwalten.body.gast, { name: 'Max Mustermann', email: 'max@example.com' })
assert.equal(verwalten.body.aktionen.absagen, true)
assert.equal(verwalten.body.aktionen.verschieben, true)
const tage = await tokenAnruf('tage', { token: mToken, monat: '2026-10' })
assert.equal(tage.status, 200)
assert.ok(tage.body.tage.includes(TAG_FERN))
assert.equal(tage.body.monat.label, 'Oktober 2026')
const zeiten = await tokenAnruf('zeiten', { token: mToken, tag: TAG_FERN })
assert.ok(zeiten.body.zeiten.includes('10:00') && zeiten.body.zeiten.includes('10:30'))
assert.deepEqual(Object.keys(zeiten.body), ['zeiten'])
const verschoben = await tokenAnruf('verschieben', { token: mToken, tag: TAG_FERN, zeit: '10:30' })
assert.equal(verschoben.status, 200)
assert.deepEqual(verschoben.body, { ok: true, status: 'bestaetigt', termin: { datum: 'Montag, 26.10.2026', zeit: '10:30' } })
assert.equal(new Date(aw.docs('termineBookings').get(buchung.$id).startsAt).toISOString(), '2026-10-26T09:30:00.000Z')
const abgesagt = await tokenAnruf('absagen', { token: mToken })
assert.deepEqual([abgesagt.status, abgesagt.body], [200, { ok: true, status: 'abgesagt' }])
assert.equal(aw.docs('termineBookings').get(buchung.$id).status, 'abgesagt')
assert.equal(claims(buchung.$id).length, 0)
assert.equal((await tokenAnruf('absagen', { token: mToken })).body.schon, true)
})
test('Token-Fehler: unbekannter Link 404, abgelaufene Reservierung 410 mit Betrieb, Frist vorbei 409; immer { fehler, code }', async () => {
await speichere()
const unbekannt = await tokenAnruf('vorschau', { token: 'x'.repeat(43) })
assert.deepEqual([unbekannt.status, unbekannt.body], [404, { fehler: { _: 'Dieser Link ist ungültig oder abgelaufen.' }, code: 'unbekannt' }])
for (const body of [{}, { token: 5 }, { token: '' }, { token: 'zu kurz' }, [], { token: { $ne: null } }]) {
const res = await tokenAnruf('vorschau', body)
assert.equal(res.status, 404, JSON.stringify(body))
}
await buchen({ zeit: '10:00' })
await warteAufVersand()
const vToken = tokenAusMail(mailsAn('max@example.com')[0], 'v')
setzeUhrFuerTests(() => plus(JETZT, 31 * 60000))
const abgelaufen = await tokenAnruf('bestaetigen', { token: vToken })
assert.equal(abgelaufen.status, 410)
assert.equal(abgelaufen.body.code, 'abgelaufen')
assert.deepEqual(abgelaufen.body.fehler, { _: 'Diese Reservierung ist abgelaufen.' })
assert.deepEqual(abgelaufen.body.betrieb, { name: 'Salon Müller', telefon: '06301 1234', websiteUrl: 'https://salon.de/' })
setzeUhrFuerTests(() => JETZT)
const { verwaltung } = await gebucht({ zeit: '11:00', email: 'anna@example.com' })
setzeUhrFuerTests(() => new Date('2026-10-26T08:00:00.000Z')) // 2 Stunden vor dem Termin
const frist = await tokenAnruf('absagen', { token: verwaltung })
assert.equal(frist.status, 409)
assert.equal(frist.body.code, 'frist_vorbei')
assert.match(frist.body.fehler._, /telefonisch/)
})
test('Widerruf mit und ohne Treffer: dieselbe Antwort; mit Treffer ist der Termin widerrufen', async () => {
await speichere()
const { id } = await gebucht({ zeit: '10:00' })
const nummer = buchungsNummer(id)
const erwartet = { ok: true, eingang: { datum: '05.10.2026', uhrzeit: '10:00' } }
const ohne = await api('POST', `/${SITE_KEY}/widerruf`, { body: { name: 'Max Mustermann', email: 'max@example.com', nummer: 'ZZZZZZZZ' } })
assert.deepEqual([ohne.status, ohne.body], [200, erwartet])
assert.equal(aw.docs('termineBookings').get(id).status, 'bestaetigt')
const fremd = await api('POST', `/${SITE_KEY}/widerruf`, { body: { name: 'Max Mustermann', email: 'anna@example.com', nummer } })
assert.deepEqual([fremd.status, fremd.body], [200, erwartet], 'richtige Nummer, fremde Adresse')
assert.equal(aw.docs('termineBookings').get(id).status, 'bestaetigt')
const treffer = await api('POST', `/${SITE_KEY}/widerruf`, { body: { name: 'Max Mustermann', email: ' MAX@example.com ', nummer: nummer.toLowerCase() } })
assert.deepEqual([treffer.status, treffer.body], [200, erwartet])
const b = aw.docs('termineBookings').get(id)
assert.equal(b.status, 'abgesagt')
assert.ok(b.widerrufenAt)
assert.equal(claims(id).length, 0)
assert.equal(treffer.headers.get('cache-control'), 'no-store')
const fehler = await api('POST', `/${SITE_KEY}/widerruf`, { body: { name: '', email: 'kaputt' } })
assert.equal(fehler.status, 400)
assert.deepEqual(Object.keys(fehler.body.fehler).sort(), ['email', 'name'])
})
test('Widerruf mit Verwaltungslink (/termin/widerruf): nur mit Namen, Eingang in Berliner Zeit', async () => {
await speichere()
const { id, verwaltung } = await gebucht({ zeit: '10:00' })
const ohneName = await tokenAnruf('widerruf', { token: verwaltung, name: '' })
assert.equal(ohneName.status, 400)
assert.deepEqual(Object.keys(ohneName.body.fehler), ['name'])
const ok = await tokenAnruf('widerruf', { token: verwaltung, name: 'Max Mustermann' })
assert.deepEqual([ok.status, ok.body], [200, { ok: true, eingang: { datum: '05.10.2026', uhrzeit: '10:00' } }])
assert.equal(aw.docs('termineBookings').get(id).status, 'abgesagt')
const unbekannt = await tokenAnruf('widerruf', { token: 'x'.repeat(43), name: 'Max' })
assert.equal(unbekannt.status, 404)
})
// ---------------------------------------------------------------- Kalender-Abo
test('Kalender-Abo: falsches Token 404 ohne Hinweis; richtiges liefert bestaetigte Termine und Sperren der Ressource im Zeitraum', async () => {
const ctx = await speichere()
const [r1, r2] = ctx.einstellung.ressourcen
const bestaetigt = await gebucht({ zeit: '10:00', ressource: R1 })
await buchen({ zeit: '14:00', ressource: R1, email: 'gehalten@example.com' }) // nur gehalten
const andere = await gebucht({ zeit: '10:00', ressource: R2, email: 'ben@example.com' })
await sperre(ctx, { ressource: R1, tag: '2026-10-12', von: '13:00', bis: '15:00', titel: 'Fortbildung' }, JETZT)
await sperre(ctx, { ressource: R1, tag: '2027-01-11', von: '13:00', bis: '15:00', titel: 'Zu spaet' }, JETZT) // 98 Tage spaeter
const feed = await api('GET', `/kalender/${SITE_KEY}/${r1.feedToken}.ics`, { origin: null })
assert.equal(feed.status, 200)
assert.equal(feed.headers.get('content-type'), 'text/calendar; charset=utf-8')
assert.match(feed.headers.get('cache-control'), /max-age=300/)
assert.equal(feed.headers.get('etag'), null)
const ics = feed.body.replace(/\r\n /g, '')
assert.match(ics, /^BEGIN:VCALENDAR\r\n/)
assert.match(ics, /METHOD:PUBLISH/)
assert.match(ics, /X-WR-CALNAME:Salon Müller – Anna/)
assert.equal((ics.match(/BEGIN:VEVENT/g) ?? []).length, 2)
assert.ok(ics.includes(`UID:${bestaetigt.id}@termine.webklar.com`))
assert.ok(ics.includes('DTSTART:20261026T090000Z') && ics.includes('DTEND:20261026T094500Z'))
assert.ok(ics.includes('SUMMARY:Damenschnitt: Max Mustermann'))
assert.ok(ics.includes('SUMMARY:Fortbildung'))
assert.ok(!ics.includes('Zu spaet'), 'Sperre nach +90 Tagen')
assert.ok(!ics.includes(andere.id), 'Termin einer anderen Ressource')
assert.ok(!ics.includes('gehalten@example.com'))
const gehaltene = buchungen().find((b) => b.gastEmail === 'gehalten@example.com')
assert.equal(gehaltene.status, 'gehalten')
assert.ok(!ics.includes(gehaltene.$id), 'der gehaltene Termin steht nicht im Kalender')
assert.doesNotMatch(ics, /ATTENDEE/)
const fremd = await api('GET', `/kalender/${SITE_KEY}/${r2.feedToken}.ics`, { origin: null })
assert.ok(fremd.body.includes(andere.id) && !fremd.body.includes(bestaetigt.id), 'jede Ressource hat ihren eigenen Link')
const falsch = await api('GET', `/kalender/${SITE_KEY}/${'A'.repeat(43)}.ics`)
assert.equal(falsch.status, 404)
assert.deepEqual(falsch.body, { fehler: { _: 'Nicht gefunden' } })
for (const pfad of [`/kalender/${SITE_KEY}/${r1.feedToken.slice(1)}.ics`, `/kalender/${SITE_KEY}/${r1.feedToken}x.ics`, `/kalender/${SITE_KEY}/${r1.feedToken}`, `/kalender/${SITE_KEY}/.ics`, `/kalender/${SITE_KEY}/${r1.feedToken}.ICS`]) {
const res = await api('GET', pfad)
assert.equal(res.status, 404, pfad)
assert.deepEqual(res.body, falsch.body, pfad)
}
const unbekanntKey = await api('GET', `/kalender/zzzzzzzzzzzzzzzz/${r1.feedToken}.ics`)
assert.deepEqual([unbekanntKey.status, unbekanntKey.body], [404, falsch.body], 'kein Hinweis, ob der siteKey existiert')
})
test('Kalender-Abo: ein neu erzeugter Token macht den alten ungueltig; abgesagte und alte Termine fehlen', async () => {
const ctx = await speichere()
const r1 = ctx.einstellung.ressourcen[0]
const a = await gebucht({ zeit: '10:00', ressource: R1 })
const b = await gebucht({ zeit: '11:00', ressource: R1, email: 'anna@example.com' })
assert.equal((await tokenAnruf('absagen', { token: b.verwaltung })).status, 200)
// der Abo-Zeitraum beginnt 7 Tage vor dem Abruf: der Termin vom 26.10. (09:00-09:45 UTC) ist am 2.11. um 08:00 UTC noch drin
setzeUhrFuerTests(() => new Date('2026-11-02T08:00:00.000Z'))
const noch = await api('GET', `/kalender/${SITE_KEY}/${r1.feedToken}.ics`)
assert.equal(noch.status, 200)
assert.ok(noch.body.includes(a.id))
assert.ok(!noch.body.includes(b.id), 'abgesagt')
setzeUhrFuerTests(() => new Date('2026-11-02T10:00:00.000Z'))
const zuAlt = await api('GET', `/kalender/${SITE_KEY}/${r1.feedToken}.ics`)
assert.equal(zuAlt.status, 200)
assert.ok(!zuAlt.body.includes(a.id), 'nach den 7 Tagen nicht mehr')
assert.equal((zuAlt.body.match(/BEGIN:VEVENT/g) ?? []).length, 0)
const neu = einstellung()
neu.ressourcen[0].feedToken = 'B'.repeat(43)
await speichere(neu)
assert.equal((await api('GET', `/kalender/${SITE_KEY}/${r1.feedToken}.ics`)).status, 404)
assert.equal((await api('GET', `/kalender/${SITE_KEY}/${'B'.repeat(43)}.ics`)).status, 200)
})
// ---------------------------------------------------------------- Fehler, Unbekanntes, Kundenansicht
test('Appwrite nicht erreichbar: 503 { fehler } ohne Rohtext, bei GET, POST und Token-Aufrufen', async () => {
await speichere()
const RAW = 'Nachbau kaputt: geheimer Rohtext'
aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: RAW })
const config1 = await api('GET', `/${SITE_KEY}/config`)
assert.equal(config1.status, 503)
assert.deepEqual(config1.body, NICHT_ERREICHBAR)
assert.equal(config1.headers.get('cache-control'), 'no-store')
resetKontextForTests()
aw.failNext({ collection: 'termineBookings', method: 'GET', status: 500, message: RAW })
const post = await buchen()
assert.equal(post.status, 503)
assert.deepEqual(post.body, NICHT_ERREICHBAR)
assert.equal(buchungen().length, 0)
aw.failNext({ collection: 'termineBookings', method: 'GET', status: 500, message: RAW })
const token = await tokenAnruf('vorschau', { token: 'x'.repeat(43) })
assert.equal(token.status, 503)
assert.deepEqual(token.body, NICHT_ERREICHBAR)
aw.failNext({ collection: 'termineBookings', method: 'GET', status: 500, message: RAW })
const tage = await api('GET', `/${SITE_KEY}/tage?leistung=${S_SOFORT}&ressource=egal&monat=2026-10`)
assert.deepEqual([tage.status, tage.body], [503, NICHT_ERREICHBAR])
assert.equal((await api('GET', `/${SITE_KEY}/tage?leistung=${S_SOFORT}&ressource=egal&monat=2026-10`)).status, 200, 'der Fehler wurde nicht gemerkt')
for (const antwort of [config1, post, token, tage]) assert.ok(!JSON.stringify(antwort.body).includes('Nachbau'))
assert.ok(log.error.mock.calls.length >= 4, 'die Stoerungen stehen im Log')
assert.ok(log.error.mock.calls.every((c) => !JSON.stringify(c.arguments).includes('max@example.com')), 'keine Gastdaten im Log')
})
test('unbekannte Pfade und Methoden: 404 { fehler }, nie Express-Standardseiten', async () => {
await speichere()
for (const [methode, pfad] of [
['GET', '/'], ['GET', `/${SITE_KEY}`], ['GET', `/${SITE_KEY}/unbekannt`], ['POST', `/${SITE_KEY}/config`], ['GET', `/${SITE_KEY}/buchungen`],
['POST', '/termin/unbekannt'], ['GET', '/termin/vorschau'], ['PUT', `/${SITE_KEY}/buchungen`], ['DELETE', `/${SITE_KEY}/widerruf`],
]) {
const res = await api(methode, pfad)
assert.equal(res.status, 404, `${methode} ${pfad}`)
assert.deepEqual(res.body, { fehler: { _: 'Nicht gefunden' } }, `${methode} ${pfad}`)
}
})
test('Kundenansicht-Cookie (Mitarbeiter im Portal eines Kunden) stoert oeffentliche POSTs nicht', async () => {
await speichere()
const cookie = kundenansichtCookie()
const mit = await buchen({ kontakt_url: 'x' }, { cookie, headers: { 'x-kundenansicht': 'a'.repeat(32) } })
assert.equal(mit.status, 201)
const ohneKennung = await buchen({ kontakt_url: 'x' }, { cookie })
assert.equal(ohneKennung.status, 201, 'auch ohne Kennung der Ansicht (sonst 409)')
const fremdeKennung = await buchen({ kontakt_url: 'x' }, { cookie, headers: { 'x-kundenansicht': 'b'.repeat(32) } })
assert.equal(fremdeKennung.status, 201)
const abgelaufen = await buchen({ kontakt_url: 'x' }, { cookie: kundenansichtCookie({ viewExpiresAt: Date.now() - 1000 }) })
assert.equal(abgelaufen.status, 201, 'eine abgelaufene Ansicht wird hier nicht beendet (kein 401)')
const token = await tokenAnruf('vorschau', { token: 'x'.repeat(43) }, { cookie })
assert.equal(token.status, 404)
assert.equal(buchungen().length, 0)
})
test('Antworten tragen kein Set-Cookie und fuer Browser nosniff', async () => {
await speichere()
for (const res of [await api('GET', `/${SITE_KEY}/config`), await buchen({ kontakt_url: 'x' }), await tokenAnruf('vorschau', { token: 'x'.repeat(43) })]) {
assert.equal(res.headers.get('set-cookie'), null)
assert.equal(res.headers.get('x-content-type-options'), 'nosniff')
}
})
test('der Pruefer fuer interne IDs hat Zaehne: Felder, Werte, verschachtelt, UUIDs - die Buchungsnummer ist erlaubt', () => {
assert.doesNotThrow(() => pruefeOhneInternes({ ok: true, termin: { nummer: 'AB12CD34', leistung: 'Damenschnitt' }, ressourcen: [{ id: R1 }] }))
for (const schlecht of [
{ featureId: 'x' }, { a: { b: [{ customerId: 'x' }] } }, { $id: 'x' }, { $collectionId: 'termineBookings' }, { text: `Funktion ${FEATURE}` },
{ liste: [KUNDE] }, { id: '0b2a3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d' }, { projectId: PROJEKT },
]) {
assert.throws(() => pruefeOhneInternes(schlecht), assert.AssertionError, JSON.stringify(schlecht))
}
})
test('schreibt die Antwort selbst nicht (Serialisierung wirft): 503 { fehler } statt haengender Anfrage und unbehandelter Ablehnung', async () => {
await speichere()
const unbehandelt = []
const merke = (grund) => unbehandelt.push(grund)
process.on('unhandledRejection', merke)
const original = Buffer.byteLength
const ersatz = mock.method(Buffer, 'byteLength', function byteLength(text, ...rest) {
if (typeof text === 'string' && text.includes('"formToken"')) throw new Error('Nachbau kaputt: geheimer Rohtext')
return original.call(this, text, ...rest)
})
let res
try {
res = await Promise.race([
api('GET', `/${SITE_KEY}/config`),
new Promise((_, ab) => setTimeout(() => ab(new Error('Anfrage haengt: keine Antwort')), 3000).unref()),
])
await new Promise((resolve) => setImmediate(resolve)) // eine unbehandelte Ablehnung meldet sich erst danach
} finally {
ersatz.mock.restore()
process.off('unhandledRejection', merke)
}
assert.deepEqual([res.status, res.body], [503, NICHT_ERREICHBAR])
assert.equal(res.headers.get('cache-control'), 'no-store')
assert.deepEqual(unbehandelt, [])
assert.ok(!JSON.stringify(res.body).includes('geheimer'))
assert.ok(log.error.mock.calls.some((c) => String(c.arguments[0]).includes('Antwort nicht geschrieben')), 'steht im Log')
assert.equal((await api('GET', `/${SITE_KEY}/config`)).status, 200, 'danach geht es normal weiter')
})