Files
Webklar-Kundenbereich/test/termineAblaufBetrieb.test.js

1161 lines
59 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { mailSink } from './support/mailSink.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
import { 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, darfEmpfaenger } = await import('../server/services/termine/limits.js')
const { anGast } = await import('../server/services/termine/mails.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 – leider abgelehnt')
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')
})
// ---------------------------------------------------------------- Empfaenger-Kappe fuer Freitext (G04, G06)
const GAST = 'max@example.com'
/** Alle Warnungen und Fehler im Log als ein Text (die Adresse des Gastes darf dort nie stehen). */
const logText = () => JSON.stringify([...log.warn.mock.calls, ...log.error.mock.calls].map((c) => c.arguments.map((a) => String(a?.message ?? a))))
/** Verbraucht n Platz(e) der Empfaenger-Kappe einer Adresse (wie n Mails mit Freitext oder G01 davor). */
const fuelleKappe = (adresse, n, now = JETZT) => {
for (let i = 0; i < n; i += 1) assert.equal(darfEmpfaenger(adresse, now), true)
}
/** Reiht eine Gastmail direkt ein und liefert die versandte Mail. */
async function sende(art, ctx, buchungsDoc, extra, now = JETZT) {
const vorher = sink.sent.length
await anGast(art, ctx, buchungsDoc, extra, now)
await warteAufVersand()
assert.equal(sink.sent.length, vorher + 1)
return sink.sent[vorher]
}
test('ablehnen: ueber der Kappe (3 je Adresse und Stunde) geht G04 ohne die Nachricht des Betriebs raus, darunter mit; die Warnung im Log nennt keine Adresse', async () => {
const ctx = await speichere()
const a = await gebucht(ctx, { leistung: S_ANFRAGE })
const b = await gebucht(ctx, { leistung: S_ANFRAGE, zeit: '12:00' })
fuelleKappe(GAST, 2) // zwei Mails mit Freitext sind schon raus: die dritte darf noch
const vorher = sink.sent.length
await aktion(ctx, a.id, { aktion: 'ablehnen', nachricht: 'Am Montag sind wir im Urlaub.' }, JETZT)
await warteAufVersand()
assert.match(sink.sent[vorher].text, /Am Montag sind wir im Urlaub\./, 'die dritte Mail der Stunde enthaelt den Text')
assert.doesNotMatch(logText(), /Kappe/)
await aktion(ctx, b.id, { aktion: 'ablehnen', nachricht: 'Bitte ruft uns einfach an.' }, JETZT)
await warteAufVersand()
assert.equal(sink.sent.length, vorher + 2, 'die Mail geht trotzdem raus')
const mail = sink.sent[vorher + 1]
assert.equal(mail.to[0].address, GAST)
assert.equal(mail.subject, 'Ihre Terminanfrage bei Salon Müller – leider abgelehnt')
assert.match(mail.text, /leider kann Salon Müller Ihre Terminanfrage nicht annehmen/, 'der Hinweis auf die Ablehnung bleibt')
assert.doesNotMatch(mail.text, /Bitte ruft uns einfach an/, 'der Freitext faellt weg')
assert.doesNotMatch(mail.text, /Nachricht von/i)
assert.doesNotMatch(JSON.stringify(eintraege()), /Bitte ruft uns einfach an/, 'er steht auch nicht im Postausgang')
assert.equal(buchung(b.id).status, 'abgelehnt')
assert.match(logText(), /G04: Empfänger-Kappe erreicht/)
assert.doesNotMatch(logText(), /max@example\.com|example\.com/, 'das Log nennt keine Adresse')
})
test('absagen: ueber der Kappe geht G06 des Betriebs ohne Grund und Nachricht raus (Kalenderabsage und Hinweis bleiben); das Log nennt keine Adresse', async () => {
const ctx = await speichere()
const { id } = await gebucht(ctx)
fuelleKappe(GAST, 3)
const vorher = sink.sent.length
assert.deepEqual(
await aktion(ctx, id, { aktion: 'absagen', grund: 'Krankheit', nachricht: 'Gute Besserung wuenschen wir.' }, JETZT),
{ status: 'abgesagt' },
)
await warteAufVersand()
assert.equal(sink.sent.length, vorher + 1)
const g06 = sink.sent[vorher]
assert.equal(g06.to[0].address, GAST)
assert.equal(g06.subject, 'Ihr Termin am 26.10.2026 wurde abgesagt')
assert.match(g06.text, /Salon Müller/)
assert.doesNotMatch(g06.text, /Krankheit/)
assert.doesNotMatch(g06.text, /Gute Besserung/)
assert.equal(g06.icalEvent.method, 'CANCEL', 'die Kalenderabsage bleibt')
assert.doesNotMatch(JSON.stringify(eintraege()), /Krankheit|Gute Besserung/)
assert.equal(buchung(id).absageGrund, 'Krankheit', 'der Betrieb behaelt seinen Grund in der Buchung')
assert.match(logText(), /G06: Empfänger-Kappe erreicht/)
assert.doesNotMatch(logText(), /example\.com/)
})
test('anGast: Freitext in G04/G06 des Betriebs geht bis zur dritten Mail je Adresse und Stunde mit, danach ohne; nach 61 Minuten wieder mit', async () => {
const ctx = await speichere()
const { buchung: b } = await gebucht(ctx)
const g04 = (text, now) => sende('G04', ctx, b, { nachricht: text }, now)
assert.match((await g04('Eins', JETZT)).text, /Eins/)
assert.match((await sende('G06', ctx, b, { von: 'betrieb', grund: 'Zwei', ohneIcs: true })).text, /Zwei/, 'G06 des Betriebs teilt sich die Zaehler mit G04')
assert.match((await g04('Drei', JETZT)).text, /Drei/)
const vier = await g04('Vier', JETZT)
assert.doesNotMatch(vier.text, /Vier/)
const fuenf = await sende('G06', ctx, b, { von: 'betrieb', grund: 'Fuenf', nachricht: 'Sechs', ohneIcs: true })
assert.doesNotMatch(fuenf.text, /Fuenf|Sechs/, 'weder Grund noch Nachricht')
assert.match(fuenf.text, /Salon Müller/)
assert.match((await g04('Sieben', plus(JETZT, 61 * MIN))).text, /Sieben/, 'nach 61 Minuten wieder')
assert.doesNotMatch(logText(), /example\.com/)
})
test('anGast: ohne Freitext zaehlt nichts - G04/G06 ohne Text, G06 des Gastes oder des Systems, G05 und die uebrigen Arten', async () => {
const ctx = await speichere()
const { buchung: b } = await gebucht(ctx)
const vorher = sink.sent.length
await sende('G04', ctx, b, {})
await sende('G04', ctx, b, { nachricht: ' ' })
await sende('G06', ctx, b, { von: 'betrieb', ohneIcs: true })
await sende('G06', ctx, b, { von: 'betrieb', grund: ' ', nachricht: '', ohneIcs: true })
const gast = await sende('G06', ctx, b, { von: 'gast', grund: 'Gastgrund', nachricht: 'Gastnachricht', ohneIcs: true })
await sende('G06', ctx, b, { von: 'system', ohneIcs: true })
await sende('G05', ctx, b, {})
await sende('G07', ctx, b, { altStart: b.startsAt, ohneIcs: true })
assert.equal(sink.sent.length, vorher + 8)
assert.match(gast.text, /Sie selbst/)
assert.doesNotMatch(gast.text, /Gastgrund|Gastnachricht/, 'bei einer Absage des Gastes gibt es weder Grund noch Nachricht')
assert.equal(log.warn.mock.callCount(), 0)
// die Kappe ist unberuehrt: alle drei Plaetze der Stunde sind frei
assert.deepEqual([1, 2, 3, 4].map(() => darfEmpfaenger(GAST, JETZT)), [true, true, true, false])
})
test('anGast: die Kappe gilt je Adresse (extra.an zaehlt klein geschrieben); eine andere Adresse ist nicht betroffen', async () => {
const ctx = await speichere()
const { buchung: b } = await gebucht(ctx)
fuelleKappe(GAST, 3)
fuelleKappe('weitere@example.com', 2)
const voll = await sende('G04', ctx, b, { nachricht: 'Nur fuer Max' })
assert.doesNotMatch(voll.text, /Nur fuer Max/)
const umgeleitet = await sende('G04', ctx, b, { nachricht: 'An eine andere Adresse', an: 'Weitere@Example.com' })
assert.match(umgeleitet.text, /An eine andere Adresse/, 'die Adresse in extra.an zaehlt (klein geschrieben): dort war noch ein Platz')
const nochmal = await sende('G04', ctx, b, { nachricht: 'Und noch eine', an: 'weitere@example.com' })
assert.doesNotMatch(nochmal.text, /Und noch eine/, 'die Zaehler sind mit der Schreibweise der Adresse nicht zu umgehen')
})
// ---------------------------------------------------------------- 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')
})
test('anonymisieren: absageGrund wird neutralisiert (auch der Vermerk "Widerruf erklärt von: <Name>"), Betriebsgründe ebenso', async () => {
const ctx = await speichere()
const { id } = await gebucht(ctx)
const abgesagtUm = iso(plus(JETZT, MIN))
// Vermerk einer Widerrufserklaerung nach Fristablauf (haltWiderrufFest): der Gast hat den Namen selbst getippt
aw.seed('termineBookings', { ...buchung(id), absageGrund: 'Widerruf erklärt von: Max Mustermann am 20.10.2026, 11:00 Uhr (nach Fristablauf)', updatedAt: abgesagtUm })
assert.match(buchung(id).absageGrund, /Max Mustermann/)
await aktion(ctx, id, { aktion: 'anonymisieren' }, plus(JETZT, 5 * MIN))
assert.equal(buchung(id).absageGrund, '')
assert.doesNotMatch(JSON.stringify(buchung(id)), /Mustermann|max@example\.com/)
// abgesagt vom Betrieb: der Grund war frei getippt und kann Namen enthalten; die Frist (updatedAt) bleibt
const zwei = await gebucht(ctx, { email: 'zwei@example.com', zeit: '12:00' })
await aktion(ctx, zwei.id, { aktion: 'absagen', grund: 'Frau Meier ist krank' }, JETZT)
assert.equal(buchung(zwei.id).absageGrund, 'Frau Meier ist krank')
const abgesagtZwei = iso(buchung(zwei.id).updatedAt)
await aktion(ctx, zwei.id, { aktion: 'anonymisieren' }, plus(JETZT, 10 * 24 * 60 * MIN))
assert.equal(buchung(zwei.id).absageGrund, '')
assert.equal(buchung(zwei.id).status, 'abgesagt')
assert.equal(iso(buchung(zwei.id).updatedAt), abgesagtZwei)
})
test('anonymisieren: noch nicht versandte Mails dieser Buchung (wartet) werden gelöscht; gesendete, fehlgeschlagene und fremde Einträge bleiben', async () => {
const ctx = await speichere()
const { id } = await gebucht(ctx)
const { id: anderer } = await gebucht(ctx, { email: 'zwei@example.com', zeit: '12:00' })
await warteAufVersand()
const vorher = eintraege().length
const voll = { featureId: FEATURE, art: 'G05', an: 'max@example.com', fromName: 'Salon Müller', subject: 'Erinnerung', html: '<p>Hallo Max Mustermann</p>', text: 'Hallo Max Mustermann', ics: '', icsMethod: '', versuche: 0, naechsterVersuch: iso(JETZT), fehler: '', createdAt: iso(JETZT) }
aw.seed('termineOutbox', { ...voll, $id: 'o_wartet1', bookingId: id, status: 'wartet' })
aw.seed('termineOutbox', { ...voll, $id: 'o_wartet2', bookingId: id, status: 'wartet', art: 'G03' })
aw.seed('termineOutbox', { ...voll, $id: 'o_gesendet', bookingId: id, status: 'gesendet', an: '', html: '', text: '' })
aw.seed('termineOutbox', { ...voll, $id: 'o_fehler', bookingId: id, status: 'fehler' })
aw.seed('termineOutbox', { ...voll, $id: 'o_fremd', bookingId: anderer, status: 'wartet', an: 'zwei@example.com' })
await aktion(ctx, id, { aktion: 'anonymisieren' }, plus(JETZT, 5 * MIN))
const ids = eintraege().map((d) => d.$id)
assert.ok(!ids.includes('o_wartet1') && !ids.includes('o_wartet2'), 'wartende Mails mit Adresse und Inhalt sind weg')
assert.ok(ids.includes('o_gesendet') && ids.includes('o_fehler') && ids.includes('o_fremd'))
assert.equal(eintraege().length, vorher + 3)
assert.equal(buchung(id).gastName, 'Gelöscht')
// zweiter Klick: nichts mehr zu loeschen, kein Fehler
assert.deepEqual(await aktion(ctx, id, { aktion: 'anonymisieren' }, plus(JETZT, 6 * MIN)), { status: 'bestaetigt' })
assert.equal(eintraege().length, vorher + 3)
})
// ---------------------------------------------------------------- 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: der Haken "Bestaetigung senden" braucht eine Adresse (400 Feld email, nichts angelegt); ohne Haken geht der Eintrag ohne Adresse', async () => {
const ctx = await speichere()
for (const email of [undefined, null, '', ' ']) {
const err = await fehlerVon(() => manuell(ctx, handEingabe({ email, bestaetigungSenden: true }), JETZT))
assert.equal(err.status, 400)
assert.deepEqual(err.fehler, { email: 'Für die Bestätigung braucht der Gast eine E-Mail-Adresse.' })
}
assert.equal(buchungen().length, 0)
assert.equal(aw.docs('termineClaims').size, 0)
const erg = await manuell(ctx, handEingabe({ bestaetigungSenden: false }), 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: Pflicht ist nur der Name; E-Mail, Telefon, Nachricht und Zusatzfelder sind freiwillig, egal was gastFelder fuer Gaeste verlangt', async () => {
const pflicht = await speichere(einstellung({
gastFelder: { telefon: 'pflicht', nachricht: 'pflicht', zusatz: [{ key: 'marke', label: 'Marke', typ: 'text', pflicht: true }] },
}))
// nur der Name
const nurName = await manuell(pflicht, { leistung: S_SOFORT, ressource: R1, tag: TAG_FERN, zeit: '10:00', name: 'Laufkunde' }, JETZT)
const b = buchung(nurName.id)
assert.equal(b.gastName, 'Laufkunde')
for (const feld of ['gastEmail', 'gastTelefon', 'gastNachricht', 'gastZusatz']) assert.equal(b[feld], '', feld)
// ohne Name geht es nicht, auch wenn alles andere da ist
const err = await fehlerVon(() => manuell(pflicht, handEingabe({ name: '', email: 'a@example.com', zeit: '11:00' }), JETZT))
assert.deepEqual(Object.keys(err.fehler), ['name'])
// ausgeschaltete Felder (aus) nehmen Eingaben des Betriebs trotzdem an
const aus = await speichere(einstellung({ gastFelder: { telefon: 'aus', nachricht: 'aus', zusatz: [{ key: 'marke', label: 'Marke', typ: 'text', pflicht: false }] } }))
const mitAllem = await manuell(aus, handEingabe({ zeit: '12:00', nachricht: 'Kommt mit Hund.', zusatz: { marke: 'Wella' } }), JETZT)
assert.equal(buchung(mitAllem.id).gastTelefon, '06301 555')
assert.equal(buchung(mitAllem.id).gastNachricht, 'Kommt mit Hund.')
assert.deepEqual(JSON.parse(buchung(mitAllem.id).gastZusatz), { marke: 'Wella' })
// was angegeben ist, wird geprueft
for (const [feld, over] of [
['telefon', { telefon: 'abc' }],
['nachricht', { nachricht: 'n'.repeat(501) }],
['zusatz.marke', { zusatz: { marke: 'm'.repeat(201) } }],
['zusatz', { zusatz: { unbekannt: 'x' } }],
['email', { email: 'kein-postfach' }],
]) {
const e = await fehlerVon(() => manuell(aus, handEingabe({ zeit: '14:00', ...over }), JETZT))
assert.equal(e.status, 400, feld)
assert.ok(Object.keys(e.fehler).includes(feld), `${feld}: ${JSON.stringify(e.fehler)}`)
}
assert.equal(buchungen().length, 2)
})
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)
})