Files
Webklar-Kundenbereich/test/termineRoutes.test.js
T
ClaudeandClaude Sonnet 5.5 e3e198fb1a Terminplaner: Kunden-Schnittstelle
/api/termine: Einstellungen (Vorlage, PATCH mit Version, Feed-Link), Buchungen
(Liste, Zeiten, von Hand, Sperre, Aktionen), CSV-Export, Protokoll der
Kundenansicht. Pflichtangaben fuer Gaeste (Telefon immer, Anschrift bei
Widerrufsbelehrung) in normalisiereEinstellung, Entwurf aus Vorlagen,
kontextPerSiteKey ohne Pflichtangaben nicht buchbar; speichereEinstellung
nimmt eine Funktion unter der Sperre.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:43 +00:00

962 lines
53 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. 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 { startFakeStripe } from './support/fakeStripe.js'
import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
// Kunden-Schnittstelle /api/termine (Aufgabe 18): Zugang (Projekt, Pilot, Funktion), Einstellungen (Vorlage, PATCH mit Version
// und Entfernen-Pruefung, Feed-Link), Buchungen (Liste, Zeiten, von Hand, Sperre, Aktionen), CSV-Export, Kundenansicht.
// Aufbau wie test/termineAblaufBetrieb.test.js (Nachbau von Appwrite mit Schema, Standard-Limit 25, feste Uhr, Mail-Senke).
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
termineConfig: schemaKeys('termineConfig'),
termineBookings: schemaKeys('termineBookings'),
termineClaims: schemaKeys('termineClaims'),
termineOutbox: schemaKeys('termineOutbox'),
customerViewLog: schemaKeys('customerViewLog'),
},
datetimes: TERMINE_DATETIMES,
defaultLimit: 25,
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, send } = await import('./support/testApp.js')
const { customerCookie, kundenansichtCookie, customers } = await import('./support/fixtures.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js')
const { default: termineRoutes, setzeUhrFuerTests, resetTermineRoutesForTests } = await import('../server/routes/termine.js')
const { default: publicTermineRoutes, resetPublicTermineForTests } = await import('../server/routes/publicTermine.js')
const { normalisiereEinstellung } = await import('../server/services/termine/einstellungen.js')
const { speichereEinstellung, ladeEinstellung, resetKontextForTests } = await import('../server/services/termine/kontext.js')
const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js')
const { resetTermineLimitsForTests } = await import('../server/services/termine/limits.js')
const { buchungsNummer } = await import('../server/services/termine/token.js')
const app = await startTestApp({
'/': kundenansichtGuard,
'/api/public/termine/v1': publicTermineRoutes,
'/api/termine': termineRoutes,
})
after(async () => {
setMailTransportForTests(null)
await Promise.all([app.close(), aw.close(), st.close()])
})
const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin (MESZ)
const TAG = '2026-10-12' // Montag, eine Woche spaeter
const KUNDE = 'c1'
const P = 'p1'
const FEATURE = 'f_p1_termine'
const SITE_KEY = 'abcdefghijklmnop'
const R1 = 'r_aaaaaaa1'
const R2 = 'r_aaaaaaa2'
const S_SOFORT = 's_bbbbbbb1' // Damenschnitt, 45 + 5 Minuten, 45 Euro, sofort
const S_ANFRAGE = 's_bbbbbbb2' // Besichtigung, 60 Minuten, ohne Preis, Anfrage
const VIEW = 'a'.repeat(32)
const MODUS_VORHER = config.termine.mode
const MAILS_VORHER = [...config.termine.pilotEmails]
const BASIS_VORHER = config.publicBaseUrl
const ABSENDER_VORHER = config.smtp.from
config.publicBaseUrl = app.url // die Kalender-Links zeigen auf diese Test-App
const cookie = customerCookie(KUNDE)
const iso = (date) => new Date(date).toISOString()
const kv = (extra = {}) => ({ cookie: kundenansichtCookie(extra), headers: { 'x-kundenansicht': extra.viewId || VIEW } })
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] },
],
regeln: { raster: 15, vorlaufMin: 120, horizontTage: 90, absageBisStd: 24, maxOffeneJeEmail: 3, personWaehlbar: true },
gastFelder: { telefon: 'optional', nachricht: 'optional', 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 featureDoc = (over = {}) => ({
$id: FEATURE,
customerId: KUNDE,
projectId: P,
featureKey: 'termine',
state: 'active',
enabled: true,
siteKey: SITE_KEY,
activatedAt: '2026-10-01T09:00:00.000Z',
...over,
})
/** Einstellung speichern (wie das Portal es nach dem ersten Oeffnen hat). */
async function speichere(e = einstellung(), feature = featureDoc()) {
resetKontextForTests()
const doc = aw.docs('termineConfig').get(feature.$id)
await speichereEinstellung(feature, e, { version: doc ? doc.version : 0, now: JETZT })
return e
}
const buchungsDoc = (id, over = {}) => ({
$id: id,
featureId: FEATURE,
customerId: KUNDE,
projectId: P,
leistungId: S_SOFORT,
leistungName: 'Damenschnitt',
ressourceId: R1,
ressourceName: 'Anna',
startsAt: `${TAG}T08:00:00.000+00:00`, // 10:00 Berlin
endsAt: `${TAG}T08:45:00.000+00:00`,
blockEndsAt: `${TAG}T08:50:00.000+00:00`,
status: 'bestaetigt',
quelle: 'online',
gastName: 'Max Mustermann',
gastEmail: 'max@example.com',
gastTelefon: '0171 1234567',
gastNachricht: '',
erinnerung: false,
preisCent: 4500,
preisArt: 'fest',
buchungsart: 'sofort',
verbraucher: true,
icsSequenz: 0,
createdAt: '2026-10-05T07:00:00.000+00:00',
updatedAt: '2026-10-05T07:00:00.000+00:00',
...over,
})
const seedBuchung = (id, over) => aw.seed('termineBookings', buchungsDoc(id, over))
const buchungen = () => [...aw.docs('termineBookings').values()]
const logEintraege = () => [...aw.docs('customerViewLog').values()]
const eintraege = () => [...aw.docs('termineOutbox').values()]
let sink
beforeEach(async () => {
for (const collection of ['portalFeatures', 'termineConfig', 'termineBookings', 'termineClaims', 'termineOutbox', 'customerViewLog', 'customers', 'websiteProjects']) {
for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id)
}
resetKontextForTests()
resetOutboxForTests()
resetTermineLimitsForTests()
resetPublicTermineForTests()
resetTermineRoutesForTests()
setzeUhrFuerTests(() => JETZT)
config.termine.mode = 'pilot'
config.termine.pilotEmails = ['*']
config.smtp.from = 'noreply@webklar.com'
sink = mailSink()
setMailTransportForTests(sink)
mock.method(console, 'error', () => {})
mock.method(console, 'warn', () => {})
// Kunde c1 mit Firma, Anschrift und Telefon im Konto; Kunde c2 ohne beides
aw.seed('customers', { ...customers.c1, companyName: 'Salon Müller', street: 'Hauptstr. 1', postalCode: '67722', city: 'Winnweiler', phone: '06301 1234' })
aw.seed('customers', customers.c2)
aw.seed('websiteProjects', { $id: P, customerId: KUNDE, projectName: 'Salon', subdomain: 'salon' })
aw.seed('websiteProjects', { $id: 'p2', customerId: KUNDE, projectName: 'Studio' })
aw.seed('websiteProjects', { $id: 'p3', customerId: 'c2', projectName: 'Fremd', subdomain: 'fremd' })
aw.seed('websiteProjects', { $id: 'p4', customerId: KUNDE, projectName: 'Alt' })
aw.seed('websiteProjects', { $id: 'p5', customerId: KUNDE, projectName: 'Ohne Funktion' })
aw.seed('websiteProjects', { $id: 'p6', customerId: 'c2', projectName: 'Ohne Telefon', subdomain: 'ohne' })
aw.seed('portalFeatures', featureDoc())
aw.seed('portalFeatures', featureDoc({ $id: 'f_p2_termine', projectId: 'p2', state: 'off', enabled: false, siteKey: 'bcdefghijklmnopq' }))
aw.seed('portalFeatures', featureDoc({ $id: 'f_p4_termine', projectId: 'p4', state: 'disabled', enabled: false, siteKey: 'cdefghijklmnopqr' }))
aw.seed('portalFeatures', featureDoc({ $id: 'f_p6_termine', customerId: 'c2', projectId: 'p6', siteKey: 'defghijklmnopqrs' }))
})
afterEach(async () => {
await warteAufVersand()
mock.restoreAll()
config.termine.mode = MODUS_VORHER
config.termine.pilotEmails = [...MAILS_VORHER]
config.smtp.from = ABSENDER_VORHER
})
after(() => {
config.publicBaseUrl = BASIS_VORHER
})
const einstellungenUrl = (projectId = P) => `/api/termine/einstellungen?projectId=${projectId}`
const lade = async (projectId = P) => (await send('GET', einstellungenUrl(projectId), { cookie })).body
// ---------------------------------------------------------------- Zugang
test('ohne Sitzung 401, ohne Projekt 400, fremdes Projekt 403 (auf jeder Route)', async () => {
await speichere()
assert.equal((await send('GET', einstellungenUrl())).status, 401)
assert.equal((await send('GET', '/api/termine/einstellungen', { cookie })).status, 400)
const fremd = [
['GET', einstellungenUrl('p3')],
['GET', '/api/termine/buchungen?projectId=p3&von=2026-10-01&bis=2026-10-31'],
['GET', '/api/termine/zeiten?projectId=p3'],
['GET', '/api/termine/export.csv?projectId=p3'],
['PATCH', '/api/termine/einstellungen', { projectId: 'p3', version: 0, einstellung: {} }],
['POST', '/api/termine/einstellungen/vorlage', { projectId: 'p3', vorlage: 'salon' }],
['POST', '/api/termine/buchungen', { projectId: 'p3', art: 'sperre' }],
['POST', '/api/termine/buchungen/x/aktion', { projectId: 'p3', aktion: 'annehmen' }],
['POST', `/api/termine/ressourcen/${R1}/feed`, { projectId: 'p3' }],
['DELETE', '/api/termine/buchungen/x?projectId=p3'],
['GET', einstellungenUrl('gibt es nicht')],
]
for (const [method, pfad, body] of fremd) {
const res = await send(method, pfad, { cookie, body })
assert.equal(res.status, 403, `${method} ${pfad}`)
assert.equal(res.body.error, 'Kein Zugriff auf dieses Projekt.')
}
})
test('ohne Pilot (Kunde nicht in der Liste, Modus off) 409 "noch nicht verfuegbar", auch beim Lesen', async () => {
await speichere()
config.termine.pilotEmails = ['andere@example.invalid']
for (const [method, pfad, body] of [['GET', einstellungenUrl()], ['PATCH', '/api/termine/einstellungen', { projectId: P, version: 1, einstellung: {} }]]) {
const res = await send(method, pfad, { cookie, body })
assert.equal(res.status, 409, `${method} ${pfad}`)
assert.equal(res.body.error, 'Der Terminplaner ist noch nicht verfügbar.')
}
config.termine.pilotEmails = [customers.c1.email]
assert.equal((await send('GET', einstellungenUrl(), { cookie })).status, 200, 'Adresse der Liste')
config.termine.mode = 'off'
assert.equal((await send('GET', einstellungenUrl(), { cookie })).status, 409)
})
test('Funktion aus, noch nicht bestellt oder ohne Dokument: 409 "nicht aktiv"', async () => {
for (const projectId of ['p2', 'p5']) {
for (const [method, pfad, body] of [
['GET', einstellungenUrl(projectId)],
['GET', `/api/termine/buchungen?projectId=${projectId}&von=2026-10-01&bis=2026-10-31`],
['GET', `/api/termine/export.csv?projectId=${projectId}`],
['POST', '/api/termine/einstellungen/vorlage', { projectId, vorlage: 'salon' }],
['PATCH', '/api/termine/einstellungen', { projectId, version: 0, einstellung: {} }],
]) {
const res = await send(method, pfad, { cookie, body })
assert.equal(res.status, 409, `${projectId} ${method} ${pfad}`)
assert.equal(res.body.error, 'Der Terminplaner ist für diese Website nicht aktiv.')
}
}
for (const state of ['ordered', 'setup']) {
aw.seed('portalFeatures', featureDoc({ $id: 'f_p5_termine', projectId: 'p5', state, enabled: false, siteKey: 'efghijklmnopqrst' }))
assert.equal((await send('GET', einstellungenUrl('p5'), { cookie })).status, 409, state)
}
})
// ---------------------------------------------------------------- Einstellungen: lesen, Vorlage
test('GET einstellungen ohne Einstellung: Zustand, Vorlagen, Einbau-Code, siteKey, keine Einstellung', async () => {
const res = await send('GET', einstellungenUrl(), { cookie })
assert.equal(res.status, 200)
const b = res.body
assert.equal(b.state, 'active')
assert.equal(b.aktivSeit, '2026-10-01T09:00:00.000Z')
assert.equal(b.einstellung, null)
assert.equal(b.version, 0)
assert.deepEqual(b.vorlagen.map((v) => v.key), ['salon', 'kosmetik', 'beratung', 'werkstatt', 'handwerk', 'tattoo', 'fotograf', 'hundesalon', 'sonstiges'])
assert.deepEqual(Object.keys(b.vorlagen[0]).sort(), ['beschreibung', 'key', 'name'])
assert.deepEqual(b.feeds, {})
assert.equal(b.siteKey, SITE_KEY)
assert.equal(b.anfragenOffen, 0)
assert.deepEqual(b.fehlt, [])
assert.match(b.einbau.snippet, new RegExp(`<div data-webklar-termine="${SITE_KEY}" data-api="${app.url}" data-modus="inline">`))
assert.ok(b.einbau.snippet.includes(`<script src="${app.url}/embed/termine.js" defer></script>`))
assert.ok(b.einbau.snippet.includes('<p>Termine telefonisch vereinbaren.</p>'), 'noch kein Telefon bekannt')
assert.equal(res.headers.get('cache-control'), 'no-store')
})
test('GET einstellungen mit Einstellung: ohne Feed-Token, Kalender-Links je Person, Einbau-Code mit Telefon, offene Anfragen', async () => {
const e = await speichere(einstellung({ betrieb: { ...eingabeEinstellung().betrieb, telefon: '06301 <12>' } }))
seedBuchung('b_anfrage1', { status: 'angefragt', leistungId: S_ANFRAGE })
seedBuchung('b_anfrage2', { status: 'angefragt', leistungId: S_ANFRAGE })
seedBuchung('b_fest', { status: 'bestaetigt' })
const res = await send('GET', einstellungenUrl(), { cookie })
const b = res.body
assert.equal(b.version, 1)
assert.equal(b.anfragenOffen, 2)
assert.deepEqual(Object.keys(b.feeds).sort(), [R1, R2])
for (const r of e.ressourcen) {
assert.equal(b.feeds[r.id], `${app.url}/api/public/termine/v1/kalender/${SITE_KEY}/${r.feedToken}.ics`)
assert.ok(!JSON.stringify(b.einstellung).includes(r.feedToken), 'der Token steht nur im Link')
}
assert.ok(b.einstellung.ressourcen.every((r) => !('feedToken' in r)))
assert.equal(b.einstellung.betrieb.name, 'Salon Müller')
assert.ok(b.einbau.snippet.includes('<p>Termine telefonisch unter 06301 &lt;12&gt;.</p>'), 'Telefon im Einbau-Code maskiert')
// der Link funktioniert an der oeffentlichen Schnittstelle
const feed = await fetch(b.feeds[R1])
assert.equal(feed.status, 200)
assert.match(feed.headers.get('content-type'), /^text\/calendar/)
})
test('POST vorlage: Betrieb aus dem Kundenkonto (Firma, Anschrift, E-Mail, Telefon), 201 mit Einstellung ohne Token; zweimal => 409', async () => {
const res = await send('POST', '/api/termine/einstellungen/vorlage', { cookie, body: { projectId: P, vorlage: 'salon' } })
assert.equal(res.status, 201)
assert.equal(res.body.version, 1)
assert.deepEqual(res.body.fehlt, [])
const betrieb = res.body.einstellung.betrieb
assert.equal(betrieb.name, 'Salon Müller')
assert.equal(betrieb.email, 'k@example.invalid')
assert.equal(betrieb.telefon, '06301 1234')
assert.equal(betrieb.adresse, 'Hauptstr. 1, 67722 Winnweiler')
assert.equal(res.body.einstellung.vorlage, 'salon')
assert.ok(res.body.einstellung.leistungen.length >= 2)
assert.ok(res.body.einstellung.ressourcen.every((r) => !('feedToken' in r)))
const gespeichert = await ladeEinstellung(FEATURE)
assert.equal(gespeichert.version, 1)
assert.ok(gespeichert.einstellung.ressourcen.every((r) => r.feedToken.length === 43))
assert.equal(aw.docs('termineConfig').get(FEATURE).updatedBy, 'kunde:c1')
const zweite = await send('POST', '/api/termine/einstellungen/vorlage', { cookie, body: { projectId: P, vorlage: 'tattoo' } })
assert.equal(zweite.status, 409)
assert.equal(zweite.body.code, 'einstellung_vorhanden')
assert.equal((await ladeEinstellung(FEATURE)).einstellung.vorlage, 'salon', 'nichts ueberschrieben')
// zwei gleichzeitige Anfragen auf eine frische Funktion: genau eine gewinnt
await deleteDocument('termineConfig', FEATURE)
const [a, b] = await Promise.all(['salon', 'kosmetik'].map((vorlage) => send('POST', '/api/termine/einstellungen/vorlage', { cookie, body: { projectId: P, vorlage } })))
assert.deepEqual([a.status, b.status].sort(), [201, 409])
})
test('POST vorlage: unbekannte, fehlende und Prototyp-Schluessel => 400', async () => {
for (const vorlage of ['gibtsnicht', '__proto__', 'constructor', 'toString', '', null, 5, undefined]) {
const res = await send('POST', '/api/termine/einstellungen/vorlage', { cookie, body: { projectId: P, vorlage } })
assert.equal(res.status, 400, String(vorlage))
assert.ok(res.body.fehler.vorlage)
}
assert.equal(aw.docs('termineConfig').size, 0)
})
test('POST vorlage ohne Telefon und Anschrift im Konto: Entwurf mit "fehlt"; oeffentlich nicht aktiv, bis die Angaben da sind (R41)', async () => {
const c2 = customerCookie('c2')
const res = await send('POST', '/api/termine/einstellungen/vorlage', { cookie: c2, body: { projectId: 'p6', vorlage: 'salon' } })
assert.equal(res.status, 201, JSON.stringify(res.body))
assert.deepEqual(res.body.fehlt, ['betrieb.telefon', 'betrieb.adresse'])
assert.equal(res.body.einstellung.betrieb.name, 'Kunde Zwei', 'ohne Firma der Name')
const gelesen = await send('GET', einstellungenUrl('p6'), { cookie: c2 })
assert.deepEqual(gelesen.body.fehlt, ['betrieb.telefon', 'betrieb.adresse'])
const oeffentlich = (siteKey) => fetch(`${app.url}/api/public/termine/v1/${siteKey}/config`).then((r) => r.json())
assert.deepEqual(await oeffentlich('defghijklmnopqrs'), { aktiv: false })
// PATCH prueft immer streng
const entwurf = gelesen.body.einstellung
const abgelehnt = await send('PATCH', '/api/termine/einstellungen', { cookie: c2, body: { projectId: 'p6', version: 1, einstellung: entwurf } })
assert.equal(abgelehnt.status, 400)
assert.deepEqual(Object.keys(abgelehnt.body.fehler).sort(), ['betrieb.adresse', 'betrieb.telefon'])
assert.match(abgelehnt.body.error, /Telefonnummer/)
const komplett = { ...entwurf, betrieb: { ...entwurf.betrieb, telefon: '0151 2345678', adresse: 'Weg 1, 12345 Ort' } }
const ok = await send('PATCH', '/api/termine/einstellungen', { cookie: c2, body: { projectId: 'p6', version: 1, einstellung: komplett } })
assert.equal(ok.status, 200, JSON.stringify(ok.body))
assert.deepEqual((await send('GET', einstellungenUrl('p6'), { cookie: c2 })).body.fehlt, [])
assert.equal((await oeffentlich('defghijklmnopqrs')).aktiv, true)
})
// ---------------------------------------------------------------- Einstellungen: PATCH
test('PATCH einstellungen: speichert, Version zaehlt hoch, Feed-Token bleiben (auch bei Wert vom Client), neue Personen bekommen einen', async () => {
await speichere()
const vorher = await lade()
const tokenVorher = (await ladeEinstellung(FEATURE)).einstellung.ressourcen.map((r) => r.feedToken)
const neu = structuredClone(vorher.einstellung)
neu.betrieb.name = 'Salon Neu'
neu.ressourcen[0].feedToken = 'x'.repeat(43) // wird ignoriert
neu.ressourcen.push({ name: 'Clara', typ: 'person', plan: { 2: [['09:00', '12:00']] } })
const res = await send('PATCH', '/api/termine/einstellungen', { cookie, body: { projectId: P, version: vorher.version, einstellung: neu } })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(Object.keys(res.body).sort(), ['einstellung', 'version'])
assert.equal(res.body.version, 2)
assert.equal(res.body.einstellung.betrieb.name, 'Salon Neu')
assert.ok(res.body.einstellung.ressourcen.every((r) => !('feedToken' in r)))
const gespeichert = (await ladeEinstellung(FEATURE)).einstellung
assert.deepEqual(gespeichert.ressourcen.slice(0, 2).map((r) => r.feedToken), tokenVorher)
assert.equal(gespeichert.ressourcen[2].feedToken.length, 43)
assert.equal(Object.keys((await lade()).feeds).length, 3)
assert.equal(aw.docs('termineConfig').get(FEATURE).updatedBy, 'kunde:c1')
})
test('PATCH einstellungen: alte Version => 409 EINSTELLUNG_VERALTET, Stand des Ersten bleibt', async () => {
await speichere()
const stand = await lade()
const aendere = (name, version = stand.version) => send('PATCH', '/api/termine/einstellungen', {
cookie,
body: { projectId: P, version, einstellung: { ...stand.einstellung, betrieb: { ...stand.einstellung.betrieb, name } } },
})
assert.equal((await aendere('Erster')).status, 200)
const zweiter = await aendere('Zweiter')
assert.equal(zweiter.status, 409)
assert.equal(zweiter.body.code, 'EINSTELLUNG_VERALTET')
assert.equal(zweiter.body.error, 'Die Einstellungen wurden inzwischen geändert. Bitte neu laden.')
assert.equal((await ladeEinstellung(FEATURE)).einstellung.betrieb.name, 'Erster')
assert.equal((await aendere('Zukunft', 9)).status, 409)
})
test('PATCH einstellungen: Version fehlt oder ist keine Ganzzahl => 400; Eingabe unbrauchbar => 400 mit Feldfehlern', async () => {
await speichere()
const stand = await lade()
for (const version of [undefined, null, '1', 1.5, -1, [], {}]) {
const res = await send('PATCH', '/api/termine/einstellungen', { cookie, body: { projectId: P, version, einstellung: stand.einstellung } })
assert.equal(res.status, 400, JSON.stringify(version))
assert.ok(res.body.fehler.version)
}
for (const einstellungWert of [null, 'x', 5, [], undefined]) {
const res = await send('PATCH', '/api/termine/einstellungen', { cookie, body: { projectId: P, version: 1, einstellung: einstellungWert } })
assert.equal(res.status, 400, JSON.stringify(einstellungWert))
assert.ok(res.body.fehler._)
}
const kaputt = structuredClone(stand.einstellung)
kaputt.betrieb.impressumUrl = 'javascript:alert(1)'
kaputt.farbe = 'rot'
const res = await send('PATCH', '/api/termine/einstellungen', { cookie, body: { projectId: P, version: 1, einstellung: kaputt } })
assert.equal(res.status, 400)
assert.deepEqual(Object.keys(res.body.fehler).sort(), ['betrieb.impressumUrl', 'farbe'])
assert.equal(res.body.error, res.body.fehler['betrieb.impressumUrl'])
assert.equal((await ladeEinstellung(FEATURE)).version, 1, 'nichts gespeichert')
})
test('PATCH einstellungen: Leistung oder Person mit kuenftigen Terminen loeschen => 400 mit Anzahl, Deaktivieren geht', async () => {
await speichere()
seedBuchung('b_a', { leistungId: S_ANFRAGE, status: 'angefragt' })
seedBuchung('b_b', { leistungId: S_ANFRAGE, status: 'bestaetigt', ressourceId: R2 })
seedBuchung('b_vorbei', { leistungId: S_ANFRAGE, startsAt: '2026-10-01T08:00:00.000+00:00', endsAt: '2026-10-01T09:00:00.000+00:00', blockEndsAt: '2026-10-01T09:00:00.000+00:00' })
const stand = await lade()
const patch = (einstellungWert, version = stand.version) => send('PATCH', '/api/termine/einstellungen', { cookie, body: { projectId: P, version, einstellung: einstellungWert } })
const ohneLeistung = { ...stand.einstellung, leistungen: stand.einstellung.leistungen.filter((l) => l.id !== S_ANFRAGE) }
const res = await patch(ohneLeistung)
assert.equal(res.status, 400)
assert.equal(res.body.fehler[`leistungen.${S_ANFRAGE}`], 'Für diese Leistung gibt es noch 2 künftige Termine. Deaktiviere sie stattdessen.')
assert.match(res.body.error, /2 künftige Termine/)
// ein abgesagter Termin zaehlt nicht mehr
seedBuchung('b_b', { leistungId: S_ANFRAGE, status: 'abgesagt', ressourceId: R2 })
const eins = await patch(ohneLeistung)
assert.equal(eins.status, 400)
assert.match(eins.body.error, /1 künftigen Termin\b/)
const ohnePerson = { ...stand.einstellung, ressourcen: stand.einstellung.ressourcen.filter((r) => r.id !== R1) }
const person = await patch(ohnePerson)
assert.equal(person.status, 400)
assert.ok(person.body.fehler[`ressourcen.${R1}`], 'auch die Person mit Terminen bleibt')
// deaktivieren: erlaubt, die Termine bleiben
const deaktiviert = { ...stand.einstellung, leistungen: stand.einstellung.leistungen.map((l) => (l.id === S_ANFRAGE ? { ...l, aktiv: false } : l)) }
const ok = await patch(deaktiviert)
assert.equal(ok.status, 200, JSON.stringify(ok.body))
assert.equal(ok.body.einstellung.leistungen.find((l) => l.id === S_ANFRAGE).aktiv, false)
assert.equal(buchungen().length, 3)
// ohne Termine: loeschen erlaubt
for (const id of [...aw.docs('termineBookings').keys()]) await deleteDocument('termineBookings', id)
const geloescht = await patch(ohneLeistung, ok.body.version)
assert.equal(geloescht.status, 200, JSON.stringify(geloescht.body))
})
// ---------------------------------------------------------------- Kalender-Link
test('POST ressourcen/:id/feed: neuer Link nur fuer diese Person, alter Link ungueltig, Version zaehlt hoch', async () => {
await speichere()
const vorher = await lade()
const res = await send('POST', `/api/termine/ressourcen/${R1}/feed`, { cookie, body: { projectId: P } })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.match(res.body.url, new RegExp(`^${app.url}/api/public/termine/v1/kalender/${SITE_KEY}/[A-Za-z0-9_-]{43}\\.ics$`))
assert.notEqual(res.body.url, vorher.feeds[R1])
assert.equal(res.body.version, 2, 'die Einstellungsseite braucht die neue Version fuer ihr naechstes Speichern')
const nachher = await lade()
assert.equal(nachher.feeds[R1], res.body.url)
assert.equal(nachher.feeds[R2], vorher.feeds[R2], 'die andere Person behaelt ihren Link')
assert.equal(nachher.version, 2)
assert.equal((await fetch(vorher.feeds[R1])).status, 404)
assert.equal((await fetch(res.body.url)).status, 200)
assert.equal((await fetch(vorher.feeds[R2])).status, 200)
// sonst bleibt alles gleich
assert.deepEqual(nachher.einstellung, vorher.einstellung)
})
test('POST ressourcen/:id/feed: unbekannte Person oder keine Einstellung => 404, nichts geschrieben', async () => {
const ohne = await send('POST', `/api/termine/ressourcen/${R1}/feed`, { cookie, body: { projectId: P } })
assert.equal(ohne.status, 404)
assert.equal(aw.docs('termineConfig').size, 0)
await speichere()
for (const id of ['r_unbekannt', '__proto__', 'x'.repeat(200)]) {
const res = await send('POST', `/api/termine/ressourcen/${id}/feed`, { cookie, body: { projectId: P } })
assert.equal(res.status, 404, id)
assert.equal(res.body.error, 'Diese Person oder diesen Platz gibt es nicht.')
}
assert.equal((await ladeEinstellung(FEATURE)).version, 1)
})
// ---------------------------------------------------------------- Buchungen lesen
test('GET buchungen: Felder, Sortierung, Zeitraum, Statusfilter, Sperren, Zustellfehler', async () => {
await speichere()
seedBuchung('b_spaet', { startsAt: '2026-10-12T13:00:00.000+00:00', endsAt: '2026-10-12T13:45:00.000+00:00', blockEndsAt: '2026-10-12T13:50:00.000+00:00', status: 'angefragt', leistungId: S_ANFRAGE, leistungName: 'Besichtigung', gastName: 'Spät', erinnerung: true })
seedBuchung('b_frueh', { angenommenAt: '2026-10-05T07:30:00.000+00:00', abgesagtVon: '', gastNachricht: 'Bitte ohne Föhnen' })
seedBuchung('b_abgesagt', {
startsAt: '2026-10-13T08:00:00.000+00:00', endsAt: '2026-10-13T08:45:00.000+00:00', blockEndsAt: '2026-10-13T08:50:00.000+00:00',
status: 'abgesagt', abgesagtVon: 'betrieb', absageGrund: 'Krank', widerrufenAt: '2026-10-06T10:00:00.000+00:00',
})
seedBuchung('b_sperre', {
startsAt: '2026-10-12T10:00:00.000+00:00', endsAt: '2026-10-12T11:00:00.000+00:00', blockEndsAt: '2026-10-12T11:00:00.000+00:00',
quelle: 'sperre', leistungId: '', leistungName: 'Mittagspause', gastName: '', gastEmail: '', gastTelefon: '', preisCent: 0, preisArt: 'keiner', verbraucher: false,
})
seedBuchung('b_oktober_spaet', { startsAt: '2026-11-20T08:00:00.000+00:00', endsAt: '2026-11-20T08:45:00.000+00:00', blockEndsAt: '2026-11-20T08:50:00.000+00:00' })
seedBuchung('b_anderer', { featureId: 'f_anderer_termine' })
aw.seed('termineOutbox', { $id: 'o1', featureId: FEATURE, bookingId: 'b_frueh', art: 'G02', status: 'fehler', an: 'max@example.com' })
aw.seed('termineOutbox', { $id: 'o2', featureId: FEATURE, bookingId: 'b_spaet', art: 'G02', status: 'gesendet', an: 'max@example.com' })
const res = await send('GET', '/api/termine/buchungen?projectId=p1&von=2026-10-12&bis=2026-10-13', { cookie })
assert.equal(res.status, 200)
assert.deepEqual(res.body.buchungen.map((b) => b.id), ['b_frueh', 'b_sperre', 'b_spaet', 'b_abgesagt'], 'nach Beginn, ohne fremde Funktion und ohne November')
const [frueh, sperre, spaet, abgesagt] = res.body.buchungen
assert.deepEqual(Object.keys(frueh), [
'id', 'nummer', 'status', 'quelle', 'startsAt', 'endsAt', 'datum', 'zeit', 'leistungId', 'leistung', 'dauer', 'titel', 'person', 'ressourceId',
'gastName', 'gastEmail', 'gastTelefon', 'gastNachricht', 'erinnerung', 'zustellFehler', 'angenommenAt', 'widerrufenAt', 'abgesagtVon', 'absageGrund',
])
assert.deepEqual(frueh, {
id: 'b_frueh', nummer: buchungsNummer('b_frueh'), status: 'bestaetigt', quelle: 'online',
startsAt: '2026-10-12T08:00:00.000Z', endsAt: '2026-10-12T08:45:00.000Z', datum: '2026-10-12', zeit: '10:00',
leistungId: S_SOFORT, leistung: 'Damenschnitt', dauer: 45, titel: 'Damenschnitt: Max Mustermann', person: 'Anna', ressourceId: R1,
gastName: 'Max Mustermann', gastEmail: 'max@example.com', gastTelefon: '0171 1234567', gastNachricht: 'Bitte ohne Föhnen',
erinnerung: false, zustellFehler: true, angenommenAt: '2026-10-05T07:30:00.000Z', widerrufenAt: null, abgesagtVon: '', absageGrund: '',
})
assert.match(frueh.nummer, /^[A-Z0-9]{8}$/)
assert.equal(spaet.zeit, '15:00')
assert.equal(spaet.erinnerung, true)
assert.equal(spaet.zustellFehler, false, 'gesendet ist kein Fehler')
assert.equal(sperre.quelle, 'sperre')
assert.equal(sperre.leistung, '')
assert.equal(sperre.titel, 'Mittagspause')
assert.equal(sperre.dauer, 60)
assert.equal(sperre.zustellFehler, false)
assert.equal(abgesagt.abgesagtVon, 'betrieb')
assert.equal(abgesagt.absageGrund, 'Krank', 'wie gespeichert')
assert.equal(abgesagt.widerrufenAt, '2026-10-06T10:00:00.000Z')
const gefiltert = await send('GET', '/api/termine/buchungen?projectId=p1&von=2026-10-12&bis=2026-10-13&status=angefragt,abgesagt', { cookie })
assert.deepEqual(gefiltert.body.buchungen.map((b) => b.id), ['b_spaet', 'b_abgesagt'])
const eins = await send('GET', '/api/termine/buchungen?projectId=p1&von=2026-10-12&bis=2026-10-12&status=bestaetigt', { cookie })
assert.deepEqual(eins.body.buchungen.map((b) => b.id), ['b_frueh', 'b_sperre'])
const leer = await send('GET', '/api/termine/buchungen?projectId=p1&von=2026-10-01&bis=2026-10-02', { cookie })
assert.deepEqual(leer.body, { buchungen: [] })
})
test('GET buchungen: Zeitraum hoechstens 62 Tage, von vor bis, gueltige Tage, nur bekannte Status', async () => {
const url = (query) => `/api/termine/buchungen?projectId=p1&${query}`
assert.equal((await send('GET', url('von=2026-10-01&bis=2026-12-02'), { cookie })).status, 200, '62 Tage Abstand')
for (const [query, feld] of [
['von=2026-10-01&bis=2026-12-03', 'bis'],
['von=2026-10-10&bis=2026-10-09', 'bis'],
['von=2026-10-01', 'von'],
['bis=2026-10-01', 'von'],
['von=heute&bis=morgen', 'von'],
['von=2026-02-30&bis=2026-03-01', 'von'],
['von=2026-10-01&bis=2026-10-02&status=bestaetigt,kaputt', 'status'],
['von=2026-10-01&bis=2026-10-02&status=bestaetigt&status=abgesagt', 'status'],
['von=2026-10-01&bis=2026-10-02&status=,', 'status'],
]) {
const res = await send('GET', url(query), { cookie })
assert.equal(res.status, 400, query)
assert.ok(res.body.fehler[feld], query)
}
})
test('GET zeiten: freie Zeiten ohne Vorlauf und Horizont (Betrieb), mit ohne=Buchung wieder frei; Eingabe geprueft', async () => {
await speichere()
const zeiten = (query) => send('GET', `/api/termine/zeiten?projectId=p1&${query}`, { cookie })
// Montag, 10:00 Berlin = jetzt: Gaeste sehen wegen vorlaufMin 120 erst ab 12:00, der Betrieb ab jetzt
const heute = await zeiten(`leistung=${S_SOFORT}&ressource=${R1}&tag=2026-10-05`)
assert.equal(heute.status, 200)
assert.deepEqual(Object.keys(heute.body), ['zeiten'])
assert.equal(heute.body.zeiten[0], '10:00')
assert.equal(heute.body.zeiten.at(-1), '17:00')
// 'egal' ist die Vorgabe
assert.deepEqual((await zeiten(`leistung=${S_SOFORT}&tag=2026-10-05`)).body.zeiten, heute.body.zeiten)
// jenseits des Buchungszeitraums (90 Tage)
assert.ok((await zeiten(`leistung=${S_SOFORT}&ressource=${R1}&tag=2027-03-01`)).body.zeiten.length > 0)
seedBuchung('b_belegt')
const belegt = await zeiten(`leistung=${S_SOFORT}&ressource=${R1}&tag=${TAG}`)
assert.ok(!belegt.body.zeiten.includes('10:00'))
assert.ok(!belegt.body.zeiten.includes('10:30'))
assert.ok(belegt.body.zeiten.includes('09:00'))
const mit = await zeiten(`leistung=${S_SOFORT}&ressource=${R1}&tag=${TAG}&ohne=b_belegt`)
assert.ok(mit.body.zeiten.includes('10:00'), 'die verschobene Buchung belegt ihre neue Zeit nicht selbst')
assert.ok((await zeiten(`leistung=${S_SOFORT}&ressource=egal&tag=${TAG}`)).body.zeiten.includes('10:00'), 'bei egal ist Ben frei')
for (const [query, feld] of [
[`ressource=${R1}&tag=${TAG}`, 'leistung'],
[`leistung=s_unbekannt&tag=${TAG}`, 'leistung'],
[`leistung=${S_SOFORT}&ressource=r_unbekannt&tag=${TAG}`, 'ressource'],
[`leistung=${S_SOFORT}&ressource=${R1}&ressource=${R2}&tag=${TAG}`, 'ressource'],
[`leistung=${S_SOFORT}`, 'tag'],
[`leistung=${S_SOFORT}&tag=2026-02-30`, 'tag'],
[`leistung=${S_SOFORT}&tag=${TAG}&ohne=a%20b`, 'ohne'],
]) {
const res = await zeiten(query)
assert.equal(res.status, 400, query)
assert.ok(res.body.fehler[feld], query)
}
})
// ---------------------------------------------------------------- Buchungen schreiben
const manuellBody = (over = {}) => ({ projectId: P, art: 'manuell', leistung: S_SOFORT, ressource: R1, tag: TAG, zeit: '11:00', name: 'Paula Telefon', telefon: '06301 555', ...over })
test('POST buchungen manuell: 201 mit Nummer, Handeintrag bestaetigt und belegt; Fehler 400, belegte Zeit 409; Liste zeigt ihn', async () => {
await speichere()
const res = await send('POST', '/api/termine/buchungen', { cookie, body: manuellBody() })
assert.equal(res.status, 201, JSON.stringify(res.body))
assert.deepEqual(Object.keys(res.body).sort(), ['id', 'nummer', 'status'])
assert.equal(res.body.status, 'bestaetigt')
assert.equal(res.body.nummer, buchungsNummer(res.body.id))
const doc = aw.docs('termineBookings').get(res.body.id)
assert.equal(doc.quelle, 'manuell')
assert.equal(doc.gastName, 'Paula Telefon')
assert.equal(doc.featureId, FEATURE)
assert.equal(aw.docs('termineClaims').size, 10, '50 Minuten in 5-Minuten-Zellen')
const liste = await send('GET', `/api/termine/buchungen?projectId=p1&von=${TAG}&bis=${TAG}`, { cookie })
assert.equal(liste.body.buchungen[0].quelle, 'manuell')
assert.equal(liste.body.buchungen[0].zeit, '11:00')
const doppelt = await send('POST', '/api/termine/buchungen', { cookie, body: manuellBody({ name: 'Zweite' }) })
assert.equal(doppelt.status, 409)
assert.equal(doppelt.body.code, 'SLOT_VERGEBEN')
assert.ok(doppelt.body.fehler.zeit)
const ohneName = await send('POST', '/api/termine/buchungen', { cookie, body: manuellBody({ name: '', zeit: '13:00' }) })
assert.equal(ohneName.status, 400)
assert.ok(ohneName.body.fehler.name)
for (const art of [undefined, 'online', 'Sperre', null, 5]) {
const kaputt = await send('POST', '/api/termine/buchungen', { cookie, body: { ...manuellBody(), art } })
assert.equal(kaputt.status, 400, String(art))
assert.ok(kaputt.body.fehler.art)
}
assert.equal(buchungen().length, 1)
})
test('POST buchungen sperre und DELETE: Zeit sperren (201), doppelt 409, nur Sperren loeschbar (200), alles andere 404', async () => {
await speichere()
seedBuchung('b_normal')
const sperre = { projectId: P, art: 'sperre', ressource: R1, tag: '2026-10-13', von: '12:00', bis: '13:00', titel: 'Mittag' }
const res = await send('POST', '/api/termine/buchungen', { cookie, body: sperre })
assert.equal(res.status, 201, JSON.stringify(res.body))
assert.equal(res.body.status, 'bestaetigt')
assert.equal(aw.docs('termineBookings').get(res.body.id).quelle, 'sperre')
const nochmal = await send('POST', '/api/termine/buchungen', { cookie, body: sperre })
assert.equal(nochmal.status, 409)
assert.equal(nochmal.body.code, 'SLOT_VERGEBEN')
assert.ok(nochmal.body.fehler.von)
const zulang = await send('POST', '/api/termine/buchungen', { cookie, body: { ...sperre, von: '08:00', bis: '18:00' } })
assert.equal(zulang.status, 400)
assert.ok(zulang.body.fehler.bis)
// ein Termin ist keine Sperre
const normal = await send('DELETE', `/api/termine/buchungen/b_normal?projectId=${P}`, { cookie })
assert.equal(normal.status, 404)
assert.equal(normal.body.error, 'Diese Sperre gibt es nicht.')
assert.ok(aw.docs('termineBookings').has('b_normal'))
const weg = await send('DELETE', `/api/termine/buchungen/${res.body.id}?projectId=${P}`, { cookie })
assert.equal(weg.status, 200)
assert.deepEqual(weg.body, { status: 'entsperrt' })
assert.ok(!aw.docs('termineBookings').has(res.body.id))
assert.equal((await send('DELETE', `/api/termine/buchungen/${res.body.id}?projectId=${P}`, { cookie })).status, 404)
assert.equal((await send('DELETE', `/api/termine/buchungen/b_normal`, { cookie })).status, 400, 'ohne projectId')
})
test('POST buchungen und Aktionen ohne gespeicherte Einstellung => 409 "zuerst einrichten"', async () => {
for (const [method, pfad, body] of [
['POST', '/api/termine/buchungen', manuellBody()],
['POST', '/api/termine/buchungen/x/aktion', { projectId: P, aktion: 'annehmen' }],
['DELETE', `/api/termine/buchungen/x?projectId=${P}`],
['GET', `/api/termine/zeiten?projectId=${P}&leistung=${S_SOFORT}&tag=${TAG}`],
]) {
const res = await send(method, pfad, { cookie, body })
assert.equal(res.status, 409, `${method} ${pfad}`)
assert.equal(res.body.code, 'keine_einstellung')
}
})
test('POST aktion annehmen: Anfrage bestaetigt, G02 an den Gast; zweiter Klick aendert nichts (schon)', async () => {
await speichere()
seedBuchung('b_anfrage', { status: 'angefragt', leistungId: S_ANFRAGE, leistungName: 'Besichtigung', preisCent: 0, preisArt: 'keiner', buchungsart: 'anfrage' })
const res = await send('POST', '/api/termine/buchungen/b_anfrage/aktion', { cookie, body: { projectId: P, aktion: 'annehmen' } })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(res.body, { status: 'bestaetigt' })
assert.equal(aw.docs('termineBookings').get('b_anfrage').status, 'bestaetigt')
await warteAufVersand()
assert.deepEqual(eintraege().map((m) => m.art), ['G02'])
assert.equal(sink.sent.length, 1)
assert.equal(sink.sent[0].to[0].address, 'max@example.com')
const zweiter = await send('POST', '/api/termine/buchungen/b_anfrage/aktion', { cookie, body: { projectId: P, aktion: 'annehmen' } })
assert.equal(zweiter.status, 200)
assert.deepEqual(zweiter.body, { status: 'bestaetigt', schon: true })
await warteAufVersand()
assert.equal(sink.sent.length, 1, 'keine zweite Mail')
})
test('POST aktion: absagen braucht einen Grund (400), Betrieb sagt ab (abgesagt von betrieb), unbekannt und Sperre 404, falscher Zustand 409', async () => {
await speichere()
seedBuchung('b_fest')
seedBuchung('b_sperre', { quelle: 'sperre', gastName: '', gastEmail: '' })
const aktion = (id, body) => send('POST', `/api/termine/buchungen/${id}/aktion`, { cookie, body: { projectId: P, ...body } })
const ohneGrund = await aktion('b_fest', { aktion: 'absagen' })
assert.equal(ohneGrund.status, 400)
assert.ok(ohneGrund.body.fehler.grund)
const unbekannteAktion = await aktion('b_fest', { aktion: 'loeschen' })
assert.equal(unbekannteAktion.status, 400)
assert.ok(unbekannteAktion.body.fehler.aktion)
const ok = await aktion('b_fest', { aktion: 'absagen', grund: 'Krank' })
assert.equal(ok.status, 200, JSON.stringify(ok.body))
assert.deepEqual(ok.body, { status: 'abgesagt' })
const doc = aw.docs('termineBookings').get('b_fest')
assert.equal(doc.abgesagtVon, 'betrieb')
assert.equal(doc.absageGrund, 'Krank')
assert.equal((await aktion('b_gibtsnicht', { aktion: 'annehmen' })).status, 404)
assert.equal((await aktion('b_sperre', { aktion: 'absagen', grund: 'x' })).status, 404, 'eine Sperre entsperrt man, man sagt sie nicht ab')
seedBuchung('b_fremd', { featureId: 'f_anderer_termine' })
assert.equal((await aktion('b_fremd', { aktion: 'absagen', grund: 'x' })).status, 404)
const falsch = await aktion('b_fest', { aktion: 'annehmen' })
assert.equal(falsch.status, 409)
assert.equal(falsch.body.code, 'nicht_moeglich')
})
// ---------------------------------------------------------------- Export
async function rohExport(projectId = P, c = cookie) {
const res = await fetch(`${app.url}/api/termine/export.csv?projectId=${projectId}`, { headers: { cookie: c } })
const bytes = Buffer.from(await res.arrayBuffer())
return { res, bytes, text: bytes.toString('utf8') }
}
test('GET export.csv: UTF-8 mit BOM, Strichpunkt, Kopfzeile, nach Beginn sortiert, ohne Sperren und fremde Funktionen', async () => {
await speichere()
seedBuchung('b_zwei', { startsAt: '2026-10-14T08:00:00.000+00:00', endsAt: '2026-10-14T08:45:00.000+00:00', blockEndsAt: '2026-10-14T08:50:00.000+00:00', status: 'angefragt', gastName: 'Zweiter', gastEmail: 'zwei@example.com', gastTelefon: '', gastNachricht: 'Zeile 1\nZeile 2' })
seedBuchung('b_eins', { gastName: 'Max "Maxe" Mustermann; Jr.' })
seedBuchung('b_sperre', { quelle: 'sperre', leistungName: 'Pause', gastName: '', gastEmail: '' })
seedBuchung('b_fremd', { featureId: 'f_anderer_termine', gastName: 'Fremd' })
const { res, bytes, text } = await rohExport()
assert.equal(res.status, 200)
assert.equal(res.headers.get('content-type'), 'text/csv; charset=utf-8')
assert.match(res.headers.get('content-disposition'), /^attachment; filename="termine\.csv"$/)
assert.equal(res.headers.get('cache-control'), 'no-store')
assert.equal(res.headers.get('x-content-type-options'), 'nosniff')
assert.deepEqual([...bytes.subarray(0, 3)], [0xef, 0xbb, 0xbf], 'BOM')
const zeilen = text.replace(/^/, '').split('\r\n')
assert.equal(zeilen[0], 'Nummer;Status;Datum;Uhrzeit;Leistung;Person/Platz;Name;E-Mail;Telefon;Nachricht')
assert.equal(zeilen[1], `${buchungsNummer('b_eins')};Bestätigt;12.10.2026;10:00;Damenschnitt;Anna;"Max ""Maxe"" Mustermann; Jr.";max@example.com;0171 1234567;`)
assert.equal(zeilen[2], `${buchungsNummer('b_zwei')};Angefragt;14.10.2026;10:00;Damenschnitt;Anna;Zweiter;zwei@example.com;;"Zeile 1\nZeile 2"`, 'Zeilenumbruch in der Zelle gequotet')
assert.equal(zeilen[3], '', 'Zeilen enden mit CRLF')
assert.equal(zeilen.length, 4)
assert.ok(!text.includes('Pause') && !text.includes('Fremd'))
})
test('GET export.csv: Zellen mit = + - @ am Anfang (auch nach Leerraum, Tab, CR) bekommen ein Hochkomma, harmlose bleiben', async () => {
await speichere()
const namen = ['=HYPERLINK("http://x")', '+49 171 1234567', '-1+1', '@SUM(A1)', ' =1+1', '\t=1+1', '\r=1+1', '\n+1', '\tText', 'Max Mustermann', 'a=b', 'Anna-Lena', '']
namen.forEach((name, i) => seedBuchung(`b_${i}`, {
gastName: name,
gastNachricht: `=cmd|' /C calc'!A0 ${i}`,
leistungName: '=Leistung',
ressourceName: '@Ben',
startsAt: `2026-10-${String(10 + i).padStart(2, '0')}T08:00:00.000+00:00`,
endsAt: `2026-10-${String(10 + i).padStart(2, '0')}T08:45:00.000+00:00`,
blockEndsAt: `2026-10-${String(10 + i).padStart(2, '0')}T08:50:00.000+00:00`,
}))
const { text } = await rohExport()
const inhalt = text.replace(/^/, '')
assert.ok(inhalt.includes(`;"'=HYPERLINK(""http://x"")";`), 'Anfuehrungszeichen verdoppelt, Hochkomma davor')
assert.ok(inhalt.includes(";'+49 171 1234567;"))
assert.ok(inhalt.includes(";'-1+1;"))
assert.ok(inhalt.includes(";'@SUM(A1);"))
assert.ok(inhalt.includes(";' =1+1;"))
assert.ok(inhalt.includes(";'\t=1+1;"))
assert.ok(inhalt.includes(";\"'\r=1+1\";"), 'CR gequotet')
assert.ok(inhalt.includes(";\"'\n+1\";"))
assert.ok(inhalt.includes(";'\tText;"))
assert.ok(inhalt.includes(";Max Mustermann;"))
assert.ok(inhalt.includes(";a=b;"))
assert.ok(inhalt.includes(";Anna-Lena;"))
// Leistung, Person und Nachricht ebenso
assert.ok(inhalt.includes(";'=Leistung;'@Ben;"))
assert.ok(inhalt.includes(";'=cmd|' /C calc'!A0 0\r\n"))
// keine Zelle beginnt mehr mit einem der vier Zeichen
for (const zeile of inhalt.split('\r\n').slice(1)) {
for (const zelle of zeile.split(';')) assert.ok(!/^"?\s*[=+\-@]/.test(zelle), `Zelle ${JSON.stringify(zelle)}`)
}
})
// ---------------------------------------------------------------- Abgeschaltet (disabled)
test('abgeschaltete Funktion: Lesen und Export gehen, jedes Schreiben ist 409', async () => {
const disabled = featureDoc({ $id: 'f_p4_termine', projectId: 'p4', state: 'disabled', enabled: false, siteKey: 'cdefghijklmnopqr' })
await speichere(einstellung(), disabled)
aw.seed('termineBookings', buchungsDoc('b_alt', { featureId: 'f_p4_termine', projectId: 'p4' }))
const lesen = await send('GET', einstellungenUrl('p4'), { cookie })
assert.equal(lesen.status, 200)
assert.equal(lesen.body.state, 'disabled')
assert.equal(lesen.body.einstellung.betrieb.name, 'Salon Müller')
assert.deepEqual(lesen.body.feeds, {}, 'die Kalender-Links sind bei einer abgeschalteten Funktion tot')
assert.equal((await send('GET', `/api/termine/buchungen?projectId=p4&von=${TAG}&bis=${TAG}`, { cookie })).body.buchungen.length, 1)
const export_ = await rohExport('p4')
assert.equal(export_.res.status, 200)
assert.ok(export_.text.includes('Max Mustermann'))
assert.equal((await send('GET', `/api/termine/zeiten?projectId=p4&leistung=${S_SOFORT}&tag=${TAG}`, { cookie })).status, 200)
const schreiben = [
['PATCH', '/api/termine/einstellungen', { projectId: 'p4', version: 1, einstellung: lesen.body.einstellung }],
['POST', '/api/termine/einstellungen/vorlage', { projectId: 'p4', vorlage: 'salon' }],
['POST', '/api/termine/buchungen', { ...manuellBody(), projectId: 'p4' }],
['POST', '/api/termine/buchungen/b_alt/aktion', { projectId: 'p4', aktion: 'absagen', grund: 'x' }],
['POST', `/api/termine/ressourcen/${R1}/feed`, { projectId: 'p4' }],
['DELETE', '/api/termine/buchungen/b_alt?projectId=p4'],
]
for (const [method, pfad, body] of schreiben) {
const res = await send(method, pfad, { cookie, body })
assert.equal(res.status, 409, `${method} ${pfad}`)
assert.equal(res.body.code, 'abgeschaltet')
}
assert.equal(aw.docs('termineBookings').get('b_alt').status, 'bestaetigt')
assert.equal(aw.docs('termineConfig').get('f_p4_termine').version, 1)
})
// ---------------------------------------------------------------- Kundenansicht
test('Kundenansicht darf PATCH, POST und DELETE (mit Header) und es wird protokolliert; ohne Gastdaten im Protokoll', async () => {
await speichere()
seedBuchung('b_anfrage', { status: 'angefragt', leistungId: S_ANFRAGE, gastName: 'Geheim Gast', gastEmail: 'geheim@example.com' })
const stand = await lade()
const k = kv()
const patch = await send('PATCH', '/api/termine/einstellungen', {
...k,
body: { projectId: P, version: stand.version, einstellung: { ...stand.einstellung, farbe: '#112233' } },
})
assert.equal(patch.status, 200, JSON.stringify(patch.body))
assert.equal(aw.docs('termineConfig').get(FEATURE).updatedBy, 'team:u_staff2')
const feed = await send('POST', `/api/termine/ressourcen/${R1}/feed`, { ...k, body: { projectId: P } })
assert.equal(feed.status, 200)
const manuell = await send('POST', '/api/termine/buchungen', { ...k, body: manuellBody({ name: 'Privat Person' }) })
assert.equal(manuell.status, 201, JSON.stringify(manuell.body))
const sperre = await send('POST', '/api/termine/buchungen', { ...k, body: { projectId: P, art: 'sperre', ressource: R2, tag: '2026-10-13', von: '12:00', bis: '13:00' } })
assert.equal(sperre.status, 201)
const aktion = await send('POST', '/api/termine/buchungen/b_anfrage/aktion', { ...k, body: { projectId: P, aktion: 'ablehnen' } })
assert.equal(aktion.status, 200, JSON.stringify(aktion.body))
const zweiter = await send('POST', '/api/termine/buchungen/b_anfrage/aktion', { ...k, body: { projectId: P, aktion: 'ablehnen' } })
assert.equal(zweiter.body.schon, true)
const weg = await send('DELETE', `/api/termine/buchungen/${sperre.body.id}?projectId=${P}`, k)
assert.equal(weg.status, 200)
const vorlage = await send('POST', '/api/termine/einstellungen/vorlage', { ...k, body: { projectId: P, vorlage: 'salon' } })
assert.equal(vorlage.status, 409, 'es gibt schon Einstellungen: nichts protokolliert')
const fehlerhaft = await send('PATCH', '/api/termine/einstellungen', { ...k, body: { projectId: P, version: 99, einstellung: stand.einstellung } })
assert.equal(fehlerhaft.status, 409)
const eintraege_ = logEintraege().sort((a, b) => a.createdAt.localeCompare(b.createdAt))
assert.deepEqual(eintraege_.map((e) => [e.action, e.detail]), [
['termin_einstellungen', 'Einstellungen gespeichert'],
['termin_einstellungen', 'Kalender-Link erneuert'],
['termin_manuell', 'manuell'],
['termin_manuell', 'sperre'],
['termin_aktion', `ablehnen ${buchungsNummer('b_anfrage')}`],
['termin_aktion', `entsperren ${buchungsNummer(sperre.body.id)}`],
])
assert.ok(eintraege_.every((e) => e.customerId === KUNDE && e.staffName === 'Andrej' && e.staffUserId === 'u_staff2'))
const alles = JSON.stringify(eintraege_)
for (const geheim of ['Geheim Gast', 'geheim@example.com', 'Privat Person', '06301 555']) assert.ok(!alles.includes(geheim), geheim)
})
test('Kundenansicht ohne Header oder mit fremder Kennung: Schreiben 409 view_replaced; Lesen geht; Kunde selbst schreibt ohne Protokoll', async () => {
await speichere()
const stand = await lade()
const body = { projectId: P, version: stand.version, einstellung: stand.einstellung }
const ohneHeader = await send('PATCH', '/api/termine/einstellungen', { cookie: kundenansichtCookie(), body })
assert.equal(ohneHeader.status, 409)
assert.equal(ohneHeader.body.code, 'view_replaced')
const fremd = await send('PATCH', '/api/termine/einstellungen', { cookie: kundenansichtCookie(), headers: { 'x-kundenansicht': 'b'.repeat(32) }, body })
assert.equal(fremd.body.code, 'view_replaced')
assert.equal((await send('GET', einstellungenUrl(), { cookie: kundenansichtCookie() })).status, 200)
assert.equal((await send('GET', `/api/termine/buchungen?projectId=${P}&von=${TAG}&bis=${TAG}`, { cookie: kundenansichtCookie() })).status, 200)
assert.equal(aw.docs('termineConfig').get(FEATURE).version, 1)
const kunde = await send('PATCH', '/api/termine/einstellungen', { cookie, body })
assert.equal(kunde.status, 200)
assert.equal(logEintraege().length, 0, 'nur die Kundenansicht wird protokolliert')
})
// ---------------------------------------------------------------- Limit und Fehler
test('Schreib-Limit: 120 Aenderungen je Kunde und Stunde, die 121. ist 429; Lesen zaehlt nicht; anderer Kunde und naechste Stunde frei', async () => {
await speichere()
let jetzt = JETZT.getTime()
setzeUhrFuerTests(() => new Date(jetzt))
for (let i = 0; i < 40; i += 1) assert.equal((await send('GET', einstellungenUrl(), { cookie })).status, 200)
for (let i = 1; i <= 120; i += 1) {
const res = await send('DELETE', `/api/termine/buchungen/x?projectId=${P}`, { cookie })
assert.equal(res.status, 404, `Anfrage ${i}`)
}
const zuViele = await send('DELETE', `/api/termine/buchungen/x?projectId=${P}`, { cookie })
assert.equal(zuViele.status, 429)
assert.match(zuViele.body.error, /Zu viele Änderungen/)
assert.equal((await send('POST', '/api/termine/buchungen', { cookie, body: manuellBody() })).status, 429, 'alle Schreib-Routen teilen das Limit')
assert.equal((await send('GET', einstellungenUrl(), { cookie })).status, 200, 'Lesen bleibt frei')
// ein anderer Kunde hat sein eigenes Limit
assert.equal((await send('DELETE', `/api/termine/buchungen/x?projectId=p6`, { cookie: customerCookie('c2') })).status, 409, 'nicht 429 (keine Einstellung)')
// eine Stunde spaeter
jetzt += 61 * 60 * 1000
assert.equal((await send('DELETE', `/api/termine/buchungen/x?projectId=${P}`, { cookie })).status, 404)
})
test('Fehler von Appwrite verlassen den Server nie als Text: 500 "Interner Serverfehler"', async () => {
await speichere()
const echt = globalThis.fetch
globalThis.fetch = async (url, optionen) => {
if (/\/collections\/termineConfig\/documents\//.test(String(url)) && (optionen?.method ?? 'GET') === 'GET') {
return new Response(JSON.stringify({ message: 'geheimer Appwrite-Text', code: 500, type: 'general_server_error' }), { status: 500, headers: { 'content-type': 'application/json' } })
}
return echt(url, optionen)
}
try {
const res = await send('GET', einstellungenUrl(), { cookie })
assert.equal(res.status, 500)
assert.deepEqual(res.body, { error: 'Interner Serverfehler' })
} finally {
globalThis.fetch = echt
}
})