Files
Webklar-Kundenbereich/test/termineRoutes.test.js
T

1345 lines
76 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 { datumKurz, uhrzeit } = await import('../server/services/termine/zeit.js')
const { exportSeite } = await import('../server/services/termine/buchungen.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: Einstellungen lesen geht (Entwurf), alles andere bleibt 409 "nicht aktiv"', async () => {
// Lesen der Einstellungen: in jedem Zustand und auch ohne Dokument (Zustandsmatrix: test/termineFragebogenRoutes.test.js)
for (const projectId of ['p2', 'p5']) {
const lesen = await send('GET', einstellungenUrl(projectId), { cookie })
assert.equal(lesen.status, 200, projectId)
assert.equal(lesen.body.state, 'off', projectId)
assert.equal(lesen.body.einstellung, null, projectId)
assert.equal(lesen.body.version, 0, projectId)
// alles andere, auch das Schreiben der Einstellungen (Schreibregeln des Entwurfs folgen in C4b)
for (const [method, pfad, body] of [
['GET', `/api/termine/buchungen?projectId=${projectId}&von=2026-10-01&bis=2026-10-31`],
['GET', `/api/termine/zeiten?projectId=${projectId}`],
['GET', `/api/termine/export.csv?projectId=${projectId}`],
['POST', '/api/termine/einstellungen/vorlage', { projectId, vorlage: 'salon' }],
['PATCH', '/api/termine/einstellungen', { projectId, version: 0, einstellung: {} }],
['POST', `/api/termine/ressourcen/${R1}/feed`, { projectId }],
['POST', '/api/termine/buchungen', { projectId, art: 'sperre' }],
]) {
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.')
}
}
assert.equal(aw.docs('portalFeatures').size, 4, 'p5 hat kein Dokument bekommen (nur die vier aus beforeEach)')
for (const state of ['ordered', 'setup']) {
aw.seed('portalFeatures', featureDoc({ $id: 'f_p5_termine', projectId: 'p5', state, enabled: false, siteKey: 'efghijklmnopqrst' }))
const lesen = await send('GET', einstellungenUrl('p5'), { cookie })
assert.equal(lesen.status, 200, state)
assert.equal(lesen.body.state, state)
assert.equal(
(await send('GET', `/api/termine/buchungen?projectId=p5&von=2026-10-01&bis=2026-10-31`, { 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)
})
test('R55: Betrieb nur mit Anfrage-Leistungen (Privatpersonen) braucht die Anschrift: PATCH 400, "fehlt", oeffentlich inaktiv; danach Belehrung im Widget und G02 mit Belehrung nach "annehmen"', async () => {
const c2 = customerCookie('c2')
const oeffentlich = (siteKey) => fetch(`${app.url}/api/public/termine/v1/${siteKey}/config`).then((r) => r.json())
const SCHLUESSEL = 'defghijklmnopqrs'
const res = await send('POST', '/api/termine/einstellungen/vorlage', { cookie: c2, body: { projectId: 'p6', vorlage: 'handwerk' } })
assert.equal(res.status, 201, JSON.stringify(res.body))
const entwurf = res.body.einstellung
assert.ok(entwurf.leistungen.every((l) => l.buchungsart === 'anfrage'), 'Vorlage: nur Anfragen')
assert.equal(entwurf.recht.gaesteSind, 'gemischt', 'Verbraucher sind dabei')
assert.deepEqual(res.body.fehlt, ['betrieb.telefon', 'betrieb.adresse'], 'auch ohne sofort-Leistung fehlt die Anschrift')
assert.deepEqual(await oeffentlich(SCHLUESSEL), { aktiv: false })
// nur das Telefon reicht nicht: PATCH nennt die Anschrift
const nurTelefon = { ...entwurf, betrieb: { ...entwurf.betrieb, telefon: '0151 2345678' } }
const abgelehnt = await send('PATCH', '/api/termine/einstellungen', { cookie: c2, body: { projectId: 'p6', version: 1, einstellung: nurTelefon } })
assert.equal(abgelehnt.status, 400)
assert.deepEqual(Object.keys(abgelehnt.body.fehler), ['betrieb.adresse'])
assert.deepEqual(await oeffentlich(SCHLUESSEL), { aktiv: false })
// mit Anschrift: speichert, Widget bekommt Belehrung (widerruf true)
const komplett = { ...nurTelefon, betrieb: { ...nurTelefon.betrieb, 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(ok.body.fehlt ?? [], [])
const config = await oeffentlich(SCHLUESSEL)
assert.equal(config.aktiv, true)
assert.equal(config.widerruf, true)
assert.match(config.texte.widerrufsbelehrung, /Widerrufsbelehrung/)
assert.match(config.texte.widerrufsbelehrung, /Weg 1, 12345 Ort/)
// annehmen: die Anfrage wird bestaetigt und G02 mit Belehrung geht raus (nicht still verschluckt)
const leistung = ok.body.einstellung.leistungen[0]
seedBuchung('b_handwerk', {
featureId: 'f_p6_termine',
customerId: 'c2',
projectId: 'p6',
status: 'angefragt',
leistungId: leistung.id,
leistungName: leistung.name,
ressourceId: leistung.ressourcen[0],
preisCent: 0,
preisArt: 'keiner',
buchungsart: 'anfrage',
})
const angenommen = await send('POST', '/api/termine/buchungen/b_handwerk/aktion', { cookie: c2, body: { projectId: 'p6', aktion: 'annehmen' } })
assert.equal(angenommen.status, 200, JSON.stringify(angenommen.body))
assert.equal(aw.docs('termineBookings').get('b_handwerk').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')
assert.match(sink.sent[0].text, /Widerrufsbelehrung/)
assert.match(sink.sent[0].text, /Weg 1, 12345 Ort/)
})
// ---------------------------------------------------------------- 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('unlesbares Einstellungs-JSON: GET zeigt keine Einstellung, aber die echte Version; Vorlage bleibt 409, PATCH mit dieser Version ueberschreibt', async () => {
aw.seed('termineConfig', { $id: FEATURE, customerId: KUNDE, projectId: P, einstellung: '{kaputt', version: 3 })
const stand = await lade()
assert.equal(stand.einstellung, null)
assert.equal(stand.version, 3)
assert.deepEqual(stand.feeds, {})
assert.deepEqual(stand.fehlt, [])
const vorlage = await send('POST', '/api/termine/einstellungen/vorlage', { cookie, body: { projectId: P, vorlage: 'salon' } })
assert.equal(vorlage.status, 409)
assert.equal(aw.docs('termineConfig').get(FEATURE).einstellung, '{kaputt', 'nichts ueberschrieben')
const patch = await send('PATCH', '/api/termine/einstellungen', { cookie, body: { projectId: P, version: stand.version, einstellung: eingabeEinstellung() } })
assert.equal(patch.status, 200, JSON.stringify(patch.body))
assert.equal(patch.body.version, 4)
const danach = await lade()
assert.equal(danach.einstellung.betrieb.name, 'Salon Müller')
assert.equal(danach.version, 4)
})
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'],
]) {
const res = await zeiten(query)
assert.equal(res.status, 400, query)
assert.ok(res.body.fehler[feld], query)
}
})
test('GET zeiten mit ohne (R42): rechnet mit der gebuchten Dauer und der Person der Buchung, Anfrage-Werte werden ignoriert', async () => {
const zeiten = async (query) => (await send('GET', `/api/termine/zeiten?projectId=p1&${query}`, { cookie })).body.zeiten
const mitRegeln = (leistungen, regeln = {}) => einstellung({ leistungen, regeln: { ...eingabeEinstellung().regeln, ...regeln } })
const leistung = (dauer, pufferNach) => [{ ...eingabeEinstellung().leistungen[0], dauer, pufferNach }]
// (a) Leistung nach der Buchung verlaengert (4 Stunden): die Buchung hat 45 + 5 Minuten und passt noch um 17:00
await speichere(mitRegeln(leistung(240, 5)))
seedBuchung('b_gebucht') // Montag, 10:00 bis 10:50 (R1)
const verlaengert = await zeiten(`leistung=${S_SOFORT}&ressource=${R1}&tag=${TAG}`)
assert.ok(!verlaengert.includes('17:00'), 'ohne ohne: die neue Dauer gilt')
const gebucht = await zeiten(`ohne=b_gebucht&tag=${TAG}&leistung=${S_ANFRAGE}&ressource=${R2}`)
assert.ok(gebucht.includes('17:00'), 'mit ohne: 17:00 + 50 Minuten endet vor 18:00')
assert.ok(gebucht.includes('10:00'), 'die eigene Zeit ist frei')
assert.equal(gebucht.at(-1), '17:00')
assert.ok(!gebucht.includes('17:15'), 'die gebuchte Dauer endet 18:05')
// (b) Leistung nach der Buchung verkuerzt (15 Minuten): ein Termin um 11:00 steht im Weg, 10:15 + 50 Minuten reicht hinein
await speichere(mitRegeln(leistung(15, 0)))
seedBuchung('b_dahinter', { ressourceId: R1, startsAt: `${TAG}T09:00:00.000+00:00`, endsAt: `${TAG}T09:45:00.000+00:00`, blockEndsAt: `${TAG}T09:50:00.000+00:00` })
const verkuerzt = await zeiten(`ohne=b_gebucht&tag=${TAG}&leistung=${S_SOFORT}&ressource=${R1}`)
assert.ok(verkuerzt.includes('10:00'), '10:00 bis 10:50 passt vor 11:00')
assert.ok(!verkuerzt.includes('10:15'), 'die gebuchte Dauer (50 Minuten) liefe in den Termin um 11:00')
// Person: waehlbar => die der Buchung, sonst jede freie ('egal'); die Angabe aus der Abfrage zaehlt nie
assert.ok(!(await zeiten(`ohne=b_gebucht&tag=${TAG}&ressource=${R2}`)).includes('10:15'), 'personWaehlbar: Annas Buchung bleibt bei Anna, Bens freie Zeit zaehlt nicht')
await speichere(mitRegeln(leistung(15, 0), { personWaehlbar: false }))
assert.ok((await zeiten(`ohne=b_gebucht&tag=${TAG}&ressource=${R1}`)).includes('10:15'), 'ohne Personenwahl genuegt eine freie Person: Ben um 10:15')
})
test('GET zeiten mit ohne (R42): unbekannte Buchung, andere Funktion, Sperre und unbrauchbare ID => 404', async () => {
await speichere()
seedBuchung('b_sperre', { quelle: 'sperre', gastName: '', gastEmail: '' })
seedBuchung('b_fremd', { featureId: 'f_anderer_termine' })
for (const ohne of ['b_gibtsnicht', 'b_sperre', 'b_fremd', 'a%20b', '..%2Fx', 'x'.repeat(200)]) {
const res = await send('GET', `/api/termine/zeiten?projectId=p1&tag=${TAG}&ohne=${ohne}`, { cookie })
assert.equal(res.status, 404, ohne)
assert.ok(res.body.error, ohne)
}
// mit ohne braucht es weder Leistung noch Person; der Tag bleibt Pflicht
seedBuchung('b_ok')
assert.equal((await send('GET', `/api/termine/zeiten?projectId=p1&tag=${TAG}&ohne=b_ok`, { cookie })).status, 200)
const ohneTag = await send('GET', '/api/termine/zeiten?projectId=p1&ohne=b_ok', { cookie })
assert.equal(ohneTag.status, 400)
assert.ok(ohneTag.body.fehler.tag)
})
// ---------------------------------------------------------------- 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')
})
// ---------------------------------------------------------------- Mandantentrennung
test('Mandantentrennung: Kunde 1 erreicht mit seiner projectId keine Buchung, Sperre oder Liste von Kunde 2 (echter zweiter Kunde mit aktiver Funktion)', async () => {
const feature2 = featureDoc({ $id: 'f_p3_termine', customerId: 'c2', projectId: 'p3', siteKey: 'fghijklmnopqrstu' })
aw.seed('portalFeatures', feature2)
await speichere(einstellung(), feature2)
const fremd = { featureId: 'f_p3_termine', customerId: 'c2', projectId: 'p3' }
seedBuchung('b_c2', { ...fremd, status: 'angefragt', gastName: 'Fremder Gast', gastEmail: 'fremd@example.com', gastNachricht: 'Streng geheim' })
seedBuchung('s_c2', { ...fremd, quelle: 'sperre', leistungId: '', leistungName: 'Fremde Sperre', gastName: '', gastEmail: '', startsAt: `${TAG}T12:00:00.000+00:00`, endsAt: `${TAG}T13:00:00.000+00:00`, blockEndsAt: `${TAG}T13:00:00.000+00:00` })
await speichere()
seedBuchung('b_eigen', { gastName: 'Eigener Gast', gastEmail: 'eigen@example.com' })
const vorher = JSON.stringify([...aw.docs('termineBookings').entries()])
// sanity: Kunde 2 sieht seine Daten
const c2 = customerCookie('c2')
const seine = await send('GET', `/api/termine/buchungen?projectId=p3&von=${TAG}&bis=${TAG}`, { cookie: c2 })
assert.deepEqual(seine.body.buchungen.map((b) => b.id), ['b_c2', 's_c2'])
const aktion = (id, body) => send('POST', `/api/termine/buchungen/${id}/aktion`, { cookie, body: { projectId: P, ...body } })
for (const body of [{ aktion: 'annehmen' }, { aktion: 'ablehnen' }, { aktion: 'absagen', grund: 'x' }, { aktion: 'verschieben', tag: '2026-10-19', zeit: '10:00' }, { aktion: 'anonymisieren' }, { aktion: 'nichterschienen' }]) {
const res = await aktion('b_c2', body)
assert.equal(res.status, 404, body.aktion)
assert.equal(res.body.code, 'unbekannt')
}
assert.equal((await aktion('s_c2', { aktion: 'absagen', grund: 'x' })).status, 404)
const weg = await send('DELETE', `/api/termine/buchungen/s_c2?projectId=${P}`, { cookie })
assert.equal(weg.status, 404)
assert.ok(aw.docs('termineBookings').has('s_c2'), 'die fremde Sperre steht noch')
assert.equal((await send('DELETE', `/api/termine/buchungen/b_c2?projectId=${P}`, { cookie })).status, 404)
for (const id of ['b_c2', 's_c2']) {
const zeiten = await send('GET', `/api/termine/zeiten?projectId=${P}&tag=${TAG}&ohne=${id}`, { cookie })
assert.equal(zeiten.status, 404, id)
}
const liste = await send('GET', `/api/termine/buchungen?projectId=${P}&von=${TAG}&bis=${TAG}`, { cookie })
assert.deepEqual(liste.body.buchungen.map((b) => b.id), ['b_eigen'])
const csv = (await rohExport()).text
assert.ok(csv.includes('Eigener Gast'))
for (const geheim of ['Fremder Gast', 'fremd@example.com', 'Streng geheim', buchungsNummer('b_c2'), 'Fremde Sperre']) assert.ok(!csv.includes(geheim), geheim)
assert.ok(!JSON.stringify(liste.body).includes('Fremder Gast'))
await warteAufVersand()
assert.equal(sink.sent.length, 0, 'keine Mail an den fremden Gast')
assert.equal(JSON.stringify([...aw.docs('termineBookings').entries()]), vorher, 'nichts veraendert')
assert.equal(logEintraege().length, 0)
})
// ---------------------------------------------------------------- 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(/^\uFEFF/, '').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(/^\uFEFF/, '')
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)}`)
}
})
// Export seitenweise (Aufgabe A5): Grosse Mengen kommen Seite fuer Seite (limit 500, nach Beginn, Cursor = letzte Rohzeile)
// und werden gestreamt; Fehler vor dem ersten Byte sind ein normales JSON-500, danach bricht die Verbindung ab.
const START_MS = Date.UTC(2026, 9, 12, 6, 0)
const startZeit = (minuten) => new Date(START_MS + minuten * 60_000).toISOString().replace('Z', '+00:00')
const nr4 = (i) => String(i).padStart(5, '0')
/** Eine Buchung zum Index i (alle 30 Minuten eine, deshalb nie zwei mit gleichem Beginn); gleiche Form wie im Export erwartet. */
function massenBuchung(i, over = {}) {
return buchungsDoc(`b_m${nr4(i)}`, {
startsAt: startZeit(i * 30),
endsAt: startZeit(i * 30 + 25),
blockEndsAt: startZeit(i * 30 + 25),
gastName: `Gast ${nr4(i)}`,
gastEmail: `gast${nr4(i)}@example.com`,
gastTelefon: '',
gastNachricht: '',
...over,
})
}
const erwarteteZeile = (i) => {
const start = new Date(START_MS + i * 30 * 60_000)
return [buchungsNummer(`b_m${nr4(i)}`), 'Bestätigt', datumKurz(start), uhrzeit(start), 'Damenschnitt', 'Anna', `Gast ${nr4(i)}`, `gast${nr4(i)}@example.com`, '', ''].join(';')
}
test('GET export.csv: 1200 Buchungen + 40 Sperren + fremde Funktion kommen in drei Seiten zu je hoechstens 500, sortiert und im alten Format', async () => {
await speichere()
const reihenfolge = Array.from({ length: 1200 }, (_, i) => 1199 - i) // absteigend angelegt: die Sortierung kommt aus der Abfrage
for (const i of reihenfolge) aw.seed('termineBookings', massenBuchung(i))
for (let k = 0; k < 40; k += 1) {
aw.seed('termineBookings', massenBuchung(k, { $id: `s_m${nr4(k)}`, quelle: 'sperre', leistungName: 'Pause', gastName: '', gastEmail: '', startsAt: startZeit(k * 30 * 30 + 5), endsAt: startZeit(k * 30 * 30 + 15), blockEndsAt: startZeit(k * 30 * 30 + 15) }))
}
for (let k = 0; k < 30; k += 1) aw.seed('termineBookings', massenBuchung(k, { $id: `b_f${nr4(k)}`, featureId: 'f_anderer_termine', gastName: 'Fremder Gast' }))
aw.resetListCalls()
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 erwartet = `${['Nummer;Status;Datum;Uhrzeit;Leistung;Person/Platz;Name;E-Mail;Telefon;Nachricht', ...Array.from({ length: 1200 }, (_, i) => erwarteteZeile(i))].join('\r\n')}\r\n`
assert.equal(text.length, erwartet.length)
assert.ok(text === erwartet, '1200 Datenzeilen nach Beginn sortiert, Zeile fuer Zeile wie bisher')
assert.ok(!text.includes('Pause') && !text.includes('Fremder Gast'))
const abfragen = aw.listCalls('termineBookings')
assert.equal(abfragen.length, 3, '1240 Rohzeilen = 500 + 500 + 240 in genau drei Abfragen')
const sortiert = [...buchungen()].filter((b) => b.featureId === FEATURE).sort((a, b) => new Date(a.startsAt) - new Date(b.startsAt))
abfragen.forEach((abfrage, nr) => {
const q = abfrage.queries
assert.deepEqual(q.find((x) => x.method === 'limit').values, [500], `Seite ${nr + 1}: limit 500`)
assert.deepEqual(q.find((x) => x.method === 'equal'), { method: 'equal', attribute: 'featureId', values: [FEATURE] })
assert.deepEqual(q.filter((x) => x.method === 'orderAsc'), [{ method: 'orderAsc', attribute: 'startsAt' }], 'nur nach Beginn sortiert, nie nach $id')
const cursor = q.find((x) => x.method === 'cursorAfter')
if (nr === 0) assert.equal(cursor, undefined)
else assert.deepEqual(cursor.values, [sortiert[nr * 500 - 1].$id], `Seite ${nr + 1}: Cursor = letzte Rohzeile der Seite davor (auch eine Sperre)`)
})
})
test('GET export.csv: 20100 Buchungen (ueber dem alten Deckel von 20000) werden vollstaendig geliefert, ohne list_limit_exceeded', async () => {
await speichere()
for (let i = 0; i < 20100; i += 1) aw.seed('termineBookings', massenBuchung(i))
aw.resetListCalls()
const { res, text } = await rohExport()
assert.equal(res.status, 200)
const zeilen = text.replace(/^/, '').split('\r\n')
assert.equal(zeilen.length, 20100 + 2, 'Kopf + 20100 Zeilen + leeres Ende')
assert.equal(zeilen[1], erwarteteZeile(0))
assert.equal(zeilen[20100], erwarteteZeile(20099))
assert.equal(zeilen[20101], '')
assert.equal(aw.listCalls('termineBookings').length, 41, '40 volle Seiten + 1 Restseite')
})
test('GET export.csv: Fehler bei der ersten Seite = normales JSON-500 ohne CSV-Anfang, Fehler bei der zweiten bricht den Download ab', async () => {
await speichere()
for (let i = 0; i < 600; i += 1) aw.seed('termineBookings', massenBuchung(i))
aw.failNext({ collection: 'termineBookings', method: 'GET', status: 500 })
const erste = await rohExport()
assert.equal(erste.res.status, 500)
assert.match(erste.res.headers.get('content-type'), /application\/json/)
assert.deepEqual(JSON.parse(erste.text), { error: 'Interner Serverfehler' })
assert.ok(!erste.text.includes('Nummer;') && !erste.text.includes(''), 'kein CSV-Anfang')
assert.equal(erste.res.headers.get('content-disposition'), null, 'kein Download-Kopf')
console.error.mock.resetCalls()
aw.failNext({ collection: 'termineBookings', method: 'GET', status: 500, after: 1 })
const res = await fetch(`${app.url}/api/termine/export.csv?projectId=${P}`, { headers: { cookie } })
assert.equal(res.status, 200, 'die Kopfzeilen sind schon raus')
assert.match(res.headers.get('content-type'), /text\/csv/)
await assert.rejects(res.arrayBuffer(), 'die Antwort bricht ab statt als vollstaendige, aber gekuerzte Datei zu enden')
const log = JSON.stringify(console.error.mock.calls.map((c) => c.arguments))
assert.ok(console.error.mock.calls.length >= 1, 'der Abbruch steht im Log')
assert.ok(!/Gast \d|@example\.com|Max|Hauptstr/.test(log), `im Log kein Gastname, keine Adresse: ${log}`)
// danach geht der Export wieder
assert.equal((await rohExport()).text.split('\r\n').length, 600 + 2)
})
test('exportSeite: ohne Funktion, mit ungueltigem Cursor oder ungueltiger Grenze TypeError (nie eine Abfrage ohne Funktionsfilter)', async () => {
await assert.rejects(exportSeite(''), TypeError)
await assert.rejects(exportSeite(undefined), TypeError)
await assert.rejects(exportSeite(FEATURE, { limit: 0 }), TypeError)
await assert.rejects(exportSeite(FEATURE, { limit: 1.5 }), TypeError)
await assert.rejects(exportSeite(FEATURE, { nach: '' }), TypeError)
await assert.rejects(exportSeite(FEATURE, { nach: 7 }), TypeError)
})
// ---------------------------------------------------------------- Abgeschaltet (disabled)
const MELDUNG_ALT = 'Der Terminplaner ist für diese Website abgeschaltet. Du kannst Termine nur noch ansehen und exportieren.'
/** Jede Schreib-Route des Portals, wie sie bei einer abgeschalteten Funktion von Projekt `projectId` aufgerufen wird. */
const schreibVersuche = (projectId, einstellung) => [
['PATCH', '/api/termine/einstellungen', { projectId, version: 1, einstellung }],
['POST', '/api/termine/einstellungen/vorlage', { projectId, vorlage: 'salon' }],
['POST', '/api/termine/buchungen', { ...manuellBody(), projectId }],
['POST', '/api/termine/buchungen/b_alt/aktion', { projectId, aktion: 'absagen', grund: 'x' }],
['POST', `/api/termine/ressourcen/${R1}/feed`, { projectId }],
['DELETE', `/api/termine/buchungen/b_alt?projectId=${projectId}`],
]
/** Funktion von p4 abgeschaltet mit Einstellung und einer Buchung; `over` setzt Grund und Zeitpunkt. */
async function seedAbgeschaltet(over = {}) {
const disabled = featureDoc({ $id: 'f_p4_termine', projectId: 'p4', state: 'disabled', enabled: false, siteKey: 'cdefghijklmnopqr', ...over })
aw.seed('portalFeatures', disabled)
await speichere(einstellung(), disabled)
aw.seed('termineBookings', buchungsDoc('b_alt', { featureId: 'f_p4_termine', projectId: 'p4' }))
return disabled
}
test('abgeschaltete Funktion: Lesen und Export gehen, jedes Schreiben ist 409', async () => {
await seedAbgeschaltet()
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)
for (const [method, pfad, body] of schreibVersuche('p4', lesen.body.einstellung)) {
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)
})
test('abgeschaltet ohne Grund und Zeitpunkt (Altbestand): abgeschaltet leer, die Meldung bleibt der bisherige Satz', async () => {
await seedAbgeschaltet()
const lesen = await send('GET', einstellungenUrl('p4'), { cookie })
assert.deepEqual(lesen.body.abgeschaltet, { grund: null, seit: null, loeschungAm: null })
for (const [method, pfad, body] of schreibVersuche('p4', lesen.body.einstellung)) {
const res = await send(method, pfad, { cookie, body })
assert.equal(res.status, 409, `${method} ${pfad}`)
assert.equal(res.body.code, 'abgeschaltet')
assert.equal(res.body.error, MELDUNG_ALT, `${method} ${pfad}`)
}
})
test('abgeschaltet wegen Abo-Ende: Grund, Löschtag und Meldung mit Wartungs-Abo und Datum; Lesen und Export gehen weiter', async () => {
await seedAbgeschaltet({ disabledReason: 'abo_ende', disabledAt: '2026-10-04T23:30:00.000Z' })
const lesen = await send('GET', einstellungenUrl('p4'), { cookie })
assert.equal(lesen.status, 200)
// 23:30 UTC am 4.10. + 30 Tage = 3.11. 23:30 UTC = 4.11. 00:30 Berliner Zeit (Winterzeit)
assert.deepEqual(lesen.body.abgeschaltet, { grund: 'abo_ende', seit: '2026-10-04T23:30:00.000Z', loeschungAm: '2026-11-04' })
assert.equal((await send('GET', `/api/termine/buchungen?projectId=p4&von=${TAG}&bis=${TAG}`, { cookie })).status, 200)
assert.equal((await rohExport('p4')).res.status, 200)
for (const [method, pfad, body] of schreibVersuche('p4', lesen.body.einstellung)) {
const res = await send(method, pfad, { cookie, body })
assert.equal(res.status, 409, `${method} ${pfad}`)
assert.equal(res.body.code, 'abgeschaltet')
assert.ok(res.body.error.includes('Wartungs-Abo') && res.body.error.includes('04.11.2026'), `${method} ${pfad}: ${res.body.error}`)
assert.equal(res.body.error, 'Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo beendet ist. Du kannst Termine nur noch ansehen und exportieren. Ab dem 04.11.2026 löschen wir die Daten.')
}
assert.equal(aw.docs('termineBookings').get('b_alt').status, 'bestaetigt')
})
test('abgeschaltet, weil das Abo vor der Einrichtung endete: Meldung nennt "eingerichtet" und das Löschdatum', async () => {
await seedAbgeschaltet({ disabledReason: 'abo_ende_vor_einrichtung', disabledAt: '2026-10-04T10:00:00.000Z' })
const lesen = await send('GET', einstellungenUrl('p4'), { cookie })
assert.deepEqual(lesen.body.abgeschaltet, { grund: 'abo_ende_vor_einrichtung', seit: '2026-10-04T10:00:00.000Z', loeschungAm: '2026-11-03' })
for (const [method, pfad, body] of schreibVersuche('p4', lesen.body.einstellung)) {
const res = await send(method, pfad, { cookie, body })
assert.equal(res.status, 409, `${method} ${pfad}`)
assert.equal(res.body.code, 'abgeschaltet')
assert.ok(res.body.error.includes('eingerichtet') && res.body.error.includes('Wartungs-Abo endete'), `${method} ${pfad}: ${res.body.error}`)
assert.ok(res.body.error.endsWith('Ab dem 03.11.2026 löschen wir die Daten.'), res.body.error)
assert.ok(!/exportieren/.test(res.body.error), 'es gibt nichts zu exportieren')
}
})
test('abgeschaltet durch das Team oder wegen gelöschtem Konto: bisheriger Satz, mit Zeitpunkt zusätzlich das Löschdatum', async () => {
for (const grund of ['team', 'kunde_geloescht']) {
await seedAbgeschaltet({ disabledReason: grund, disabledAt: '2026-10-04T23:30:00.000Z' })
const lesen = await send('GET', einstellungenUrl('p4'), { cookie })
assert.deepEqual(lesen.body.abgeschaltet, { grund, seit: '2026-10-04T23:30:00.000Z', loeschungAm: '2026-11-04' })
const res = await send('PATCH', '/api/termine/einstellungen', { cookie, body: { projectId: 'p4', version: 1, einstellung: lesen.body.einstellung } })
assert.equal(res.status, 409, grund)
assert.equal(res.body.code, 'abgeschaltet')
assert.equal(res.body.error, `${MELDUNG_ALT} Ab dem 04.11.2026 löschen wir die Daten.`, grund)
}
})
test('abgeschaltet mit unlesbarem Zeitpunkt: Grund bleibt, Löschtag fehlt und die Meldung nennt kein Datum', async () => {
await seedAbgeschaltet({ disabledReason: 'abo_ende', disabledAt: 'kein Datum' })
const lesen = await send('GET', einstellungenUrl('p4'), { cookie })
assert.deepEqual(lesen.body.abgeschaltet, { grund: 'abo_ende', seit: 'kein Datum', loeschungAm: null })
const res = await send('PATCH', '/api/termine/einstellungen', { cookie, body: { projectId: 'p4', version: 1, einstellung: lesen.body.einstellung } })
assert.equal(res.status, 409)
assert.equal(res.body.error, 'Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo beendet ist. Du kannst Termine nur noch ansehen und exportieren.')
})
test('aktive Funktion: abgeschaltet ist null, auch wenn am Dokument noch Reste einer früheren Abschaltung stehen', async () => {
await speichere()
assert.equal((await lade()).abgeschaltet, null)
aw.seed('portalFeatures', featureDoc({ disabledReason: 'abo_ende', disabledAt: '2026-10-04T23:30:00.000Z' }))
resetKontextForTests()
const body = await lade()
assert.equal(body.state, 'active')
assert.equal(body.abgeschaltet, null)
})
// ---------------------------------------------------------------- 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
}
})