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

1052 lines
57 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 { createHash } from 'node:crypto'
import { startAppwriteMock } from './support/appwriteMock.js'
import { mailSink } from './support/mailSink.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
// Gast-Ablauf des Terminplaners: halten, Vorschau, bestaetigen. Nachbau von Appwrite mit Schema fuer die Termine-
// Collections (Standard-Limit 25 wie in Produktion), Mails landen in der Senke, feste Zeiten per Parameter.
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
termineConfig: schemaKeys('termineConfig'),
termineBookings: schemaKeys('termineBookings'),
termineClaims: schemaKeys('termineClaims'),
termineOutbox: schemaKeys('termineOutbox'),
},
datetimes: TERMINE_DATETIMES,
defaultLimit: 25,
})
after(() => aw.close())
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { normalisiereEinstellung } = await import('../server/services/termine/einstellungen.js')
const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js')
const { hashToken, verwaltungsToken, buchungsNummer } = await import('../server/services/termine/token.js')
const { speichereEinstellung, resetKontextForTests } = await import('../server/services/termine/kontext.js')
const { zellenFuer, zellenId } = await import('../server/services/termine/zellen.js')
const { RECHTSTEXTE_VERSION, ZUSTIMMUNG_STORNO, ZUSTIMMUNG_VORZEITIG, ZUSTIMMUNG_ERINNERUNG, formularHinweis } = await import(
'../server/services/termine/rechtstexte.js'
)
const { halte, vorschau, bestaetige, pruefeEingabe } = await import('../server/services/termine/ablauf.js')
after(() => setMailTransportForTests(null))
const MIN = 60000
const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin
const TAG_NAH = '2026-10-12' // 7 Tage spaeter: vorzeitiger Beginn zustimmungspflichtig
const TAG_FERN = '2026-10-26' // 21 Tage spaeter, Montag
const KUNDE = 'c_kunde01'
const PROJEKT = 'p_projekt01'
const FEATURE = 'f_p_projekt01_termine'
const R1 = 'r_aaaaaaa1'
const R2 = 'r_aaaaaaa2'
const S_SOFORT = 's_bbbbbbb1' // Damenschnitt, 45 Minuten, 45 Euro, sofort
const S_ANFRAGE = 's_bbbbbbb2' // Besichtigung, 60 Minuten, ohne Preis, Anfrage
const S_GRATIS = 's_bbbbbbb3' // Kennenlernen, 30 Minuten, ohne Preis, sofort
const ABSENDER_VORHER = config.smtp.from
const MODUS_VORHER = config.termine.mode
const BASIS_VORHER = config.publicBaseUrl
const iso = (date) => new Date(date).toISOString()
const sha256 = (text) => createHash('sha256').update(text).digest('hex')
const plus = (date, ms) => new Date(date.getTime() + ms)
const feature = () => ({ $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'termine', state: 'active', enabled: true, siteKey: 'abcdefghijklmnop' })
const eingabeEinstellung = (over = {}) => ({
betrieb: {
name: 'Salon Müller',
email: 'info@salon.de',
telefon: '06301 1234',
adresse: 'Hauptstr. 1, 67722 Winnweiler',
websiteUrl: 'https://salon.de',
impressumUrl: 'https://salon.de/impressum',
datenschutzUrl: 'https://salon.de/datenschutz',
},
anrede: 'sie',
bundesland: 'RP',
ressourcen: [
{ id: R1, name: 'Anna', plan: { 1: [['09:00', '18:00']] } },
{ id: R2, name: 'Ben', plan: { 1: [['09:00', '18:00']] } },
],
leistungen: [
{ id: S_SOFORT, name: 'Damenschnitt', dauer: 45, pufferNach: 5, preisArt: 'fest', preisCent: 4500, buchungsart: 'sofort', ressourcen: [R1, R2] },
{ id: S_ANFRAGE, name: 'Besichtigung', dauer: 60, preisArt: 'keiner', buchungsart: 'anfrage', ressourcen: [R1, R2] },
{ id: S_GRATIS, name: 'Kennenlernen', dauer: 30, preisArt: 'keiner', buchungsart: 'sofort', ressourcen: [R1, R2] },
],
regeln: { raster: 15, vorlaufMin: 120, horizontTage: 90, absageBisStd: 24, maxOffeneJeEmail: 3, personWaehlbar: true },
gastFelder: { telefon: 'optional', nachricht: 'aus', zusatz: [] },
erinnerung: { anbieten: true, stundenVorher: 24 },
storno: { text: 'Kostenlose Absage bis 24 Stunden vor dem Termin.' },
recht: { gaesteSind: 'verbraucher', freizeit: false },
zahlung: { modus: 'vorort', vorOrtArten: ['bar', 'ec'] },
...over,
})
const einstellung = (over) => {
const erg = normalisiereEinstellung(eingabeEinstellung(over), { now: JETZT })
assert.equal(erg.fehler, undefined, JSON.stringify(erg.fehler))
return erg.einstellung
}
const ctxMit = (e = einstellung()) => ({ feature: feature(), project: { $id: PROJEKT, subdomain: 'salon' }, einstellung: e, version: 1 })
const eingabe = (over = {}) => ({
leistung: S_SOFORT,
ressource: 'egal',
tag: TAG_FERN,
zeit: '10:00',
name: 'Max Mustermann',
email: 'max@example.com',
telefon: '0171 1234567',
nachricht: '',
zusatz: {},
erinnerung: false,
zustimmungen: { storno: true, vorzeitig: false },
...over,
})
let sink
let log
/** Die Einstellung wird gespeichert, damit die Token-Funktionen (kontextPerFeature) sie finden. */
async function speichere(e = einstellung()) {
resetKontextForTests()
const doc = aw.docs('termineConfig').get(FEATURE)
await speichereEinstellung(feature(), e, { version: doc ? doc.version : 0, now: JETZT })
return ctxMit(e)
}
beforeEach(async () => {
for (const collection of ['portalFeatures', 'termineConfig', 'termineBookings', 'termineClaims', 'termineOutbox', 'customers', 'websiteProjects']) {
for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id)
}
resetKontextForTests()
resetOutboxForTests()
config.termine.mode = 'pilot'
config.smtp.from = 'noreply@webklar.com'
config.publicBaseUrl = 'https://project.webklar.com'
sink = mailSink()
setMailTransportForTests(sink)
log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}) }
aw.seed('portalFeatures', feature())
aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, subdomain: 'salon' })
aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: 'silke@example.com' })
})
afterEach(async () => {
await warteAufVersand()
mock.restoreAll()
config.termine.mode = MODUS_VORHER
config.smtp.from = ABSENDER_VORHER
config.publicBaseUrl = BASIS_VORHER
})
const buchungen = () => [...aw.docs('termineBookings').values()]
const buchungMitStatus = (status) => buchungen().filter((b) => b.status === status)
const claims = (bookingId) => [...aw.docs('termineClaims').values()].filter((c) => c.bookingId === bookingId)
const eintraege = () => [...aw.docs('termineOutbox').values()]
const entfalte = (ics) => ics.replace(/\r\n /g, '')
/** Fehler eines Aufrufs (status, fehler, code); schlaegt fehl, wenn nichts geworfen wird. */
async function fehlerVon(fn) {
try {
await fn()
} catch (err) {
return err
}
assert.fail('es wurde kein Fehler geworfen')
}
/** Haelt einen Termin und gibt { buchung, token } zurueck; das Token steht nur in G01 (Mail-Link #v=). */
async function haltenMitToken(ctx, over = {}, now = JETZT) {
const vorher = sink.sent.length
const erg = await halte(ctx, eingabe(over), now)
await warteAufVersand()
assert.equal(erg.status, 'gehalten')
const mail = sink.sent[vorher]
const token = /termin\.html#v=([A-Za-z0-9_-]{43})/.exec(mail.text)[1]
const buchung = buchungen().find((b) => b.verifyHash === hashToken(token))
assert.ok(buchung, 'Buchung zum Token')
return { buchung, token, mail }
}
/** Gehalten und bestaetigt; => { buchung (frisch gelesen), verwaltungsToken, mailStart (Index der ersten Bestaetigungsmail) }. */
async function gebucht(ctx, over = {}, now = JETZT) {
const { buchung, token } = await haltenMitToken(ctx, over, now)
const mailStart = sink.sent.length
const erg = await bestaetige(token, now)
await warteAufVersand()
return { erg, id: buchung.$id, buchung: aw.docs('termineBookings').get(buchung.$id), verwaltung: verwaltungsToken(buchung.$id), token, mailStart }
}
// ---------------------------------------------------------------- Halten
test('halte: Buchung gehalten mit Haltezeit, Preis, Nachweis und Zellen; G01 ohne Gastname, Token nur in der Mail', async () => {
const ctx = await speichere()
const erg = await halte(ctx, eingabe({ name: ' Max Mustermann ', email: ' Max@Example.COM ', telefon: '0171 123', erinnerung: true }), JETZT)
await warteAufVersand()
assert.deepEqual(erg, { status: 'gehalten', email: 'max@example.com' })
const [b] = buchungen()
assert.equal(buchungen().length, 1)
assert.match(b.$id, /^[0-9a-f-]{36}$/)
assert.deepEqual(
{
featureId: b.featureId, customerId: b.customerId, projectId: b.projectId, leistungId: b.leistungId, leistungName: b.leistungName,
status: b.status, quelle: b.quelle, gastName: b.gastName, gastEmail: b.gastEmail, gastTelefon: b.gastTelefon, gastNachricht: b.gastNachricht,
gastZusatz: b.gastZusatz, erinnerung: b.erinnerung, preisCent: b.preisCent, preisArt: b.preisArt, buchungsart: b.buchungsart,
verbraucher: b.verbraucher, zahlart: b.zahlart, zahlStatus: b.zahlStatus, icsSequenz: b.icsSequenz,
},
{
featureId: FEATURE, customerId: KUNDE, projectId: PROJEKT, leistungId: S_SOFORT, leistungName: 'Damenschnitt',
status: 'gehalten', quelle: 'online', gastName: 'Max Mustermann', gastEmail: 'max@example.com', gastTelefon: '0171 123', gastNachricht: '',
gastZusatz: '', erinnerung: true, preisCent: 4500, preisArt: 'fest', buchungsart: 'sofort',
verbraucher: true, zahlart: 'vorort', zahlStatus: 'keine', icsSequenz: 0,
},
)
// 10:00 Berlin (MESZ, bis 25.10.) und 26.10. = MEZ: 10:00 = 09:00Z
assert.equal(iso(b.startsAt), '2026-10-26T09:00:00.000Z')
assert.equal(iso(b.endsAt), '2026-10-26T09:45:00.000Z')
assert.equal(iso(b.blockEndsAt), '2026-10-26T09:50:00.000Z', 'Pause danach gehoert zum Block')
assert.equal(iso(b.heldUntil), iso(plus(JETZT, 30 * MIN)))
assert.equal(iso(b.createdAt), iso(JETZT))
assert.equal(b.manageHash, undefined, 'Verwaltungstoken erst bei der Bestaetigung')
assert.equal(b.bestaetigtAt, undefined)
assert.ok([R1, R2].includes(b.ressourceId))
// Zellen: 50 Minuten = 10 Zellen, Ende = blockEndsAt
const zellen = claims(b.$id)
assert.equal(zellen.length, 10)
assert.ok(zellen.every((z) => z.ressourceId === b.ressourceId && z.featureId === FEATURE && iso(z.bis) === iso(b.blockEndsAt)))
assert.ok(aw.docs('termineClaims').has(zellenId(FEATURE, b.ressourceId, zellenFuer(b.startsAt, b.blockEndsAt)[0])))
// G01: genau eine Mail an die normalisierte Adresse, ohne Gastname, ohne Kalenderdatei, ohne BCC
assert.equal(sink.sent.length, 1)
const [mail] = sink.sent
assert.equal(mail.subject, 'Bitte bestätigen Sie Ihren Termin bei Salon Müller')
assert.deepEqual(mail.to, [{ address: 'max@example.com', name: '' }])
assert.equal('bcc' in mail, false)
assert.equal('icalEvent' in mail, false)
for (const inhalt of [mail.text, mail.html]) {
assert.doesNotMatch(inhalt, /Mustermann/)
assert.doesNotMatch(inhalt, /0171/)
}
const token = /termin\.html#v=([A-Za-z0-9_-]{43})/.exec(mail.text)?.[1]
assert.ok(token, 'Bestaetigungslink mit Token im Fragment')
assert.equal(b.verifyHash, hashToken(token))
assert.doesNotMatch(JSON.stringify(buchungen()), new RegExp(token), 'das Token selbst wird nie gespeichert')
assert.equal(eintraege().filter((d) => d.art === 'G01').length, 1)
})
test('halte: nachweis enthaelt Fassung, Zeitpunkt, Knopftext, Anrede und die Hashes der gezeigten Texte (ohne IP und Browser)', async () => {
const ctx = await speichere()
await halte(ctx, eingabe({ tag: TAG_NAH, erinnerung: true, zustimmungen: { storno: true, vorzeitig: true } }), JETZT)
const nachweis = JSON.parse(buchungen()[0].nachweis)
const e = ctx.einstellung
assert.equal(nachweis.fassung, RECHTSTEXTE_VERSION)
assert.equal(nachweis.am, iso(JETZT))
assert.equal(nachweis.knopf, 'Zahlungspflichtig buchen')
assert.equal(nachweis.anrede, 'sie')
assert.equal(nachweis.datenschutz, sha256(formularHinweis(e)))
assert.deepEqual(nachweis.storno, { version: e.storno.version, hash: sha256(e.storno.text), haken: sha256(ZUSTIMMUNG_STORNO), am: iso(JETZT) })
assert.deepEqual(nachweis.vorzeitig, { hash: sha256(ZUSTIMMUNG_VORZEITIG('Salon Müller')), am: iso(JETZT) })
assert.deepEqual(nachweis.erinnerung, { stunden: 24, hash: sha256(ZUSTIMMUNG_ERINNERUNG(24)), am: iso(JETZT) })
assert.match(nachweis.widerruf.hash, /^[0-9a-f]{64}$/, 'Widerrufsbelehrung bei verbindlicher Verbraucherbuchung')
assert.deepEqual(Object.keys(nachweis).sort(), ['am', 'anrede', 'datenschutz', 'erinnerung', 'fassung', 'knopf', 'storno', 'vorzeitig', 'widerruf'])
assert.ok(buchungen()[0].nachweis.length < 4000)
})
test('halte: Zustimmungen, die nicht verlangt oder nicht gegeben wurden, stehen nicht im Nachweis', async () => {
const ctx = await speichere(einstellung({ storno: { text: '' }, recht: { gaesteSind: 'unternehmer' } }))
await halte(ctx, eingabe({ tag: TAG_NAH, erinnerung: false, zustimmungen: { storno: true, vorzeitig: true } }), JETZT)
const nachweis = JSON.parse(buchungen()[0].nachweis)
assert.deepEqual(Object.keys(nachweis).sort(), ['am', 'anrede', 'datenschutz', 'fassung', 'knopf'], 'kein Storno-Text, Unternehmer, keine Erinnerung')
assert.equal(buchungen()[0].verbraucher, false)
})
test('halte: Anfrage und Leistung ohne Preis bekommen ihre Knoepfe und brauchen keine Zustimmung zum vorzeitigen Beginn', async () => {
const ctx = await speichere()
await halte(ctx, eingabe({ leistung: S_ANFRAGE, tag: TAG_NAH, zeit: '10:00' }), JETZT)
await halte(ctx, eingabe({ leistung: S_GRATIS, tag: TAG_NAH, zeit: '12:00', email: 'zwei@example.com' }), JETZT)
const nachweise = buchungen().map((b) => [b.buchungsart, JSON.parse(b.nachweis).knopf, JSON.parse(b.nachweis).vorzeitig]).sort()
assert.deepEqual(nachweise, [['anfrage', 'Termin anfragen', undefined], ['sofort', 'Termin buchen', undefined]])
const anfrage = buchungen().find((b) => b.buchungsart === 'anfrage')
assert.equal(anfrage.preisCent, 0)
assert.equal(anfrage.preisArt, 'keiner')
// R55: das Widget zeigt die Belehrung auch vor einer Anfrage, der Nachweis haelt sie fest
assert.match(JSON.parse(anfrage.nachweis).widerruf.hash, /^[0-9a-f]{64}$/)
})
test('storno_haken_nur_bei_preis: der Storno-Haken ist nur bei einer Leistung mit Preis Pflicht und steht nur dort im Nachweis', async () => {
const ctx = await speichere()
// mit Preis: ohne Haken wie bisher 400 (Feld storno), nichts angelegt
const err = await fehlerVon(() => halte(ctx, eingabe({ zustimmungen: { storno: false, vorzeitig: false } }), JETZT))
assert.equal(err.status, 400)
assert.deepEqual(Object.keys(err.fehler), ['storno'])
assert.equal(buchungen().length, 0)
// ohne Preis (sofort): ohne Haken gehalten, kein Eintrag storno im Nachweis
await halte(ctx, eingabe({ leistung: S_GRATIS, zeit: '10:00', email: 'a@example.com', zustimmungen: { storno: false, vorzeitig: false } }), JETZT)
// ohne Preis, auch ganz ohne Zustimmungen
await halte(ctx, eingabe({ leistung: S_GRATIS, zeit: '12:00', email: 'b@example.com', zustimmungen: undefined }), JETZT)
// ohne Preis, Anfrage
await halte(ctx, eingabe({ leistung: S_ANFRAGE, zeit: '14:00', email: 'c@example.com', zustimmungen: {} }), JETZT)
// ohne Preis mit storno: true (altes Widget, anderer Client): ebenfalls kein Nachweis des Hakens
await halte(ctx, eingabe({ leistung: S_GRATIS, zeit: '16:00', email: 'd@example.com', zustimmungen: { storno: true, vorzeitig: false } }), JETZT)
assert.equal(buchungen().length, 4)
for (const b of buchungen()) {
assert.equal(b.status, 'gehalten')
const nachweis = JSON.parse(b.nachweis)
assert.equal(nachweis.storno, undefined, `${b.leistungId}: kein Storno-Nachweis ohne Preis`)
assert.equal(Object.hasOwn(nachweis, 'storno'), false)
assert.ok(!b.nachweis.includes(sha256(ZUSTIMMUNG_STORNO)), 'auch der Hash des Haken-Textes fehlt')
}
// mit Preis und Haken: Nachweis wie bisher
await halte(ctx, eingabe({ zeit: '09:00', email: 'e@example.com' }), JETZT)
const mitPreis = buchungen().find((b) => b.leistungId === S_SOFORT)
assert.deepEqual(JSON.parse(mitPreis.nachweis).storno, { version: ctx.einstellung.storno.version, hash: sha256(ctx.einstellung.storno.text), haken: sha256(ZUSTIMMUNG_STORNO), am: iso(JETZT) })
// Anfrage mit Preis: Haken bleibt Pflicht
const anfrageMitPreis = { id: 's_bbbbbbb4', name: 'Beratung', dauer: 30, preisArt: 'fest', preisCent: 3000, buchungsart: 'anfrage', ressourcen: [R1, R2] }
const mitAnfrage = await speichere(einstellung({ leistungen: [anfrageMitPreis] }))
const ohneHaken = await fehlerVon(() => halte(mitAnfrage, eingabe({ leistung: 's_bbbbbbb4', zeit: '11:00', email: 'f@example.com', zustimmungen: { storno: false, vorzeitig: true } }), JETZT))
assert.deepEqual(Object.keys(ohneHaken.fehler), ['storno'])
// Handeintrag (manuell): nie Zustimmungen, mit und ohne Preis
const e = ctx.einstellung
for (const leistung of [S_SOFORT, S_GRATIS]) {
const geprueft = pruefeEingabe(e, eingabe({ leistung, zustimmungen: undefined }), JETZT, { manuell: true })
assert.equal(geprueft.stornoNoetig, false, `Handeintrag ${leistung}`)
}
assert.equal(pruefeEingabe(e, eingabe({ leistung: S_SOFORT }), JETZT).stornoNoetig, true)
assert.equal(pruefeEingabe(e, eingabe({ leistung: S_GRATIS }), JETZT).stornoNoetig, false)
})
test('halte: egal waehlt die Ressource mit den wenigsten Buchungen des Tages, eine feste Ressource bleibt sie', async () => {
const ctx = await speichere()
await halte(ctx, eingabe({ ressource: R1, zeit: '09:00' }), JETZT)
await halte(ctx, eingabe({ ressource: 'egal', zeit: '10:00', email: 'zwei@example.com' }), JETZT)
const nachZeit = Object.fromEntries(buchungen().map((b) => [new Date(b.startsAt).toISOString().slice(11, 16), b.ressourceId]))
assert.deepEqual(nachZeit, { '08:00': R1, '09:00': R2 }, 'Anna hat schon einen Termin, also Ben')
assert.equal(buchungen().find((b) => b.ressourceId === R2).ressourceName, 'Ben')
})
// ---------------------------------------------------------------- Pruefung der Eingabe
test('halte: ungueltige Eingaben ergeben 400 mit Feldfehlern und speichern nichts', async () => {
const ctx = await speichere(einstellung({
gastFelder: {
telefon: 'pflicht',
nachricht: 'optional',
zusatz: [
{ key: 'kennzeichen', label: 'Kennzeichen', typ: 'text', pflicht: true },
{ key: 'art', label: 'Art', typ: 'auswahl', optionen: ['Kurz', 'Lang'], pflicht: false },
],
},
}))
const gut = eingabe({ zusatz: { kennzeichen: 'KL-AB 123' } })
const faelle = [
['leistung', { leistung: 's_unbekannt' }],
['leistung', { leistung: undefined }],
['leistung', { leistung: { $ne: null } }],
['ressource', { ressource: 'r_unbekannt' }],
['ressource', { ressource: undefined }],
['ressource', { ressource: ['egal'] }],
['tag', { tag: '2026-02-30' }],
['tag', { tag: '26.10.2026' }],
['tag', { tag: undefined }],
['zeit', { zeit: '10:7' }],
['zeit', { zeit: '24:00' }],
['zeit', { zeit: '25:00' }],
['zeit', { tag: '2027-03-28', zeit: '02:30' }], // Luecke der Maerz-Umstellung
['zeit', { zeit: undefined }],
['name', { name: '' }],
['name', { name: ' ' }],
['name', { name: '​\u0000' }],
['name', { name: 'M'.repeat(101) }],
['name', { name: 42 }],
['email', { email: 'max' }],
['email', { email: 'max@example' }],
['email', { email: 'a@example.com, b@example.com' }],
['email', { email: 'a@example.com;b@example.com' }],
['email', { email: '"Max" <max@example.com>' }],
['email', { email: `${'a'.repeat(250)}@example.com` }],
['email', { email: ['max@example.com'] }],
['telefon', { telefon: '' }],
['telefon', { telefon: 'abc' }],
['telefon', { telefon: '0171 <script>' }],
['telefon', { telefon: '0'.repeat(41) }],
['nachricht', { nachricht: 'x'.repeat(501) }],
['zusatz.kennzeichen', { zusatz: {} }],
['zusatz.kennzeichen', { zusatz: { kennzeichen: '' } }],
['zusatz.kennzeichen', { zusatz: { kennzeichen: 'K'.repeat(201) } }],
['zusatz.kennzeichen', { zusatz: { kennzeichen: 7 } }],
['zusatz.art', { zusatz: { kennzeichen: 'KL-AB 123', art: 'Mittel' } }],
['zusatz', { zusatz: { kennzeichen: 'KL-AB 123', fremd: 'x' } }],
['zusatz', { zusatz: ['KL'] }],
['erinnerung', { erinnerung: 'ja' }],
['storno', { zustimmungen: { storno: false, vorzeitig: false } }],
['storno', { zustimmungen: {} }],
['storno', { zustimmungen: { storno: 'true' } }],
['storno', { zustimmungen: undefined }],
]
for (const [feld, over] of faelle) {
const err = await fehlerVon(() => halte(ctx, { ...gut, ...over }, JETZT))
assert.equal(err.status, 400, feld)
assert.ok(Object.hasOwn(err.fehler, feld), `Feld ${feld} fehlt in ${JSON.stringify(err.fehler)}`)
assert.equal(typeof err.fehler[feld], 'string')
assert.doesNotMatch(err.fehler[feld], /undefined|\[object/)
}
assert.equal(buchungen().length, 0)
assert.equal(aw.docs('termineClaims').size, 0)
assert.equal(eintraege().length, 0)
const err = await fehlerVon(() => halte(ctx, null, JETZT))
assert.equal(err.status, 400)
assert.deepEqual(Object.keys(err.fehler), ['_'])
})
test('halte: mehrere Fehler kommen zusammen; Zusatzfeld, Telefon und Nachricht werden bereinigt gespeichert', async () => {
const ctx = await speichere(einstellung({
gastFelder: { telefon: 'optional', nachricht: 'optional', zusatz: [{ key: 'art', label: 'Art', typ: 'auswahl', optionen: ['Kurz', 'Lang'], pflicht: false }] },
}))
const err = await fehlerVon(() => halte(ctx, eingabe({ name: '', email: 'nein', zustimmungen: {} }), JETZT))
assert.deepEqual(Object.keys(err.fehler).sort(), ['email', 'name', 'storno'])
assert.equal(err.message, err.fehler[Object.keys(err.fehler)[0]], 'die Meldung ist kein Rohfehler')
await halte(ctx, eingabe({ name: 'Max\u0000 ​Mustermann', telefon: ' +49 (171) 123-45 ', nachricht: 'Bitte\r\nkeine\u0007 Eile', zusatz: { art: 'Lang' } }), JETZT)
const [b] = buchungen()
assert.equal(b.gastName, 'Max Mustermann')
assert.equal(b.gastTelefon, '+49 (171) 123-45')
assert.equal(b.gastNachricht, 'Bitte\nkeine Eile')
assert.deepEqual(JSON.parse(b.gastZusatz), { art: 'Lang' })
})
test('halte: abgeschaltete Gastfelder werden ignoriert, auch wenn der Client sie schickt', async () => {
const ctx = await speichere(einstellung({ gastFelder: { telefon: 'aus', nachricht: 'aus', zusatz: [] } }))
await halte(ctx, eingabe({ telefon: '0171 123', nachricht: 'Hallo' }), JETZT)
const [b] = buchungen()
assert.deepEqual([b.gastTelefon, b.gastNachricht, b.gastZusatz], ['', '', ''])
})
test('halte: egal ist nur erlaubt, wenn die Person waehlbar ist oder mehrere Ressourcen infrage kommen', async () => {
const einzel = einstellung({
ressourcen: [{ id: R1, name: 'Anna', plan: { 1: [['09:00', '18:00']] } }],
leistungen: [{ id: S_SOFORT, name: 'Damenschnitt', dauer: 45, preisArt: 'fest', preisCent: 4500, ressourcen: [R1] }],
regeln: { personWaehlbar: false, raster: 15, vorlaufMin: 120, horizontTage: 90, absageBisStd: 24, maxOffeneJeEmail: 3 },
})
const ctxEinzel = await speichere(einzel)
const err = await fehlerVon(() => halte(ctxEinzel, eingabe({ ressource: 'egal' }), JETZT))
assert.equal(err.status, 400)
assert.ok(err.fehler.ressource)
assert.equal((await halte(ctxEinzel, eingabe({ ressource: R1 }), JETZT)).status, 'gehalten')
const mehrere = einstellung({ regeln: { personWaehlbar: false, raster: 15, vorlaufMin: 120, horizontTage: 90, absageBisStd: 24, maxOffeneJeEmail: 3 } })
assert.equal((await halte(ctxMit(mehrere), eingabe({ ressource: 'egal', email: 'zwei@example.com' }), JETZT)).status, 'gehalten', 'mehrere Ressourcen')
})
test('halte: Erinnerung wird nur gespeichert, wenn der Betrieb sie anbietet', async () => {
const mit = await speichere()
await halte(mit, eingabe({ erinnerung: true }), JETZT)
const ohne = ctxMit(einstellung({ erinnerung: { anbieten: false, stundenVorher: 24 } }))
await halte(ohne, eingabe({ erinnerung: true, zeit: '12:00', email: 'zwei@example.com' }), JETZT)
const nachZeit = Object.fromEntries(buchungen().map((b) => [new Date(b.startsAt).toISOString().slice(11, 16), b.erinnerung]))
assert.deepEqual(nachZeit, { '09:00': true, '11:00': false })
assert.equal(JSON.parse(buchungen().find((b) => b.erinnerung === false).nachweis).erinnerung, undefined)
})
test('halte: fehlt die Zustimmung zum vorzeitigen Beginn (verbindlich, Preis, Verbraucher, Termin < 14 Tage), ist es ein 400 mit Feld vorzeitig', async () => {
const ctx = await speichere()
const err = await fehlerVon(() => halte(ctx, eingabe({ tag: TAG_NAH, zustimmungen: { storno: true, vorzeitig: false } }), JETZT))
assert.equal(err.status, 400)
assert.deepEqual(Object.keys(err.fehler), ['vorzeitig'])
assert.equal(buchungen().length, 0)
assert.equal(aw.docs('termineClaims').size, 0)
assert.equal((await halte(ctx, eingabe({ tag: TAG_NAH, zustimmungen: { storno: true, vorzeitig: true } }), JETZT)).status, 'gehalten')
// 14 Tage und mehr, Unternehmer, kein Preis, Anfrage: nicht noetig
assert.equal((await halte(ctx, eingabe({ tag: TAG_FERN, zeit: '12:00', email: 'a@example.com' }), JETZT)).status, 'gehalten')
assert.equal((await halte(ctx, eingabe({ tag: TAG_NAH, leistung: S_GRATIS, zeit: '14:00', email: 'b@example.com' }), JETZT)).status, 'gehalten')
const unternehmer = ctxMit(einstellung({ recht: { gaesteSind: 'unternehmer', freizeit: false } }))
assert.equal((await halte(unternehmer, eingabe({ tag: TAG_NAH, zeit: '16:00', email: 'c@example.com' }), JETZT)).status, 'gehalten')
})
test('halte: die Zustimmung zum vorzeitigen Beginn gilt bis zum Ende der Berliner Widerrufsfrist (R31), bei einer Anfrage mit Preis immer', async () => {
const ctx = await speichere()
// Buchung am Montag 05.10. (Haltezeit mitgerechnet) ⇒ Fristende Montag 19.10., 23:59:59 Uhr: 14 x 24 Stunden reichen nicht
const err = await fehlerVon(() => halte(ctx, eingabe({ tag: '2026-10-19', zeit: '10:00' }), JETZT))
assert.deepEqual(Object.keys(err.fehler), ['vorzeitig'])
assert.equal((await halte(ctx, eingabe({ tag: '2026-10-19', zeit: '10:00', zustimmungen: { storno: true, vorzeitig: true } }), JETZT)).status, 'gehalten')
// der naechste Tag liegt hinter der Frist
assert.equal((await halte(ctx, eingabe({ tag: '2026-10-26', zeit: '10:00', email: 'a@example.com' }), JETZT)).status, 'gehalten')
const anfrageMitPreis = { id: 's_bbbbbbb4', name: 'Beratung', dauer: 30, preisArt: 'fest', preisCent: 3000, buchungsart: 'anfrage', ressourcen: [R1, R2] }
const mitAnfrage = await speichere(einstellung({ leistungen: [anfrageMitPreis] }))
const fern = eingabe({ leistung: 's_bbbbbbb4', tag: TAG_FERN, zeit: '10:00', email: 'b@example.com' })
assert.deepEqual(Object.keys((await fehlerVon(() => halte(mitAnfrage, fern, JETZT))).fehler), ['vorzeitig'])
const ok = await halte(mitAnfrage, { ...fern, zustimmungen: { storno: true, vorzeitig: true } }, JETZT)
assert.equal(ok.status, 'gehalten')
assert.ok(JSON.parse(buchungen().find((b) => b.leistungId === 's_bbbbbbb4').nachweis).vorzeitig)
})
// ---------------------------------------------------------------- Slot und Doppelbuchung
test('halte: eine Zeit ausserhalb der freien Zeiten ist ein 409 mit Feld zeit und legt nichts an', async () => {
const ctx = await speichere()
const zugestimmt = { storno: true, vorzeitig: true }
for (const over of [{ zeit: '08:00' }, { zeit: '10:07' }, { tag: '2026-10-27' }, { tag: '2026-10-05', zeit: '09:00' }, { tag: '2027-12-06' }]) {
const err = await fehlerVon(() => halte(ctx, eingabe({ ...over, zustimmungen: zugestimmt }), JETZT))
assert.equal(err.status, 409, JSON.stringify(over))
assert.equal(err.code, 'SLOT_VERGEBEN')
assert.deepEqual(Object.keys(err.fehler), ['zeit'])
}
assert.equal(buchungen().length, 0)
assert.equal(eintraege().length, 0)
})
test('halte: dieselbe Zeit bei derselben Ressource ist beim zweiten Gast ein 409, mit "egal" weicht er auf die andere Ressource aus', async () => {
const ctx = await speichere()
await halte(ctx, eingabe({ ressource: R1 }), JETZT)
const err = await fehlerVon(() => halte(ctx, eingabe({ ressource: R1, email: 'zwei@example.com' }), JETZT))
assert.equal(err.status, 409)
assert.deepEqual(Object.keys(err.fehler), ['zeit'])
assert.equal(buchungen().length, 1)
assert.equal((await halte(ctx, eingabe({ ressource: 'egal', email: 'zwei@example.com' }), JETZT)).status, 'gehalten')
assert.deepEqual(buchungen().map((b) => b.ressourceId).sort(), [R1, R2])
})
test('halte: Zellen schon vergeben (Wettlauf) => Buchung verfallen, keine Zellen, 409 Feld zeit', async () => {
const ctx = await speichere()
// Fremde Buchung, deren Zeiten die Tagesabfrage nicht findet, deren Zellen aber noch gelten: nur belegeZellen merkt es
const fremd = {
$id: 'fremd-buchung', featureId: FEATURE, customerId: KUNDE, projectId: PROJEKT, leistungId: S_SOFORT, leistungName: 'Damenschnitt',
ressourceId: R1, ressourceName: 'Anna', status: 'bestaetigt', quelle: 'online',
startsAt: '2020-01-01T09:00:00.000Z', endsAt: '2020-01-01T09:45:00.000Z', blockEndsAt: '2020-01-01T09:50:00.000Z',
}
aw.seed('termineBookings', fremd)
const start = new Date('2026-10-26T09:00:00.000Z')
for (const zelle of zellenFuer(start, new Date('2026-10-26T09:50:00.000Z'))) {
aw.seed('termineClaims', {
$id: zellenId(FEATURE, R1, zelle), featureId: FEATURE, ressourceId: R1, bookingId: 'fremd-buchung', zelle: iso(zelle), bis: '2026-10-26T09:50:00.000Z',
})
}
const claimsVorher = aw.docs('termineClaims').size
const err = await fehlerVon(() => halte(ctx, eingabe({ ressource: R1 }), JETZT))
assert.equal(err.status, 409)
assert.equal(err.code, 'SLOT_VERGEBEN')
assert.deepEqual(Object.keys(err.fehler), ['zeit'])
const eigene = buchungen().filter((b) => b.$id !== 'fremd-buchung')
assert.equal(eigene.length, 1, 'die Buchung wurde zuerst angelegt')
assert.equal(eigene[0].status, 'verfallen')
assert.equal(claims(eigene[0].$id).length, 0, 'keine Zellen')
assert.equal(aw.docs('termineClaims').size, claimsVorher, 'die Zellen der fremden Buchung bleiben')
assert.equal(eintraege().length, 0, 'kein G01')
assert.equal(buchungen().find((b) => b.$id === 'fremd-buchung').status, 'bestaetigt')
})
test('halte: zwei Gaeste gleichzeitig auf dieselbe Zeit => genau einer bekommt sie, der andere ein 409', async () => {
const ctx = await speichere()
const ergebnisse = await Promise.allSettled([
halte(ctx, eingabe({ ressource: R1, email: 'eins@example.com' }), JETZT),
halte(ctx, eingabe({ ressource: R1, email: 'zwei@example.com' }), JETZT),
])
await warteAufVersand()
assert.deepEqual(ergebnisse.map((e) => e.status).sort(), ['fulfilled', 'rejected'])
const abgelehnt = ergebnisse.find((e) => e.status === 'rejected').reason
assert.equal(abgelehnt.status, 409)
const gehalten = buchungMitStatus('gehalten')
assert.equal(gehalten.length, 1)
assert.equal(claims(gehalten[0].$id).length, 10)
assert.equal(aw.docs('termineClaims').size, 10, 'Zellen gehoeren nur dem Gewinner')
assert.ok(buchungen().every((b) => ['gehalten', 'verfallen'].includes(b.status)))
assert.equal(sink.sent.length, 1, 'nur eine Bestaetigungsmail')
})
// ---------------------------------------------------------------- Grenze offener Termine
test('halte: email_normalisiert - Gross-/Kleinschreibung und Leerzeichen zaehlen gegen dieselbe Grenze; die vierte Buchung wird nicht angelegt', async () => {
const ctx = await speichere()
const varianten = ['Max@GMail.com', ' max@gmail.com ', 'MAX@GMAIL.COM']
for (const [i, email] of varianten.entries()) {
const erg = await halte(ctx, eingabe({ email, zeit: ['09:00', '11:00', '13:00'][i] }), JETZT)
assert.deepEqual(erg, { status: 'gehalten', email: 'max@gmail.com' })
}
assert.deepEqual([...new Set(buchungen().map((b) => b.gastEmail))], ['max@gmail.com'])
await warteAufVersand()
const mailsVorher = sink.sent.length
const zellenVorher = aw.docs('termineClaims').size
const erg = await halte(ctx, eingabe({ email: ' Max@GMail.com ', zeit: '15:00', name: 'Eva Dritte' }), JETZT)
await warteAufVersand()
assert.deepEqual(erg, { status: 'zu_viele', email: 'max@gmail.com' })
assert.equal(buchungen().length, 3, 'keine Buchung angelegt')
assert.equal(aw.docs('termineClaims').size, zellenVorher, 'keine Zellen')
assert.equal(sink.sent.length, mailsVorher + 1)
const mail = sink.sent.at(-1)
assert.equal(mail.subject, 'Ihr Termin bei Salon Müller konnte nicht vorgemerkt werden')
assert.deepEqual(mail.to, [{ address: 'max@gmail.com', name: '' }])
assert.doesNotMatch(mail.text, /termin\.html#v=/, 'kein Bestaetigungslink')
assert.doesNotMatch(mail.text + mail.html, /Eva|Dritte/, 'kein Text des Absenders')
})
test('halte: die Grenze zaehlt nur offene Termine dieser Adresse; abgesagte, abgelaufene und fremde Adressen zaehlen nicht', async () => {
const ctx = await speichere(einstellung({ regeln: { raster: 15, vorlaufMin: 120, horizontTage: 90, absageBisStd: 24, maxOffeneJeEmail: 1, personWaehlbar: true } }))
await halte(ctx, eingabe({ zeit: '09:00' }), JETZT)
assert.equal((await halte(ctx, eingabe({ zeit: '11:00', email: 'anders@example.com' }), JETZT)).status, 'gehalten', 'andere Adresse')
assert.equal((await halte(ctx, eingabe({ zeit: '13:00' }), JETZT)).status, 'zu_viele')
// Haltung abgelaufen => zaehlt nicht mehr
assert.equal((await halte(ctx, eingabe({ zeit: '13:00' }), plus(JETZT, 31 * MIN))).status, 'gehalten')
// abgesagt => zaehlt nicht
const dritte = buchungen().find((b) => b.gastEmail === 'max@example.com' && new Date(b.startsAt).getUTCHours() === 12)
aw.seed('termineBookings', { ...dritte, status: 'abgesagt' })
assert.equal((await halte(ctx, eingabe({ zeit: '15:00' }), plus(JETZT, 31 * MIN))).status, 'gehalten')
})
test('halte: gleichzeitige Anfragen derselben Adresse ueberschreiten die Grenze nicht', async () => {
const ctx = await speichere()
const zeiten = ['09:00', '10:00', '11:00', '12:00', '13:00']
const ergebnisse = await Promise.all(zeiten.map((zeit) => halte(ctx, eingabe({ zeit }), JETZT)))
await warteAufVersand()
assert.deepEqual(ergebnisse.map((e) => e.status).sort(), ['gehalten', 'gehalten', 'gehalten', 'zu_viele', 'zu_viele'])
assert.equal(buchungen().length, 3)
})
test('halte: bei erreichter Grenze kommt dieselbe Antwort wie sonst, aber eine abgelehnte Zeit bleibt ein 409 (kein Orakel ueber die Adresse)', async () => {
const ctx = await speichere(einstellung({ regeln: { raster: 15, vorlaufMin: 120, horizontTage: 90, absageBisStd: 24, maxOffeneJeEmail: 1, personWaehlbar: true } }))
await halte(ctx, eingabe({ zeit: '09:00', ressource: R1 }), JETZT)
const err = await fehlerVon(() => halte(ctx, eingabe({ zeit: '09:00', ressource: R1 }), JETZT))
assert.equal(err.status, 409, 'belegte Zeit gilt auch fuer eine Adresse an der Grenze')
})
// ---------------------------------------------------------------- Mailfehler
test('halte: scheitert die Mail, bleibt die Haltung bestehen; das Log nennt keine personenbezogenen Daten', async () => {
const ctx = await speichere()
const echt = globalThis.fetch
globalThis.fetch = async (url, optionen) => {
if (/\/collections\/termineOutbox\/documents/.test(String(url)) && optionen?.method === 'POST') {
return new Response(JSON.stringify({ message: 'Server error', code: 500, type: 'general_server_error' }), { status: 500, headers: { 'content-type': 'application/json' } })
}
return echt(url, optionen)
}
let erg
try {
erg = await halte(ctx, eingabe({ email: 'geheim@example.com', name: 'Geheim Person' }), JETZT)
} finally {
globalThis.fetch = echt
}
assert.equal(erg.status, 'gehalten')
assert.equal(buchungMitStatus('gehalten').length, 1)
assert.equal(claims(buchungen()[0].$id).length, 10)
const protokoll = JSON.stringify(log.error.mock.calls.map((c) => c.arguments))
assert.match(protokoll, /G01/)
assert.doesNotMatch(protokoll, /geheim|Geheim/i)
})
// ---------------------------------------------------------------- Bestaetigen
test('bestaetige (sofort): bestaetigt mit bestaetigtAt und manageHash; G02 mit Kalendereinladung und #m=-Link, K20 an den Betrieb ohne BCC', async () => {
const ctx = await speichere()
const { buchung, token } = await haltenMitToken(ctx, { erinnerung: true })
const spaeter = plus(JETZT, 10 * MIN)
const erg = await bestaetige(token, spaeter)
await warteAufVersand()
assert.deepEqual(erg, { status: 'bestaetigt' })
const b = aw.docs('termineBookings').get(buchung.$id)
assert.equal(b.status, 'bestaetigt')
assert.equal(iso(b.bestaetigtAt), iso(spaeter))
assert.equal(b.manageHash, hashToken(verwaltungsToken(buchung.$id)))
assert.equal(b.angenommenAt, undefined)
assert.equal(b.verifyHash, hashToken(token), 'der Hash bleibt, damit ein zweiter Klick erkannt wird')
assert.equal(claims(buchung.$id).length, 10, 'Zellen bleiben')
assert.equal(sink.sent.length, 3, 'G01, G02, K20')
const g02 = sink.sent[1]
assert.equal(g02.subject, 'Ihr Termin bei Salon Müller am 26.10.2026')
assert.deepEqual(g02.to, [{ address: 'max@example.com', name: '' }])
assert.deepEqual(g02.from, { address: 'noreply@webklar.com', name: 'Salon Müller' })
assert.deepEqual(g02.replyTo, [{ address: 'info@salon.de', name: '' }])
assert.equal('bcc' in g02, false)
assert.equal(g02.icalEvent.method, 'REQUEST')
const ics = entfalte(g02.icalEvent.content)
assert.match(ics, new RegExp(`UID:${buchung.$id}@termine\\.webklar\\.com`))
assert.match(ics, /DTSTART:20261026T090000Z/)
assert.match(ics, /SEQUENCE:0/)
assert.ok(g02.html.includes(`termin.html#m=${verwaltungsToken(buchung.$id)}`))
assert.match(g02.text, new RegExp(buchungsNummer(buchung.$id)))
assert.match(g02.text, /Widerruf/, 'Widerrufsbelehrung fuer Verbraucher')
const k20 = sink.sent[2]
assert.equal(k20.subject, 'Neue Buchung: Damenschnitt am 26.10.2026 um 10:00')
assert.deepEqual(k20.to, [{ address: 'info@salon.de', name: '' }])
assert.deepEqual(k20.from, { address: 'noreply@webklar.com', name: 'WEBklar' })
assert.equal('bcc' in k20, false)
assert.equal(k20.icalEvent.method, 'PUBLISH')
assert.match(k20.text, /Hallo Silke Müller,/)
assert.deepEqual(eintraege().map((d) => d.art).sort(), ['G01', 'G02', 'K20'])
})
test('bestaetige (Anfrage): angefragt mit manageHash, G03 an den Gast und K20-Block anfrage ohne Kalendereintrag', async () => {
const ctx = await speichere()
const { buchung, token } = await haltenMitToken(ctx, { leistung: S_ANFRAGE, tag: TAG_NAH, zeit: '10:00' })
const erg = await bestaetige(token, plus(JETZT, 5 * MIN))
await warteAufVersand()
assert.deepEqual(erg, { status: 'angefragt' })
const b = aw.docs('termineBookings').get(buchung.$id)
assert.equal(b.status, 'angefragt')
assert.equal(b.manageHash, hashToken(verwaltungsToken(buchung.$id)))
assert.equal(b.bestaetigtAt, undefined)
assert.equal(b.angenommenAt, undefined, 'Vertragsschluss erst durch den Betrieb')
assert.equal(sink.sent.length, 3, 'G01, G03, K20')
const g03 = sink.sent[1]
assert.equal(g03.subject, 'Ihre Terminanfrage bei Salon Müller')
assert.ok(g03.html.includes(`termin.html#m=${verwaltungsToken(buchung.$id)}`))
assert.equal('icalEvent' in g03, false)
const k20 = sink.sent[2]
assert.equal(k20.subject, 'Neue Terminanfrage: Besichtigung am 12.10.2026 um 10:00')
assert.equal('icalEvent' in k20, false)
assert.equal('bcc' in k20, false)
})
test('bestaetige: ein zweiter Klick aendert nichts und schickt nichts, er meldet schon', async () => {
const ctx = await speichere()
const { id, token, buchung } = await gebucht(ctx)
const mails = sink.sent.length
const geaendert = buchung.updatedAt
assert.deepEqual(await bestaetige(token, plus(JETZT, 20 * MIN)), { status: 'bestaetigt', schon: true })
assert.deepEqual(await bestaetige(token, plus(JETZT, 3 * 60 * MIN)), { status: 'bestaetigt', schon: true }, 'auch nach Ablauf der Haltezeit')
await warteAufVersand()
assert.equal(sink.sent.length, mails)
assert.equal(aw.docs('termineBookings').get(id).updatedAt, geaendert)
const anfrage = await haltenMitToken(ctx, { leistung: S_ANFRAGE, zeit: '12:00', email: 'zwei@example.com' })
await bestaetige(anfrage.token, JETZT)
assert.deepEqual(await bestaetige(anfrage.token, JETZT), { status: 'angefragt', schon: true })
})
test('bestaetige: zwei gleichzeitige Klicks ergeben genau eine Bestaetigung und je eine Mail', async () => {
const ctx = await speichere()
const { token } = await haltenMitToken(ctx)
const ergebnisse = await Promise.all([bestaetige(token, JETZT), bestaetige(token, JETZT), bestaetige(token, JETZT)])
await warteAufVersand()
assert.equal(ergebnisse.filter((e) => !e.schon).length, 1)
assert.equal(ergebnisse.filter((e) => e.schon === true).length, 2)
assert.deepEqual(eintraege().map((d) => d.art).sort(), ['G01', 'G02', 'K20'])
})
test('bestaetige: nach 31 Minuten ist die Reservierung abgelaufen (410 mit Betriebsdaten), die Haltezeit endet genau bei heldUntil', async () => {
const ctx = await speichere()
const { buchung, token } = await haltenMitToken(ctx)
const mails = sink.sent.length
for (const wann of [plus(JETZT, 30 * MIN), plus(JETZT, 31 * MIN)]) {
const err = await fehlerVon(() => bestaetige(token, wann))
assert.equal(err.status, 410)
assert.equal(err.code, 'abgelaufen')
assert.deepEqual(err.betrieb, { name: 'Salon Müller', telefon: '06301 1234', websiteUrl: 'https://salon.de/' })
assert.ok(err.fehler._)
}
assert.equal(aw.docs('termineBookings').get(buchung.$id).status, 'gehalten', 'das Verfallen erledigt der Minutenjob')
await warteAufVersand()
assert.equal(sink.sent.length, mails)
// eine Minute vor Ablauf geht es noch
assert.deepEqual(await bestaetige(token, plus(JETZT, 29 * MIN)), { status: 'bestaetigt' })
})
test('bestaetige: hat ein anderer Gast die Zellen einer abgelaufenen Haltung uebernommen, ist die Bestaetigung ein 410 und die Buchung verfallen (keine zwei Bestaetigungen auf einer Zeit)', async () => {
const ctx = await speichere()
const a = await haltenMitToken(ctx, { ressource: R1, email: 'eins@example.com' })
const ablauf = plus(JETZT, 30 * MIN) // heldUntil von A
// B haelt dieselbe Zeit genau zum Ablauf von A: A gilt fuer B als abgelaufen, B loescht A's Zellen und belegt sie
const b = await haltenMitToken(ctx, { ressource: R1, email: 'zwei@example.com' }, ablauf)
assert.equal(claims(a.buchung.$id).length, 0, 'B hat die Zellen von A uebernommen')
assert.equal(claims(b.buchung.$id).length, 10)
const mailsVorher = sink.sent.length
// A's Anfrage lief noch eine Millisekunde vor heldUntil: ohne eigene Zellen darf sie nichts bestaetigen
const err = await fehlerVon(() => bestaetige(a.token, new Date(ablauf.getTime() - 1)))
await warteAufVersand()
assert.equal(err.status, 410)
assert.equal(err.code, 'abgelaufen')
assert.deepEqual(err.betrieb, { name: 'Salon Müller', telefon: '06301 1234', websiteUrl: 'https://salon.de/' })
assert.equal(aw.docs('termineBookings').get(a.buchung.$id).status, 'verfallen')
assert.equal(aw.docs('termineBookings').get(a.buchung.$id).manageHash, undefined)
assert.equal(aw.docs('termineBookings').get(a.buchung.$id).bestaetigtAt, undefined)
assert.equal(claims(a.buchung.$id).length, 0)
assert.equal(claims(b.buchung.$id).length, 10, 'B behaelt alle Zellen')
assert.equal(sink.sent.length, mailsVorher, 'keine Bestaetigungsmails an A und den Betrieb')
assert.equal((await fehlerVon(() => bestaetige(a.token, new Date(ablauf.getTime() - 1)))).status, 410, 'auch ein weiterer Klick')
// B kann bestaetigen; es gibt genau eine bestaetigte Buchung auf dieser Zeit
assert.deepEqual(await bestaetige(b.token, plus(ablauf, MIN)), { status: 'bestaetigt' })
await warteAufVersand()
assert.deepEqual(buchungen().map((x) => x.status).sort(), ['bestaetigt', 'verfallen'])
assert.equal(claims(b.buchung.$id).length, 10)
})
test('bestaetige: fehlen nur die Zellen der eigenen Haltung (niemand sonst hat sie), werden sie neu angelegt und die Bestaetigung gilt', async () => {
const ctx = await speichere()
const { buchung, token } = await haltenMitToken(ctx)
for (const zelle of claims(buchung.$id)) await deleteDocument('termineClaims', zelle.$id)
assert.equal(claims(buchung.$id).length, 0)
assert.deepEqual(await bestaetige(token, plus(JETZT, 5 * MIN)), { status: 'bestaetigt' })
await warteAufVersand()
assert.equal(claims(buchung.$id).length, 10)
assert.ok(claims(buchung.$id).every((z) => z.ressourceId === buchung.ressourceId && iso(z.bis) === iso(buchung.blockEndsAt)))
assert.deepEqual(eintraege().map((d) => d.art).sort(), ['G01', 'G02', 'K20'])
})
test('bestaetige: verfallene Buchungen, unbekannte und fremde Token ergeben 404/410, ein Verwaltungstoken bestaetigt nichts', async () => {
const ctx = await speichere()
const { buchung, token } = await haltenMitToken(ctx)
aw.seed('termineBookings', { ...aw.docs('termineBookings').get(buchung.$id), status: 'verfallen' })
assert.equal((await fehlerVon(() => bestaetige(token, JETZT))).status, 410)
for (const falsch of ['', 'x', 'A'.repeat(43), 'A'.repeat(300), null, undefined, 42, { a: 1 }, verwaltungsToken(buchung.$id)]) {
const err = await fehlerVon(() => bestaetige(falsch, JETZT))
assert.equal(err.status, 404, String(falsch).slice(0, 20))
assert.ok(err.fehler._)
}
const zwei = await haltenMitToken(ctx, { zeit: '12:00', email: 'zwei@example.com' })
await bestaetige(zwei.token, JETZT)
assert.equal((await fehlerVon(() => bestaetige(verwaltungsToken(zwei.buchung.$id), JETZT))).status, 404, 'Verwaltungstoken ist kein Bestaetigungstoken')
})
test('bestaetige und vorschau: bei Modus off oder fehlender Einstellung gibt es nichts (404)', async () => {
const ctx = await speichere()
const { token } = await haltenMitToken(ctx)
config.termine.mode = 'off'
assert.equal((await fehlerVon(() => bestaetige(token, JETZT))).status, 404)
assert.equal((await fehlerVon(() => vorschau(token, JETZT))).status, 404)
config.termine.mode = 'pilot'
assert.equal((await vorschau(token, JETZT)).art, 'bestaetigen')
})
test('bestaetige: scheitert die Mail, bleibt die Bestaetigung bestehen', async () => {
const ctx = await speichere()
const { buchung, token } = await haltenMitToken(ctx)
const echt = globalThis.fetch
globalThis.fetch = async (url, optionen) => {
if (/\/collections\/termineOutbox\/documents/.test(String(url)) && optionen?.method === 'POST') {
return new Response(JSON.stringify({ message: 'Server error', code: 500, type: 'general_server_error' }), { status: 500, headers: { 'content-type': 'application/json' } })
}
return echt(url, optionen)
}
let erg
try {
erg = await bestaetige(token, JETZT)
} finally {
globalThis.fetch = echt
}
assert.deepEqual(erg, { status: 'bestaetigt' })
assert.equal(aw.docs('termineBookings').get(buchung.$id).status, 'bestaetigt')
const protokoll = JSON.stringify(log.error.mock.calls.map((c) => c.arguments))
assert.match(protokoll, /G02/)
assert.match(protokoll, /K20/)
assert.doesNotMatch(protokoll, /max@example\.com|Mustermann/)
})
// ---------------------------------------------------------------- Vorschau
test('vorschau (Bestaetigungstoken): Zusammenfassung, Knopf wie im Widget (bestaetigen), nur diese Aktion, kein Gast, keine internen IDs', async () => {
const ctx = await speichere()
const { buchung, token } = await haltenMitToken(ctx)
const v = await vorschau(token, plus(JETZT, MIN))
assert.deepEqual(v, {
art: 'bestaetigen',
status: 'gehalten',
termin: {
nummer: buchungsNummer(buchung.$id),
leistung: 'Damenschnitt',
person: v.termin.person,
datum: 'Montag, 26.10.2026',
zeit: '10:00',
dauer: '45 Minuten',
preis: '45,00 € (inkl. USt)',
zahlung: 'Bezahlung vor Ort: bar, EC-Karte',
ort: 'Hauptstr. 1, 67722 Winnweiler',
},
betrieb: {
name: 'Salon Müller',
telefon: '06301 1234',
adresse: 'Hauptstr. 1, 67722 Winnweiler',
impressumUrl: 'https://salon.de/impressum',
datenschutzUrl: 'https://salon.de/datenschutz',
websiteUrl: 'https://salon.de/',
},
gast: null,
anrede: 'sie',
farbe: '#7c3aed',
knopf: 'Zahlungspflichtig bestätigen',
aktionen: { bestaetigen: true, absagen: false, verschieben: false, widerrufen: false },
hinweis: '',
})
assert.ok(['Anna', 'Ben'].includes(v.termin.person), 'mehrere Ressourcen => Person wird gezeigt')
const text = JSON.stringify(v)
for (const intern of [FEATURE, KUNDE, PROJEKT, buchung.$id, 'featureId', 'customerId', 'projectId', '$id', 'verifyHash', 'manageHash', 'info@salon.de']) {
assert.ok(!text.includes(intern), `${intern} darf nicht in der Antwort stehen`)
}
assert.equal(buchungen()[0].status, 'gehalten', 'die Vorschau aendert nichts')
})
test('vorschau: Knopftexte der Bestaetigungsseite je Leistung; ohne Preis und mit Kleinunternehmer-Hinweis', async () => {
const ctx = await speichere(einstellung({ betrieb: { ...eingabeEinstellung().betrieb, kleinunternehmer: true } }))
const a = await haltenMitToken(ctx)
const b = await haltenMitToken(ctx, { leistung: S_ANFRAGE, zeit: '12:00', email: 'zwei@example.com' })
const c = await haltenMitToken(ctx, { leistung: S_GRATIS, zeit: '14:00', email: 'drei@example.com' })
const [va, vb, vc] = await Promise.all([a, b, c].map((x) => vorschau(x.token, JETZT)))
assert.deepEqual([va.knopf, vb.knopf, vc.knopf], ['Zahlungspflichtig bestätigen', 'Anfrage absenden', 'Termin bestätigen'])
assert.equal(va.termin.preis, '45,00 € (kein Ausweis der USt nach § 19 UStG)')
assert.deepEqual([vb.termin.preis, vb.termin.zahlung, vc.termin.preis, vc.termin.zahlung], ['', '', '', ''])
assert.equal(vb.termin.dauer, '1 Stunde')
})
test('vorschau: einen Termin einer einzigen Ressource zeigt ohne Person; der Preis stammt von der Buchung, nicht von der aktuellen Einstellung', async () => {
const einzel = einstellung({
ressourcen: [{ id: R1, name: 'Anna', plan: { 1: [['09:00', '18:00']] } }],
leistungen: [{ id: S_SOFORT, name: 'Damenschnitt', dauer: 45, preisArt: 'fest', preisCent: 4500, ressourcen: [R1] }],
})
const ctx = await speichere(einzel)
const { token } = await haltenMitToken(ctx, { ressource: R1 })
assert.equal((await vorschau(token, JETZT)).termin.person, '')
// Betrieb erhoeht den Preis und benennt die Leistung um: der Gast bestaetigt, was er gebucht hat
const neu = einstellung({
ressourcen: einzel.ressourcen.map((r) => ({ ...r })),
leistungen: [{ id: S_SOFORT, name: 'Damenschnitt neu', dauer: 45, preisArt: 'fest', preisCent: 9900, ressourcen: [R1] }],
})
await speichere(neu)
const v = await vorschau(token, JETZT)
assert.equal(v.termin.preis, '45,00 € (inkl. USt)')
assert.equal(v.termin.leistung, 'Damenschnitt')
})
test('vorschau: abgelaufene oder verfallene Reservierung und unbekannte Token ergeben 404', async () => {
const ctx = await speichere()
const { buchung, token } = await haltenMitToken(ctx)
for (const wann of [plus(JETZT, 30 * MIN), plus(JETZT, 2 * 60 * MIN)]) {
const err = await fehlerVon(() => vorschau(token, wann))
assert.equal(err.status, 404)
assert.equal(err.code, 'abgelaufen')
assert.equal(err.betrieb.name, 'Salon Müller')
}
aw.seed('termineBookings', { ...aw.docs('termineBookings').get(buchung.$id), status: 'verfallen' })
assert.equal((await fehlerVon(() => vorschau(token, JETZT))).status, 404)
for (const falsch of ['', 'A'.repeat(43), 'x'.repeat(500), null, 7]) {
const err = await fehlerVon(() => vorschau(falsch, JETZT))
assert.equal(err.status, 404)
assert.equal(err.code, 'unbekannt')
assert.equal(err.betrieb, undefined)
}
})
test('vorschau: nach der Bestaetigung zeigt das Bestaetigungstoken den Stand ohne Aktionen', async () => {
const ctx = await speichere()
const { token } = await gebucht(ctx)
const v = await vorschau(token, plus(JETZT, 3 * 60 * MIN))
assert.equal(v.art, 'bestaetigen')
assert.equal(v.status, 'bestaetigt')
assert.deepEqual(v.aktionen, { bestaetigen: false, absagen: false, verschieben: false, widerrufen: false })
assert.equal(v.gast, null)
})
test('vorschau mit Verwaltungstoken liefert gast und die Aktionen nach Lage (absagen, verschieben, widerrufen)', async () => {
const ctx = await speichere()
const { buchung, verwaltung } = await gebucht(ctx)
const v = await vorschau(verwaltung, plus(JETZT, 3 * 60 * MIN))
assert.equal(v.art, 'verwalten')
assert.equal(v.status, 'bestaetigt')
assert.deepEqual(v.gast, { name: 'Max Mustermann', email: 'max@example.com' })
assert.deepEqual(v.aktionen, { bestaetigen: false, absagen: true, verschieben: true, widerrufen: true })
assert.equal(v.hinweis, '')
assert.equal(v.termin.nummer, buchungsNummer(buchung.$id))
assert.equal(v.knopf, '')
assert.ok(!JSON.stringify(v).includes(buchung.$id))
// Vertrag am Montag 05.10.: die Frist endet mit Ablauf des Montags 19.10. (Berliner Zeit, 23:59:59,999 MESZ = 21:59:59,999 UTC);
// danach (Termin liegt 21 Tage nach der Buchung) kein Widerruf mehr, Absagen geht
const fristEnde = new Date('2026-10-19T21:59:59.999Z')
const nach14 = await vorschau(verwaltung, plus(fristEnde, 1))
assert.deepEqual([nach14.aktionen.widerrufen, nach14.aktionen.absagen], [false, true])
assert.equal((await vorschau(verwaltung, fristEnde)).aktionen.widerrufen, true)
// 24 Stunden vor Beginn geht das Absagen noch, eine Minute spaeter nicht mehr (Absagefrist 24 h); Beginn: keine Aktion
const start = new Date(buchung.startsAt)
assert.equal((await vorschau(verwaltung, plus(start, -24 * 60 * MIN))).aktionen.absagen, true)
const knapp = await vorschau(verwaltung, plus(start, -24 * 60 * MIN + 1))
assert.deepEqual([knapp.aktionen.absagen, knapp.aktionen.verschieben], [false, false])
assert.equal(knapp.hinweis, 'Absage nur telefonisch: 06301 1234')
const begonnen = await vorschau(verwaltung, plus(start, 10 * MIN))
assert.deepEqual(begonnen.aktionen, { bestaetigen: false, absagen: false, verschieben: false, widerrufen: false })
assert.equal(begonnen.hinweis, '')
})
test('vorschau (Verwaltung): kurz vor dem Termin bleibt der Widerruf innerhalb der 14 Tage, die Absage ist nur telefonisch', async () => {
const ctx = await speichere()
const { buchung, verwaltung } = await gebucht(ctx, { tag: TAG_NAH, zustimmungen: { storno: true, vorzeitig: true } })
const spaet = await vorschau(verwaltung, plus(new Date(buchung.startsAt), -23 * 60 * MIN))
assert.deepEqual(spaet.aktionen, { bestaetigen: false, absagen: false, verschieben: false, widerrufen: true })
assert.equal(spaet.hinweis, 'Absage nur telefonisch: 06301 1234')
})
test('vorschau (Verwaltung): kein Widerruf fuer Unternehmer, Freizeit, Anfragen und Handbuchungen; Absagen auch bei Anfragen', async () => {
const unternehmer = await speichere(einstellung({ recht: { gaesteSind: 'unternehmer', freizeit: false } }))
const u = await gebucht(unternehmer)
assert.deepEqual((await vorschau(u.verwaltung, JETZT)).aktionen, { bestaetigen: false, absagen: true, verschieben: true, widerrufen: false })
const ctx = await speichere()
const anfrage = await gebucht(ctx, { leistung: S_ANFRAGE, zeit: '12:00', email: 'zwei@example.com' })
assert.equal(anfrage.erg.status, 'angefragt')
assert.deepEqual((await vorschau(anfrage.verwaltung, JETZT)).aktionen, { bestaetigen: false, absagen: true, verschieben: true, widerrufen: false })
const manuell = await gebucht(ctx, { zeit: '14:00', email: 'drei@example.com' })
aw.seed('termineBookings', { ...manuell.buchung, quelle: 'manuell' })
assert.equal((await vorschau(manuell.verwaltung, JETZT)).aktionen.widerrufen, false)
})
test('vorschau (Verwaltung): abgesagte und beendete Termine bieten nichts an; das Token gilt bis Terminende + 1 Tag', async () => {
const ctx = await speichere()
const { id, buchung, verwaltung } = await gebucht(ctx)
const ende = new Date(buchung.endsAt)
const abgesagt = { ...aw.docs('termineBookings').get(id), status: 'abgesagt' }
aw.seed('termineBookings', abgesagt)
const v = await vorschau(verwaltung, JETZT)
assert.equal(v.status, 'abgesagt')
assert.deepEqual(v.aktionen, { bestaetigen: false, absagen: false, verschieben: false, widerrufen: false })
assert.equal((await vorschau(verwaltung, plus(ende, 24 * 60 * MIN - 1))).art, 'verwalten')
for (const wann of [plus(ende, 24 * 60 * MIN), plus(ende, 5 * 24 * 60 * MIN)]) {
const err = await fehlerVon(() => vorschau(verwaltung, wann))
assert.equal(err.status, 404)
assert.equal(err.code, 'abgelaufen')
}
})
test('vorschau: ein Verwaltungstoken einer anderen Buchung oder mit geaendertem Geheimnis findet nichts', async () => {
const ctx = await speichere()
const { verwaltung } = await gebucht(ctx)
assert.equal((await vorschau(verwaltung, JETZT)).art, 'verwalten')
assert.equal((await fehlerVon(() => vorschau(verwaltung.slice(0, -1) + (verwaltung.at(-1) === 'A' ? 'B' : 'A'), JETZT))).status, 404)
assert.equal((await fehlerVon(() => vorschau(verwaltungsToken('andere-id'), JETZT))).status, 404)
})
test('Du-Betrieb: Anrede in der Vorschau, G01 in der Du-Form', async () => {
const ctx = await speichere(einstellung({ anrede: 'du' }))
const { token, mail } = await haltenMitToken(ctx)
assert.equal(mail.subject, 'Bitte bestätige deinen Termin bei Salon Müller')
assert.equal((await vorschau(token, JETZT)).anrede, 'du')
assert.equal(JSON.parse(buchungen()[0].nachweis).anrede, 'du')
})
test('Aufrufe ohne gueltige Uhr oder ohne Kontext werfen TypeError statt zu raten', async () => {
const ctx = await speichere()
await assert.rejects(() => halte(ctx, eingabe(), undefined), TypeError)
await assert.rejects(() => halte(ctx, eingabe(), '2026-10-05'), TypeError)
await assert.rejects(() => halte({ feature: feature(), einstellung: null }, eingabe(), JETZT), TypeError)
await assert.rejects(() => vorschau('A'.repeat(43), undefined), TypeError)
await assert.rejects(() => bestaetige('A'.repeat(43), undefined), TypeError)
assert.equal(buchungen().length, 0)
})