Terminplaner: Aktionen des Betriebs, manuelle Termine, Sperren

Neues Modul ablaufBetrieb.js: aktion (annehmen, ablehnen, absagen, verschieben,
nichterschienen, anonymisieren), manuell, sperre, entsperre. ablauf.js exportiert
die gemeinsamen Bausteine (Pruefungen, Meldungen, mailSicher, pruefeSlot,
verschiebeKern). R31: brauchtZustimmungVorzeitig folgt der Berliner Widerrufsfrist
(withdrawalDeadline ab now + Haltezeit); bei einer Anfrage mit Preis immer.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-03 08:17:43 +00:00
1 parent c4ec24448e
commit be49d38e0c
8 files changed
+1563 -79

No files matched your search

+943
View File
@@ -0,0 +1,943 @@
import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { mailSink } from './support/mailSink.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
// Betrieb im Portal: Aktionen auf Buchungen (annehmen, ablehnen, absagen, verschieben, nichterschienen, anonymisieren),
// Termine von Hand und Sperren. Aufbau wie test/termineAblaufVerwalten.test.js: Nachbau von Appwrite mit Schema
// (Standard-Limit 25), Mails 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 { resetTermineLimitsForTests } = await import('../server/services/termine/limits.js')
const { halte, vorschau, bestaetige, kannWiderrufen, verschiebeGast } = await import('../server/services/termine/ablauf.js')
const { aktion, manuell, sperre, entsperre } = await import('../server/services/termine/ablaufBetrieb.js')
after(() => setMailTransportForTests(null))
const MIN = 60000
const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin (MESZ)
const TAG_NAH = '2026-10-12' // Montag, 7 Tage spaeter: Beginn innerhalb der Widerrufsfrist
const TAG_FERN = '2026-10-26' // Montag, 21 Tage spaeter (MEZ)
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 + 5 Minuten, 45 Euro, sofort
const S_ANFRAGE = 's_bbbbbbb2' // Besichtigung, 60 Minuten, ohne Preis, Anfrage
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 plus = (date, ms) => new Date(new Date(date).getTime() + ms)
const feature = (id = FEATURE) => ({ $id: id, 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] },
],
regeln: { raster: 15, vorlaufMin: 120, horizontTage: 90, absageBisStd: 24, maxOffeneJeEmail: 3, personWaehlbar: true },
gastFelder: { telefon: 'optional', nachricht: 'optional', zusatz: [{ key: 'marke', label: 'Marke', typ: 'text', pflicht: false }] },
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(), id = FEATURE) => ({ feature: feature(id), project: { $id: PROJEKT, subdomain: 'salon' }, einstellung: e, version: 1 })
/** Gastformular wie in den Gast-Tests: Montag in 3 Wochen, 10:00, Verbraucher, Stornohaken gesetzt. */
const eingabe = (over = {}) => ({
leistung: S_SOFORT,
ressource: R1,
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,
})
/** Eingabe fuer einen Termin von Hand (keine Zustimmungen). */
const handEingabe = (over = {}) => ({
leistung: S_SOFORT,
ressource: R1,
tag: TAG_FERN,
zeit: '10:00',
name: 'Paula Telefon',
email: '',
telefon: '06301 555',
nachricht: '',
zusatz: {},
bestaetigungSenden: 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()
resetTermineLimitsForTests()
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 buchung = (id) => aw.docs('termineBookings').get(id)
const claims = (bookingId) => [...aw.docs('termineClaims').values()].filter((c) => c.bookingId === bookingId)
const eintraege = () => [...aw.docs('termineOutbox').values()]
const arten = () => eintraege().map((d) => d.art).sort()
const entfalte = (ics) => ics.replace(/\r\n /g, '')
const claimsStand = () => JSON.stringify([...aw.docs('termineClaims').entries()])
const an = (liste, adresse) => liste.filter((m) => m.to.some((t) => t.address === adresse))
const zeitenIso = (zellen) => zellen.map((c) => iso(c.zelle)).sort()
const linkIn = (mail) => /termin\.html#m=([A-Za-z0-9_-]{43})/.exec(mail.text)?.[1]
/** 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 und bestaetigt einen Termin; => { id, buchung (frisch gelesen), verwaltung (Verwaltungstoken), token, mailStart }. */
async function gebucht(ctx, over = {}, now = JETZT) {
await warteAufVersand() // Mails frueherer Aufrufe sind raus, bevor der Index der neuen gilt
const vorher = sink.sent.length
const erg = await halte(ctx, eingabe(over), now)
await warteAufVersand()
assert.equal(erg.status, 'gehalten')
const token = /termin\.html#v=([A-Za-z0-9_-]{43})/.exec(sink.sent[vorher].text)[1]
const gehalten = buchungen().find((b) => b.verifyHash === hashToken(token))
assert.ok(gehalten, 'Buchung zum Token')
const mailStart = sink.sent.length
await bestaetige(token, now)
await warteAufVersand()
return { id: gehalten.$id, buchung: buchung(gehalten.$id), verwaltung: verwaltungsToken(gehalten.$id), token, mailStart }
}
/** Fehler im Postausgang simulieren: jede neue Mail scheitert beim Einreihen (Appwrite 500). */
async function mitDefektemPostausgang(fn) {
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)
}
try {
return await fn()
} finally {
globalThis.fetch = echt
}
}
// ---------------------------------------------------------------- annehmen
test('annehmen: angefragt => bestaetigt mit angenommenAt, G02 (Block anfrage_angenommen) mit Einladung und demselben Verwaltungslink wie G03', async () => {
const ctx = await speichere()
const { id, verwaltung } = await gebucht(ctx, { leistung: S_ANFRAGE })
assert.equal(buchung(id).status, 'angefragt')
const g03 = an(sink.sent, 'max@example.com').find((m) => /Terminanfrage/.test(m.subject))
assert.equal(linkIn(g03), verwaltung)
const standZellen = claimsStand()
const vorher = sink.sent.length
const jetzt = plus(JETZT, 60 * MIN)
assert.deepEqual(await aktion(ctx, id, { aktion: 'annehmen' }, jetzt), { status: 'bestaetigt' })
await warteAufVersand()
const nachher = buchung(id)
assert.equal(nachher.status, 'bestaetigt')
assert.equal(iso(nachher.angenommenAt), iso(jetzt))
assert.equal(nachher.bestaetigtAt, undefined, 'der Vertrag kommt bei einer Anfrage erst mit der Annahme zustande')
assert.equal(iso(nachher.updatedAt), iso(jetzt))
assert.equal(claimsStand(), standZellen, 'die Zellen bleiben')
assert.equal(kannWiderrufen(nachher, plus(jetzt, 2 * 24 * 60 * MIN)), true, 'die Widerrufsfrist laeuft ab der Annahme')
const neu = sink.sent.slice(vorher)
assert.equal(neu.length, 1, 'nur G02, kein K20/K21')
const g02 = neu[0]
assert.equal(g02.to[0].address, 'max@example.com')
assert.equal(g02.subject, 'Ihr Termin bei Salon Müller am 26.10.2026')
assert.match(g02.text, /Salon Müller hat Ihre Terminanfrage angenommen/)
assert.equal(linkIn(g02), verwaltung, 'derselbe Verwaltungslink wie in G03')
assert.equal(g02.icalEvent.method, 'REQUEST')
const ics = entfalte(g02.icalEvent.content)
assert.match(ics, /SEQUENCE:0\r\n/)
assert.match(ics, new RegExp(`UID:${id}@termine.webklar.com`))
assert.deepEqual(arten(), ['G01', 'G02', 'G03', 'K20'])
// der Link aus der Mail funktioniert: Verwaltung zeigt den bestaetigten Termin
assert.equal((await vorschau(verwaltung, jetzt)).status, 'bestaetigt')
})
test('annehmen: zweiter Klick meldet schon; nur Anfragen; eine Anfrage mit vergangenem Beginn nicht; nichts wird gesendet', async () => {
const ctx = await speichere()
const { id } = await gebucht(ctx, { leistung: S_ANFRAGE })
await aktion(ctx, id, { aktion: 'annehmen' }, JETZT)
await warteAufVersand()
const mails = sink.sent.length
assert.deepEqual(await aktion(ctx, id, { aktion: 'annehmen' }, plus(JETZT, MIN)), { status: 'bestaetigt', schon: true })
await warteAufVersand()
assert.equal(sink.sent.length, mails)
// eine sofort gebuchte Leistung ist keine Anfrage
const sofort = await gebucht(ctx, { email: 'zwei@example.com', zeit: '12:00' })
const err = await fehlerVon(() => aktion(ctx, sofort.id, { aktion: 'annehmen' }, JETZT))
assert.equal(err.status, 409)
assert.equal(err.code, 'nicht_moeglich')
assert.deepEqual(err.fehler, { _: 'Diese Anfrage ist nicht mehr offen.' })
// Beginn schon vorbei (der Minutenjob hat die Anfrage noch nicht abgelehnt)
const alt = await gebucht(ctx, { leistung: S_ANFRAGE, email: 'drei@example.com', zeit: '14:00' })
const spaet = plus(new Date(alt.buchung.startsAt), MIN)
const vorbei = await fehlerVon(() => aktion(ctx, alt.id, { aktion: 'annehmen' }, spaet))
assert.equal(vorbei.status, 409)
assert.equal(vorbei.code, 'vergangen')
assert.equal(buchung(alt.id).status, 'angefragt')
})
// ---------------------------------------------------------------- ablehnen
test('ablehnen: abgelehnt, Zellen frei, G04 mit der Nachricht des Betriebs; die Zeit ist wieder buchbar', async () => {
const ctx = await speichere()
const { id } = await gebucht(ctx, { leistung: S_ANFRAGE })
assert.equal(claims(id).length, 12)
const vorher = sink.sent.length
const jetzt = plus(JETZT, 30 * MIN)
assert.deepEqual(await aktion(ctx, id, { aktion: 'ablehnen', nachricht: 'Am Montag sind wir im Urlaub.' }, jetzt), { status: 'abgelehnt' })
await warteAufVersand()
assert.equal(buchung(id).status, 'abgelehnt')
assert.equal(iso(buchung(id).updatedAt), iso(jetzt))
assert.equal(claims(id).length, 0)
const neu = sink.sent.slice(vorher)
assert.equal(neu.length, 1)
assert.equal(neu[0].to[0].address, 'max@example.com')
assert.equal(neu[0].subject, 'Ihre Terminanfrage bei Salon Müller')
assert.match(neu[0].text, /leider kann Salon Müller Ihre Terminanfrage nicht annehmen/)
assert.match(neu[0].text, /Am Montag sind wir im Urlaub\./)
assert.equal('icalEvent' in neu[0], false)
assert.equal((await halte(ctx, eingabe({ leistung: S_ANFRAGE, email: 'zwei@example.com' }), jetzt)).status, 'gehalten')
assert.deepEqual(await aktion(ctx, id, { aktion: 'ablehnen' }, plus(jetzt, MIN)), { status: 'abgelehnt', schon: true })
})
test('ablehnen: ohne Nachricht kein Nachrichtenblock; bestaetigte Termine lassen sich nicht ablehnen', async () => {
const ctx = await speichere()
const { id } = await gebucht(ctx, { leistung: S_ANFRAGE })
const vorher = sink.sent.length
await aktion(ctx, id, { aktion: 'ablehnen' }, JETZT)
await warteAufVersand()
assert.doesNotMatch(sink.sent[vorher].text, /Nachricht von/i)
const sofort = await gebucht(ctx, { email: 'zwei@example.com', zeit: '12:00' })
const err = await fehlerVon(() => aktion(ctx, sofort.id, { aktion: 'ablehnen' }, JETZT))
assert.equal(err.code, 'nicht_moeglich')
assert.equal(buchung(sofort.id).status, 'bestaetigt')
assert.equal(claims(sofort.id).length, 10)
})
// ---------------------------------------------------------------- absagen
test('absagen: abgesagt von betrieb mit Grund, Zellen frei, G06 mit Betriebsname, Grund, Nachricht und Kalenderabsage; kein K21; keine Frist', async () => {
const ctx = await speichere()
const { id, buchung: b, mailStart } = await gebucht(ctx)
assert.equal(claims(id).length, 10)
// eine Stunde vor dem Termin: der Gast koennte nicht mehr absagen, der Betrieb schon
const jetzt = plus(new Date(b.startsAt), -60 * MIN)
assert.deepEqual(await aktion(ctx, id, { aktion: 'absagen', grund: 'Krankheit', nachricht: 'Gute Besserung wuenschen wir.' }, jetzt), { status: 'abgesagt' })
await warteAufVersand()
const nachher = buchung(id)
assert.equal(nachher.status, 'abgesagt')
assert.equal(nachher.abgesagtVon, 'betrieb')
assert.equal(nachher.absageGrund, 'Krankheit')
assert.equal(iso(nachher.abgesagtAt), iso(jetzt))
assert.equal(nachher.widerrufenAt, undefined)
assert.equal(claims(id).length, 0)
const neu = sink.sent.slice(mailStart + 2) // G02 und K20 der Bestaetigung ausgenommen
assert.equal(neu.length, 1, 'nur G06 an den Gast, kein K21')
const g06 = neu[0]
assert.equal(g06.to[0].address, 'max@example.com')
assert.equal(g06.subject, 'Ihr Termin am 26.10.2026 wurde abgesagt')
assert.match(g06.text, /Salon Müller/)
assert.doesNotMatch(g06.text, /Sie selbst/)
assert.match(g06.text, /Krankheit/)
assert.match(g06.text, /Gute Besserung wuenschen wir\./)
assert.equal(g06.icalEvent.method, 'CANCEL')
assert.match(entfalte(g06.icalEvent.content), /SEQUENCE:1\r\n/)
assert.deepEqual(arten(), ['G01', 'G02', 'G06', 'K20'])
assert.deepEqual(await aktion(ctx, id, { aktion: 'absagen', grund: 'Krankheit' }, plus(jetzt, MIN)), { status: 'abgesagt', schon: true })
})
test('absagen: Grund ist Pflicht (400 Feld grund), zu lange Texte werden abgelehnt, nichts aendert sich', async () => {
const ctx = await speichere()
const { id } = await gebucht(ctx)
const standZellen = claimsStand()
const vorher = sink.sent.length
for (const [feld, roh] of [
['grund', { aktion: 'absagen' }],
['grund', { aktion: 'absagen', grund: ' ' }],
['grund', { aktion: 'absagen', grund: 7 }],
['grund', { aktion: 'absagen', grund: 'x'.repeat(101) }],
['nachricht', { aktion: 'absagen', grund: 'Krankheit', nachricht: 'y'.repeat(501) }],
]) {
const err = await fehlerVon(() => aktion(ctx, id, roh, JETZT))
assert.equal(err.status, 400, JSON.stringify(roh).slice(0, 60))
assert.deepEqual(Object.keys(err.fehler), [feld])
}
assert.equal(buchung(id).status, 'bestaetigt')
assert.equal(claimsStand(), standZellen)
await warteAufVersand()
assert.equal(sink.sent.length, vorher)
})
test('absagen: eine Anfrage (nie eine Einladung erhalten) bekommt G06 ohne Kalenderdatei (R21); erledigte Termine lassen sich nicht absagen', async () => {
const ctx = await speichere()
const { id } = await gebucht(ctx, { leistung: S_ANFRAGE })
const vorher = sink.sent.length
await aktion(ctx, id, { aktion: 'absagen', grund: 'Betriebsstoerung' }, JETZT)
await warteAufVersand()
const g06 = sink.sent.slice(vorher)[0]
assert.equal(g06.subject, 'Ihr Termin am 26.10.2026 wurde abgesagt')
assert.equal('icalEvent' in g06, false)
assert.equal(buchung(id).status, 'abgesagt')
assert.equal(claims(id).length, 0)
const zwei = await gebucht(ctx, { email: 'zwei@example.com', zeit: '12:00' })
aw.seed('termineBookings', { ...buchung(zwei.id), status: 'erledigt' })
const err = await fehlerVon(() => aktion(ctx, zwei.id, { aktion: 'absagen', grund: 'Krankheit' }, JETZT))
assert.equal(err.status, 409)
assert.equal(err.code, 'nicht_moeglich')
})
// ---------------------------------------------------------------- verschieben
test('verschieben: wie beim Gast (10:00 -> 10:30 ueberlappend), Sequenz + 1, neue Zellen, G07 mit Einladung, kein K21', async () => {
const ctx = await speichere()
const { id, buchung: alt, mailStart } = await gebucht(ctx)
const altZellen = zeitenIso(claims(id))
const jetzt = plus(JETZT, 60 * MIN)
const erg = await aktion(ctx, id, { aktion: 'verschieben', tag: TAG_FERN, zeit: '10:30' }, jetzt)
await warteAufVersand()
assert.equal(erg.status, 'bestaetigt')
assert.match(erg.termin.datum, /26\.10\.2026/)
assert.equal(erg.termin.zeit, '10:30')
const nachher = buchung(id)
assert.equal(iso(nachher.startsAt), '2026-10-26T09:30:00.000Z')
assert.equal(iso(nachher.endsAt), '2026-10-26T10:15:00.000Z')
assert.equal(iso(nachher.blockEndsAt), '2026-10-26T10:20:00.000Z')
assert.equal(nachher.icsSequenz, alt.icsSequenz + 1)
assert.equal(nachher.status, 'bestaetigt')
const neuZellen = zeitenIso(zellenFuer(nachher.startsAt, nachher.blockEndsAt).map((z) => ({ zelle: z })))
assert.deepEqual(zeitenIso(claims(id)), neuZellen)
assert.notDeepEqual(zeitenIso(claims(id)), altZellen)
const neu = sink.sent.slice(mailStart + 2)
assert.equal(neu.length, 1)
assert.equal(neu[0].subject, 'Ihr Termin wurde verschoben')
assert.equal(neu[0].icalEvent.method, 'REQUEST')
assert.match(entfalte(neu[0].icalEvent.content), /SEQUENCE:1\r\n/)
assert.deepEqual(arten(), ['G01', 'G02', 'G07', 'K20'])
})
test('verschieben: der Betrieb darf in den Vorlauf, ueber den Horizont und in die Widerrufsfrist, der Gast nicht', async () => {
const ctx = await speichere()
const { id, verwaltung } = await gebucht(ctx) // TAG_FERN ohne Zustimmung zum vorzeitigen Beginn
const zurueck = () => aktion(ctx, id, { aktion: 'verschieben', tag: TAG_FERN, zeit: '10:00' }, JETZT)
// Vorlauf (120 Minuten): heute 11:00
const vorlauf = await fehlerVon(() => verschiebeGast(verwaltung, { tag: '2026-10-05', zeit: '11:00' }, JETZT))
assert.equal(vorlauf.code, 'SLOT_VERGEBEN')
await aktion(ctx, id, { aktion: 'verschieben', tag: '2026-10-05', zeit: '11:00' }, JETZT)
assert.equal(iso(buchung(id).startsAt), '2026-10-05T09:00:00.000Z')
await zurueck()
// Horizont (90 Tage)
const weit = '2027-02-01'
assert.equal((await fehlerVon(() => verschiebeGast(verwaltung, { tag: weit, zeit: '10:00' }, JETZT))).code, 'SLOT_VERGEBEN')
await aktion(ctx, id, { aktion: 'verschieben', tag: weit, zeit: '10:00' }, JETZT)
assert.equal(iso(buchung(id).startsAt), '2027-02-01T09:00:00.000Z')
await zurueck()
// Termin innerhalb der Widerrufsfrist ohne Zustimmung zum vorzeitigen Beginn
assert.equal((await fehlerVon(() => verschiebeGast(verwaltung, { tag: TAG_NAH, zeit: '10:00' }, JETZT))).code, 'zustimmung_noetig')
await aktion(ctx, id, { aktion: 'verschieben', tag: TAG_NAH, zeit: '10:00' }, JETZT)
assert.equal(iso(buchung(id).startsAt), '2026-10-12T08:00:00.000Z')
assert.equal(claims(id).length, 10)
})
test('verschieben: nie in die Vergangenheit, nie auf belegte Zellen, nie auf dieselbe Zeit; alles bleibt wie es war', async () => {
const ctx = await speichere()
const { id } = await gebucht(ctx)
await gebucht(ctx, { email: 'zwei@example.com', zeit: '14:00' }) // R1 14:00-14:50
const standZellen = claimsStand()
const stand = JSON.stringify(buchung(id))
const vorher = sink.sent.length
for (const [roh, status, feld] of [
[{ tag: '2026-10-05', zeit: '09:00' }, 409, 'zeit'], // heute, vor jetzt (10:00)
[{ tag: '2026-10-04', zeit: '10:00' }, 409, 'zeit'], // gestern
[{ tag: TAG_FERN, zeit: '14:15' }, 409, 'zeit'], // ueberlappt den Termin um 14:00
[{ tag: TAG_FERN, zeit: '08:00' }, 409, 'zeit'], // vor der Oeffnung
[{ tag: '2026-10-27', zeit: '10:00' }, 409, 'zeit'], // Dienstag, kein Plan
[{ tag: TAG_FERN, zeit: '10:00' }, 400, 'zeit'], // dieselbe Zeit
[{ tag: 'kaputt', zeit: '10:00' }, 400, 'tag'],
[{ tag: TAG_FERN, zeit: '25:00' }, 400, 'zeit'],
]) {
const err = await fehlerVon(() => aktion(ctx, id, { aktion: 'verschieben', ...roh }, JETZT))
assert.equal(err.status, status, JSON.stringify(roh))
assert.deepEqual(Object.keys(err.fehler), [feld], JSON.stringify(roh))
}
assert.equal(JSON.stringify(buchung(id)), stand)
assert.equal(claimsStand(), standZellen)
await warteAufVersand()
assert.equal(sink.sent.length, vorher)
})
test('verschieben: eine Anfrage bekommt G07 ohne Kalenderdatei (R21), ein abgesagter Termin laesst sich nicht verschieben', async () => {
const ctx = await speichere()
const { id } = await gebucht(ctx, { leistung: S_ANFRAGE })
const vorher = sink.sent.length
await aktion(ctx, id, { aktion: 'verschieben', tag: TAG_FERN, zeit: '11:00' }, JETZT)
await warteAufVersand()
const g07 = sink.sent.slice(vorher)[0]
assert.equal(g07.subject, 'Ihr Termin wurde verschoben')
assert.equal('icalEvent' in g07, false)
assert.equal(buchung(id).status, 'angefragt')
await aktion(ctx, id, { aktion: 'absagen', grund: 'Krankheit' }, JETZT)
const err = await fehlerVon(() => aktion(ctx, id, { aktion: 'verschieben', tag: TAG_FERN, zeit: '12:00' }, JETZT))
assert.equal(err.code, 'nicht_moeglich')
})
// ---------------------------------------------------------------- nichterschienen, anonymisieren
test('nichterschienen: vor Beginn 409, danach nichterschienen (Zellen frei); zweiter Klick meldet schon', async () => {
const ctx = await speichere()
const { id, buchung: b } = await gebucht(ctx)
const standZellen = claimsStand()
const err = await fehlerVon(() => aktion(ctx, id, { aktion: 'nichterschienen' }, JETZT))
assert.equal(err.status, 409)
assert.equal(err.code, 'vor_beginn')
assert.deepEqual(err.fehler, { _: 'Das geht erst, wenn der Termin begonnen hat.' })
assert.equal(buchung(id).status, 'bestaetigt')
assert.equal(claimsStand(), standZellen)
assert.equal((await fehlerVon(() => aktion(ctx, id, { aktion: 'nichterschienen' }, plus(new Date(b.startsAt), -1)))).code, 'vor_beginn')
const beginn = new Date(b.startsAt)
assert.deepEqual(await aktion(ctx, id, { aktion: 'nichterschienen' }, beginn), { status: 'nichterschienen' })
assert.equal(buchung(id).status, 'nichterschienen')
assert.equal(claims(id).length, 0)
assert.deepEqual(await aktion(ctx, id, { aktion: 'nichterschienen' }, plus(beginn, MIN)), { status: 'nichterschienen', schon: true })
// auch nach dem automatischen Erledigen noch moeglich; abgesagte Termine nicht
const zwei = await gebucht(ctx, { email: 'zwei@example.com', zeit: '12:00' })
aw.seed('termineBookings', { ...buchung(zwei.id), status: 'erledigt' })
assert.deepEqual(await aktion(ctx, zwei.id, { aktion: 'nichterschienen' }, plus(new Date(zwei.buchung.endsAt), 90 * MIN)), { status: 'nichterschienen' })
const drei = await gebucht(ctx, { email: 'drei@example.com', zeit: '14:00' })
await aktion(ctx, drei.id, { aktion: 'absagen', grund: 'Krankheit' }, JETZT)
assert.equal((await fehlerVon(() => aktion(ctx, drei.id, { aktion: 'nichterschienen' }, plus(new Date(drei.buchung.startsAt), MIN)))).code, 'nicht_moeglich')
})
test('anonymisieren: Gastfelder leer ("Gelöscht"), Status und Zeiten bleiben, Links ungueltig, keine Erinnerung, keine Mail', async () => {
const ctx = await speichere()
const { id, buchung: b, verwaltung } = await gebucht(ctx, { erinnerung: true, nachricht: 'Bitte ohne Foehn.', zusatz: { marke: 'Wella' } })
assert.equal(b.erinnerung, true)
const vorher = sink.sent.length
const jetzt = plus(JETZT, 5 * 24 * 60 * MIN)
assert.deepEqual(await aktion(ctx, id, { aktion: 'anonymisieren' }, jetzt), { status: 'bestaetigt' })
await warteAufVersand()
const nachher = buchung(id)
assert.equal(nachher.gastName, 'Gelöscht')
for (const feld of ['gastEmail', 'gastTelefon', 'gastNachricht', 'gastZusatz', 'verifyHash', 'manageHash']) assert.equal(nachher[feld], '', feld)
assert.equal(nachher.erinnerung, false)
assert.equal(nachher.status, 'bestaetigt')
assert.equal(iso(nachher.startsAt), iso(b.startsAt))
assert.equal(iso(nachher.endsAt), iso(b.endsAt))
assert.equal(claims(id).length, 10)
assert.equal(sink.sent.length, vorher)
assert.equal((await fehlerVon(() => vorschau(verwaltung, jetzt))).status, 404, 'der alte Link gilt nicht mehr')
// zweiter Aufruf aendert nichts; ohne Adresse gehen weitere Aktionen ohne Mail durch
assert.deepEqual(await aktion(ctx, id, { aktion: 'anonymisieren' }, plus(jetzt, MIN)), { status: 'bestaetigt' })
await aktion(ctx, id, { aktion: 'absagen', grund: 'Krankheit' }, plus(jetzt, 2 * MIN))
await warteAufVersand()
assert.equal(sink.sent.length, vorher, 'ohne Adresse keine G06')
assert.equal(log.error.mock.callCount(), 0)
})
test('anonymisieren: bei abgesagten Terminen bleibt updatedAt (die Loeschfrist laeuft weiter); eine Haltung bleibt unberuehrt', async () => {
const ctx = await speichere()
const { id } = await gebucht(ctx)
await aktion(ctx, id, { aktion: 'absagen', grund: 'Krankheit' }, JETZT)
const abgesagtUm = iso(buchung(id).updatedAt)
await aktion(ctx, id, { aktion: 'anonymisieren' }, plus(JETZT, 10 * 24 * 60 * MIN))
assert.equal(buchung(id).status, 'abgesagt')
assert.equal(buchung(id).gastName, 'Gelöscht')
assert.equal(iso(buchung(id).updatedAt), abgesagtUm)
await halte(ctx, eingabe({ email: 'zwei@example.com', zeit: '12:00' }), JETZT)
const gehalten = buchungen().find((b) => b.status === 'gehalten')
const err = await fehlerVon(() => aktion(ctx, gehalten.$id, { aktion: 'anonymisieren' }, JETZT))
assert.equal(err.code, 'nicht_moeglich')
assert.equal(buchung(gehalten.$id).gastEmail, 'zwei@example.com')
})
// ---------------------------------------------------------------- aktion allgemein
test('aktion: fremde Funktion, Sperren, unbekannte Nummern und kaputte Eingaben aendern nichts', async () => {
const ctx = await speichere()
const { id } = await gebucht(ctx)
const standZellen = claimsStand()
const stand = JSON.stringify(buchung(id))
const fremd = ctxMit(einstellung(), 'f_andere_funktion')
const err = await fehlerVon(() => aktion(fremd, id, { aktion: 'absagen', grund: 'Krankheit' }, JETZT))
assert.equal(err.status, 404)
assert.equal(err.code, 'unbekannt')
assert.deepEqual(err.fehler, { _: 'Diesen Termin gibt es nicht.' })
for (const falsch of ['gibt-es-nicht', '', null, undefined, 42, '../x', 'a'.repeat(40)]) {
assert.equal((await fehlerVon(() => aktion(ctx, falsch, { aktion: 'absagen', grund: 'Krankheit' }, JETZT))).status, 404, String(falsch))
}
for (const roh of [undefined, null, 'absagen', [], {}, { aktion: 'loeschen' }, { aktion: 7 }]) {
const e = await fehlerVon(() => aktion(ctx, id, roh, JETZT))
assert.equal(e.status, 400, JSON.stringify(roh))
}
assert.equal(JSON.stringify(buchung(id)), stand)
assert.equal(claimsStand(), standZellen)
const s = await sperre(ctx, { ressource: R2, tag: TAG_FERN, von: '09:00', bis: '10:00', titel: 'Pause' }, JETZT)
assert.equal((await fehlerVon(() => aktion(ctx, s.id, { aktion: 'absagen', grund: 'Krankheit' }, JETZT))).status, 404, 'eine Sperre ist keine Buchung')
assert.equal(buchung(s.id).status, 'bestaetigt')
await assert.rejects(() => aktion(ctx, id, { aktion: 'anonymisieren' }, undefined), TypeError)
await assert.rejects(() => aktion({ feature: ctx.feature }, id, { aktion: 'anonymisieren' }, JETZT), TypeError)
})
test('aktion: scheitert die Mail beim Einreihen, bleibt die Aenderung bestehen und der Fehler steht im Log', async () => {
const ctx = await speichere()
const { id } = await gebucht(ctx, { leistung: S_ANFRAGE })
const erg = await mitDefektemPostausgang(() => aktion(ctx, id, { aktion: 'annehmen' }, JETZT))
assert.deepEqual(erg, { status: 'bestaetigt' })
assert.equal(buchung(id).status, 'bestaetigt')
assert.ok(log.error.mock.calls.some((c) => /G02 nicht eingereiht/.test(String(c.arguments[0]))))
assert.ok(!log.error.mock.calls.some((c) => /max@example\.com/.test(String(c.arguments[0]))), 'keine Adresse im Log')
})
test('aktion: parallele Aktionen auf dieselbe Buchung ergeben genau einen Endzustand', async () => {
const ctx = await speichere()
const { id } = await gebucht(ctx, { leistung: S_ANFRAGE })
const erg = await Promise.allSettled([
aktion(ctx, id, { aktion: 'annehmen' }, JETZT),
aktion(ctx, id, { aktion: 'ablehnen' }, JETZT),
aktion(ctx, id, { aktion: 'absagen', grund: 'Krankheit' }, JETZT),
])
await warteAufVersand()
const gut = erg.filter((e) => e.status === 'fulfilled' && !e.value.schon)
assert.equal(gut.length >= 1, true)
const endzustand = buchung(id).status
assert.ok(['bestaetigt', 'abgelehnt', 'abgesagt'].includes(endzustand))
// genau die Mail zum Endzustand plus hoechstens die der Annahme, wenn danach abgesagt wurde
const gast = arten().filter((a) => ['G02', 'G04', 'G06'].includes(a))
assert.ok(gast.length >= 1 && gast.length <= 2, gast.join(','))
if (endzustand === 'abgelehnt') assert.deepEqual(gast, ['G04'])
if (endzustand !== 'bestaetigt') assert.equal(claims(id).length, 0)
})
// ---------------------------------------------------------------- manuell
test('manuell: quelle manuell, bestaetigt, kein Verbraucher, Zellen, ohne Haken keine Mail', async () => {
const ctx = await speichere()
const erg = await manuell(ctx, handEingabe({ email: 'paula@example.com', zusatz: { marke: 'Schwarzkopf' } }), JETZT)
await warteAufVersand()
assert.equal(erg.status, 'bestaetigt')
assert.equal(erg.nummer, buchungsNummer(erg.id))
const b = buchung(erg.id)
assert.equal(b.quelle, 'manuell')
assert.equal(b.status, 'bestaetigt')
assert.equal(b.verbraucher, false)
assert.equal(b.featureId, FEATURE)
assert.equal(b.customerId, KUNDE)
assert.equal(b.projectId, PROJEKT)
assert.equal(b.leistungId, S_SOFORT)
assert.equal(b.leistungName, 'Damenschnitt')
assert.equal(b.ressourceId, R1)
assert.equal(b.ressourceName, 'Anna')
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')
assert.equal(b.gastName, 'Paula Telefon')
assert.equal(b.gastEmail, 'paula@example.com')
assert.equal(b.gastTelefon, '06301 555')
assert.deepEqual(JSON.parse(b.gastZusatz), { marke: 'Schwarzkopf' })
assert.equal(b.erinnerung, false)
assert.equal(iso(b.bestaetigtAt), iso(JETZT))
assert.equal(b.manageHash, hashToken(verwaltungsToken(erg.id)))
assert.equal(b.icsSequenz, 0)
assert.equal(b.preisCent, 4500)
assert.equal(claims(erg.id).length, 10)
assert.equal(kannWiderrufen(b, JETZT), false, 'ein Handeintrag hat kein Widerrufsrecht')
assert.equal(sink.sent.length, 0, 'ohne Haken keine Gastmail (und kein K20 an den Betrieb)')
assert.equal(eintraege().length, 0)
// die Zeit ist belegt
assert.equal((await fehlerVon(() => halte(ctx, eingabe({ email: 'zwei@example.com', zustimmungen: { storno: true, vorzeitig: false } }), JETZT))).code, 'SLOT_VERGEBEN')
})
test('manuell: mit Haken und Adresse geht G02 mit Einladung und funktionierendem Verwaltungslink, ohne Widerrufsbelehrung', async () => {
const ctx = await speichere()
const erg = await manuell(ctx, handEingabe({ email: ' Paula@Example.com ', bestaetigungSenden: true }), JETZT)
await warteAufVersand()
assert.equal(buchung(erg.id).gastEmail, 'paula@example.com')
assert.equal(sink.sent.length, 1)
const g02 = sink.sent[0]
assert.equal(g02.to[0].address, 'paula@example.com')
assert.equal(g02.subject, 'Ihr Termin bei Salon Müller am 26.10.2026')
assert.doesNotMatch(g02.text, /angenommen/)
assert.doesNotMatch(g02.text, /Widerruf/)
assert.equal(g02.icalEvent.method, 'REQUEST')
assert.match(entfalte(g02.icalEvent.content), /SEQUENCE:0\r\n/)
assert.deepEqual(arten(), ['G02'])
const link = linkIn(g02)
assert.equal(link, verwaltungsToken(erg.id))
const v = await vorschau(link, JETZT)
assert.equal(v.art, 'verwalten')
assert.equal(v.aktionen.widerrufen, false)
})
test('manuell: Haken ohne Adresse ist kein Fehler, es geht nur keine Mail raus', async () => {
const ctx = await speichere()
const erg = await manuell(ctx, handEingabe({ bestaetigungSenden: true }), JETZT)
await warteAufVersand()
assert.equal(buchung(erg.id).gastEmail, '')
assert.equal(sink.sent.length, 0)
assert.equal(log.error.mock.callCount(), 0)
// ohne Adresse gehen auch spaetere Aktionen ohne Mail durch
await aktion(ctx, erg.id, { aktion: 'absagen', grund: 'Krankheit' }, JETZT)
await warteAufVersand()
assert.equal(sink.sent.length, 0)
assert.equal(buchung(erg.id).status, 'abgesagt')
})
test('manuell: Pruefung wie beim Gast, aber ohne Zustimmungen; Adresse freiwillig, aber wenn angegeben gueltig', async () => {
const ctx = await speichere()
// Storno-Haken und Zustimmung zum vorzeitigen Beginn entfallen (Termin in 7 Tagen, mit Preis, Verbraucher-Betrieb)
assert.equal((await manuell(ctx, handEingabe({ tag: TAG_NAH }), JETZT)).status, 'bestaetigt')
for (const [feld, over] of [
['name', { name: ' ' }],
['name', { name: 'N'.repeat(101) }],
['email', { email: 'kein-postfach' }],
['email', { email: 'a@example.com, b@example.com' }],
['email', { email: 5 }],
['leistung', { leistung: 's_gibtesnicht' }],
['ressource', { ressource: 'r_gibtesnicht' }],
['tag', { tag: '2026-02-30' }],
['zeit', { zeit: '25:61' }],
['telefon', { telefon: 'abc' }],
['nachricht', { nachricht: 'n'.repeat(501) }],
['zusatz', { zusatz: { unbekannt: 'x' } }],
['bestaetigungSenden', { bestaetigungSenden: 'ja' }],
]) {
const err = await fehlerVon(() => manuell(ctx, handEingabe({ zeit: '15:00', ...over }), JETZT))
assert.equal(err.status, 400, feld)
assert.ok(Object.keys(err.fehler).includes(feld), `${feld}: ${JSON.stringify(err.fehler)}`)
}
for (const roh of [undefined, null, 'x', []]) assert.equal((await fehlerVon(() => manuell(ctx, roh, JETZT))).status, 400)
assert.equal(buchungen().length, 1, 'nur der gueltige Eintrag')
await assert.rejects(() => manuell(ctx, handEingabe(), undefined), TypeError)
await assert.rejects(() => manuell({ feature: ctx.feature }, handEingabe(), JETZT), TypeError)
})
test('manuell: ohne Vorlauf und Horizont, nie in der Vergangenheit, nie ausserhalb der Oeffnung, nie auf belegten Zellen', async () => {
const ctx = await speichere()
// Vorlauf (120 Minuten): heute 11:00 geht; Horizont (90 Tage): Februar geht
assert.equal((await manuell(ctx, handEingabe({ tag: '2026-10-05', zeit: '11:00' }), JETZT)).status, 'bestaetigt')
assert.equal((await manuell(ctx, handEingabe({ tag: '2027-02-01', zeit: '10:00' }), JETZT)).status, 'bestaetigt')
const vorher = buchungen().length
const standZellen = claimsStand()
for (const over of [
{ tag: '2026-10-05', zeit: '09:00' }, // vergangen
{ tag: '2026-10-04', zeit: '10:00' }, // gestern
{ tag: '2026-10-27', zeit: '10:00' }, // Dienstag, kein Plan
{ tag: TAG_FERN, zeit: '08:45' }, // vor der Oeffnung
{ tag: TAG_FERN, zeit: '10:07' }, // nicht im Raster
{ tag: '2026-10-05', zeit: '11:15' }, // ueberlappt den Termin um 11:00
]) {
const err = await fehlerVon(() => manuell(ctx, handEingabe(over), 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, vorher)
assert.equal(claimsStand(), standZellen)
})
test('manuell: "egal" waehlt die Person mit den wenigsten Terminen des Tages', async () => {
const ctx = await speichere()
const a = await manuell(ctx, handEingabe({ zeit: '09:00' }), JETZT) // R1
assert.equal(buchung(a.id).ressourceId, R1)
const b = await manuell(ctx, handEingabe({ ressource: 'egal', zeit: '10:00' }), JETZT)
assert.equal(buchung(b.id).ressourceId, R2)
assert.equal(buchung(b.id).ressourceName, 'Ben')
})
test('manuell: ein Konflikt beim Belegen der Zellen (409) raeumt die Buchung wieder weg', async () => {
const ctx = await speichere()
// Zelle um 10:00 gehoert einer fremden, bestaetigten Buchung, die der Tagesabruf nicht sieht (anderer Tag in der Datenbank)
aw.seed('termineBookings', {
$id: 'fremd-0001',
featureId: FEATURE,
ressourceId: R1,
status: 'bestaetigt',
startsAt: '2026-12-01T09:00:00.000Z',
endsAt: '2026-12-01T09:45:00.000Z',
blockEndsAt: '2026-12-01T09:50:00.000Z',
})
aw.seed('termineClaims', {
$id: zellenId(FEATURE, R1, '2026-10-26T09:00:00.000Z'),
featureId: FEATURE,
ressourceId: R1,
bookingId: 'fremd-0001',
zelle: '2026-10-26T09:00:00.000Z',
bis: '2026-10-26T09:50:00.000Z',
})
const err = await fehlerVon(() => manuell(ctx, handEingabe(), JETZT))
assert.equal(err.status, 409)
assert.equal(err.code, 'SLOT_VERGEBEN')
assert.deepEqual(buchungen().map((b) => b.$id), ['fremd-0001'], 'keine Reste der gescheiterten Buchung')
assert.equal(aw.docs('termineClaims').size, 1)
})
test('manuell: eine Belegung ueber 500 Minuten wird abgelehnt (400 Feld leistung), bevor etwas angelegt wird', async () => {
const e = einstellung()
const lang = { ...e, leistungen: e.leistungen.map((l) => (l.id === S_SOFORT ? { ...l, dauer: 480, pufferNach: 25 } : l)) }
const ctx = ctxMit(lang)
const err = await fehlerVon(() => manuell(ctx, handEingabe({ zeit: '09:00' }), JETZT))
assert.equal(err.status, 400)
assert.deepEqual(Object.keys(err.fehler), ['leistung'])
assert.equal(buchungen().length, 0)
})
// ---------------------------------------------------------------- Sperren
test('sperre: 09:00 bis 17:30 sind 510 Minuten => 400 Feld bis mit dem Hinweis auf Abwesenheiten; 500 Minuten gehen', async () => {
const ctx = await speichere()
const err = await fehlerVon(() => sperre(ctx, { ressource: R1, tag: TAG_FERN, von: '09:00', bis: '17:30', titel: 'Fortbildung' }, JETZT))
assert.equal(err.status, 400)
assert.deepEqual(err.fehler, { bis: 'Eine Sperre kann höchstens 8 Stunden 20 Minuten dauern. Für ganze Tage nutze Abwesenheiten.' })
assert.equal((await fehlerVon(() => sperre(ctx, { ressource: R1, tag: TAG_FERN, von: '09:00', bis: '17:25' }, JETZT))).status, 400)
assert.equal(buchungen().length, 0)
assert.equal(aw.docs('termineClaims').size, 0)
const ok = await sperre(ctx, { ressource: R1, tag: TAG_FERN, von: '09:00', bis: '17:20', titel: 'Fortbildung' }, JETZT)
assert.equal(ok.status, 'bestaetigt')
assert.equal(claims(ok.id).length, 100)
})
test('sperre: 09:00 bis 13:00 ist eine bestaetigte Buchung ohne Gast und blockiert die Slots dieser Ressource', async () => {
const ctx = await speichere()
const erg = await sperre(ctx, { ressource: R1, tag: TAG_FERN, von: '09:00', bis: '13:00', titel: 'Fortbildung' }, JETZT)
await warteAufVersand()
const b = buchung(erg.id)
assert.equal(b.quelle, 'sperre')
assert.equal(b.status, 'bestaetigt')
assert.equal(b.leistungName, 'Fortbildung')
assert.equal(b.ressourceId, R1)
assert.equal(b.ressourceName, 'Anna')
assert.equal(iso(b.startsAt), '2026-10-26T08:00:00.000Z')
assert.equal(iso(b.endsAt), '2026-10-26T12:00:00.000Z')
assert.equal(iso(b.blockEndsAt), '2026-10-26T12:00:00.000Z')
assert.equal(b.gastName, undefined)
assert.equal(b.gastEmail, undefined)
assert.equal(b.verbraucher, false)
assert.equal(claims(erg.id).length, 48)
assert.equal(eintraege().length, 0, 'eine Sperre schickt keine Mail')
// R1 ist gesperrt, R2 nicht; um 13:00 ist R1 wieder frei
for (const zeit of ['09:00', '10:00', '12:00']) {
const err = await fehlerVon(() => halte(ctx, eingabe({ zeit, email: `x${zeit.slice(0, 2)}@example.com` }), JETZT))
assert.equal(err.code, 'SLOT_VERGEBEN', zeit)
}
assert.equal((await halte(ctx, eingabe({ zeit: '10:00', ressource: R2, email: 'ben@example.com' }), JETZT)).status, 'gehalten')
assert.equal((await halte(ctx, eingabe({ zeit: '13:00', email: 'dreizehn@example.com' }), JETZT)).status, 'gehalten')
// "egal" weicht auf die freie Person aus
assert.equal((await halte(ctx, eingabe({ zeit: '11:00', ressource: 'egal', email: 'egal@example.com' }), JETZT)).status, 'gehalten')
assert.equal(buchungen().find((x) => x.gastEmail === 'egal@example.com').ressourceId, R2)
})
test('sperre: Titel (Vorgabe "Gesperrt"), Ressource, Tag und Uhrzeiten in 5-Minuten-Schritten werden geprueft', async () => {
const ctx = await speichere()
const gut = { ressource: R1, tag: TAG_FERN, von: '09:00', bis: '10:00' }
const ohneTitel = await sperre(ctx, gut, JETZT)
assert.equal(buchung(ohneTitel.id).leistungName, 'Gesperrt')
const bis24 = await sperre(ctx, { ressource: R2, tag: TAG_FERN, von: '20:00', bis: '24:00', titel: ' Nachtruhe ' }, JETZT)
assert.equal(buchung(bis24.id).leistungName, 'Nachtruhe')
assert.equal(iso(buchung(bis24.id).endsAt), '2026-10-26T23:00:00.000Z')
const vorher = buchungen().length
for (const [feld, over] of [
['ressource', { ressource: 'r_gibtesnicht' }],
['ressource', { ressource: undefined }],
['tag', { tag: '2026-13-01' }],
['von', { von: '09:03' }],
['von', { von: '9:00' }],
['von', { von: '24:00' }],
['bis', { bis: '10:07' }],
['bis', { bis: undefined }],
['bis', { von: '10:00', bis: '10:00' }], // Ende nicht nach Beginn
['bis', { von: '10:00', bis: '09:00' }],
['bis', { tag: '2026-10-05', von: '08:00', bis: '09:00' }], // endet in der Vergangenheit
['von', { tag: '2026-03-29', von: '02:30', bis: '04:00' }], // Luecke der Maerz-Umstellung
['titel', { titel: 't'.repeat(121) }],
]) {
const err = await fehlerVon(() => sperre(ctx, { ...gut, ...over }, JETZT))
assert.equal(err.status, 400, feld)
assert.ok(Object.keys(err.fehler).includes(feld), `${feld}: ${JSON.stringify(err.fehler)}`)
}
for (const roh of [undefined, null, 'x', []]) assert.equal((await fehlerVon(() => sperre(ctx, roh, JETZT))).status, 400)
assert.equal(buchungen().length, vorher)
// eine Sperre darf schon laufen (Beginn vor jetzt, Ende danach)
assert.equal((await sperre(ctx, { ressource: R1, tag: '2026-10-05', von: '09:00', bis: '12:00' }, JETZT)).status, 'bestaetigt')
await assert.rejects(() => sperre(ctx, gut, undefined), TypeError)
await assert.rejects(() => sperre({ feature: ctx.feature }, gut, JETZT), TypeError)
})
test('sperre: ueber einem Termin oder einer anderen Sperre 409 (Feld von), ohne Reste; erst absagen, dann sperren', async () => {
const ctx = await speichere()
const { id } = await gebucht(ctx) // R1 10:00-10:50
const standZellen = claimsStand()
const vorher = buchungen().length
const err = await fehlerVon(() => sperre(ctx, { ressource: R1, tag: TAG_FERN, von: '09:00', bis: '13:00' }, JETZT))
assert.equal(err.status, 409)
assert.equal(err.code, 'SLOT_VERGEBEN')
assert.deepEqual(err.fehler, { von: 'In dieser Zeit gibt es schon einen Termin oder eine Sperre.' })
assert.equal(buchungen().length, vorher)
assert.equal(claimsStand(), standZellen)
// die andere Person ist frei
const r2 = await sperre(ctx, { ressource: R2, tag: TAG_FERN, von: '09:00', bis: '13:00' }, JETZT)
assert.equal((await fehlerVon(() => sperre(ctx, { ressource: R2, tag: TAG_FERN, von: '12:30', bis: '14:00' }, JETZT))).status, 409)
assert.equal(claims(r2.id).length, 48)
await aktion(ctx, id, { aktion: 'absagen', grund: 'Krankheit' }, JETZT)
assert.equal((await sperre(ctx, { ressource: R1, tag: TAG_FERN, von: '09:00', bis: '13:00' }, JETZT)).status, 'bestaetigt')
})
test('entsperre: loescht die Sperre samt Zellen, die Zeit ist wieder buchbar; nur Sperren der eigenen Funktion', async () => {
const ctx = await speichere()
const s = await sperre(ctx, { ressource: R1, tag: TAG_FERN, von: '09:00', bis: '13:00', titel: 'Fortbildung' }, JETZT)
const { id } = await gebucht(ctx, { ressource: R2, email: 'ben@example.com' })
assert.equal(claims(s.id).length, 48)
// fremde Funktion, normale Buchung, unbekannte und kaputte Nummern
for (const [kontext, bid] of [[ctxMit(einstellung(), 'f_andere_funktion'), s.id], [ctx, id], [ctx, 'gibt-es-nicht'], [ctx, ''], [ctx, null], [ctx, '../x']]) {
const err = await fehlerVon(() => entsperre(kontext, bid))
assert.equal(err.status, 404, String(bid))
assert.deepEqual(err.fehler, { _: 'Diese Sperre gibt es nicht.' })
}
assert.equal(buchung(s.id).status, 'bestaetigt')
assert.equal(buchung(id).status, 'bestaetigt')
assert.deepEqual(await entsperre(ctx, s.id), { status: 'entsperrt' })
assert.equal(buchung(s.id), undefined)
assert.equal(claims(s.id).length, 0)
assert.equal(claims(id).length, 10, 'andere Zellen bleiben')
assert.equal((await halte(ctx, eingabe({ zeit: '10:00', email: 'neu@example.com' }), JETZT)).status, 'gehalten')
assert.equal((await fehlerVon(() => entsperre(ctx, s.id))).status, 404)
await assert.rejects(() => entsperre({ feature: ctx.feature }, s.id), TypeError)
})
+18
View File
@@ -426,6 +426,24 @@ test('halte: fehlt die Zustimmung zum vorzeitigen Beginn (verbindlich, Preis, Ve
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 () => {
+34 -6
View File
@@ -721,28 +721,56 @@ test('istVerbraucher: nicht nur Unternehmer und keine Freizeitleistung', () => {
assert.equal(istVerbraucher({ recht: { gaesteSind: 'verbraucher', freizeit: true } }), false)
})
test('brauchtZustimmungVorzeitig: verbindlich + Preis + Verbraucher + Beginn in weniger als 14 Tagen', () => {
test('brauchtZustimmungVorzeitig: verbindlich + Preis + Verbraucher + Beginn bis zum Ende der Widerrufsfrist (Berliner Kalender, §§ 187, 188, 193 BGB)', () => {
const verbraucher = { recht: { gaesteSind: 'verbraucher', freizeit: false } }
const unternehmer = { recht: { gaesteSind: 'unternehmer', freizeit: false } }
const freizeit = { recht: { gaesteSind: 'verbraucher', freizeit: true } }
const mitPreis = { buchungsart: 'sofort', preisArt: 'fest', preisCent: 4500 }
const inTagen = (n) => new Date(NOW.getTime() + n * TAG)
// NOW = Freitag 02.10.2026, 12:00 Berlin; Vertragsschluss spaetestens nach der Haltezeit (30 Minuten): Fristende
// Freitag 16.10.2026, 23:59:59,999 Uhr Berlin = 21:59:59,999 Uhr UTC (MESZ)
const FRISTENDE = Date.parse('2026-10-16T21:59:59.999Z')
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, inTagen(3), NOW), true)
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, inTagen(20), NOW), false)
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, inTagen(14), NOW), false)
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date(inTagen(14).getTime() - 1), NOW), true)
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, inTagen(0), NOW), true)
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, inTagen(20), NOW), false)
// Grenze: die letzte Millisekunde der Frist ja, die naechste nein
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date(FRISTENDE), NOW), true)
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date(FRISTENDE + 1), NOW), false)
// 14 mal 24 Stunden reichen nicht: 14 Tage nach der Buchung um 12:00 liegen noch in der Frist (bis 24 Uhr des Tages)
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, inTagen(14), NOW), true)
assert.equal(brauchtZustimmungVorzeitig(verbraucher, { ...mitPreis, preisArt: 'ab' }, inTagen(3), NOW), true)
// Unternehmer, Freizeit, kein Preis, Anfrage: keine Zustimmung noetig
// Fristende auf einem Wochenende ⇒ naechster Werktag: Samstag 10.10.2026 + 14 = Samstag 24.10. ⇒ Montag 26.10., 23:59:59,999 Uhr Berlin (MEZ)
const SAMSTAG = new Date('2026-10-10T10:00:00.000Z')
const wochenendeEnde = Date.parse('2026-10-26T22:59:59.999Z')
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date(wochenendeEnde), SAMSTAG), true)
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date(wochenendeEnde + 1), SAMSTAG), false)
// die Haltezeit zaehlt mit: wer um 23:45 Uhr bucht, schliesst den Vertrag erst nach Mitternacht (Fristende einen Tag spaeter)
// (Samstag 03.10. + 14 = Samstag 17.10. ⇒ Montag 19.10., 23:59:59,999 Uhr Berlin = 21:59:59,999 Uhr UTC)
const spaet = new Date('2026-10-02T21:45:00.000Z') // 23:45 Uhr Berlin, Freitag; + 30 Minuten = Samstag 00:15 Uhr
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date(Date.parse('2026-10-16T21:59:59.999Z') + 1), spaet), true)
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date(Date.parse('2026-10-19T21:59:59.999Z') + 1), spaet), false)
// Unternehmer, Freizeit, kein Preis: keine Zustimmung noetig
assert.equal(brauchtZustimmungVorzeitig(unternehmer, mitPreis, inTagen(3), NOW), false)
assert.equal(brauchtZustimmungVorzeitig(freizeit, mitPreis, inTagen(3), NOW), false)
assert.equal(brauchtZustimmungVorzeitig(verbraucher, { ...mitPreis, preisArt: 'keiner', preisCent: 0 }, inTagen(3), NOW), false)
assert.equal(brauchtZustimmungVorzeitig(verbraucher, { ...mitPreis, buchungsart: 'anfrage' }, inTagen(3), NOW), false)
// ungueltiger Beginn: lieber die Zustimmung verlangen
assert.equal(brauchtZustimmungVorzeitig(verbraucher, mitPreis, new Date('kaputt'), NOW), true)
})
test('brauchtZustimmungVorzeitig: bei einer Anfrage mit Preis fuer Verbraucher immer (der Tag der Annahme ist unbekannt)', () => {
const verbraucher = { recht: { gaesteSind: 'verbraucher', freizeit: false } }
const anfrage = { buchungsart: 'anfrage', preisArt: 'fest', preisCent: 4500 }
const inTagen = (n) => new Date(NOW.getTime() + n * TAG)
assert.equal(brauchtZustimmungVorzeitig(verbraucher, anfrage, inTagen(3), NOW), true)
assert.equal(brauchtZustimmungVorzeitig(verbraucher, anfrage, inTagen(60), NOW), true)
assert.equal(brauchtZustimmungVorzeitig(verbraucher, anfrage, new Date('kaputt'), NOW), true)
// ohne Preis, Unternehmer, Freizeit: nein
assert.equal(brauchtZustimmungVorzeitig(verbraucher, { ...anfrage, preisArt: 'keiner', preisCent: 0 }, inTagen(3), NOW), false)
assert.equal(brauchtZustimmungVorzeitig({ recht: { gaesteSind: 'unternehmer', freizeit: false } }, anfrage, inTagen(3), NOW), false)
assert.equal(brauchtZustimmungVorzeitig({ recht: { gaesteSind: 'verbraucher', freizeit: true } }, anfrage, inTagen(3), NOW), false)
})
test('preisText, preisHinweis, zahlungText', () => {
assert.equal(preisText({ preisArt: 'fest', preisCent: 4500 }), '45,00 €')
assert.equal(preisText({ preisArt: 'ab', preisCent: 4500 }), 'ab 45,00 €')