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), Feed-Link, Buchungen und Zeiten bleiben 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 (PATCH der Einstellungen und POST der Vorlage im Entwurf: Schreibregeln in test/termineFragebogenRoutes.test.js, Teile 1b und 2) 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/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(`
`)) assert.ok(b.einbau.snippet.includes(``)) assert.ok(b.einbau.snippet.includes('

Termine telefonisch vereinbaren.

'), '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('

Termine telefonisch unter 06301 <12>.

'), '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', 'fehlt', 'version']) assert.deepEqual(res.body.fehlt, []) 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 } })