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

1232 lines
68 KiB
JavaScript

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'
// Gast-Verwaltung des Terminplaners: absagen, verschieben, Zeiten fuer das Verschieben, Widerruf (mit und ohne Link).
// Aufbau wie test/termineAblaufGast.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 { monatInfo } = await import('../server/services/termine/zeit.js')
const { resetTermineLimitsForTests } = await import('../server/services/termine/limits.js')
const { halte, vorschau, bestaetige, kannWiderrufen, sageAbGast, verschiebeGast, zeitenFuerToken, widerrufeGast, widerrufeOhneLink } = await import(
'../server/services/termine/ablauf.js'
)
after(() => setMailTransportForTests(null))
const MIN = 60000
const TAG_MS = 24 * 60 * MIN
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 (MESZ): vorzeitiger Beginn zustimmungspflichtig
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 = (state = 'active') => ({ $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'termine', state, 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: 'aus', zusatz: [] },
erinnerung: { anbieten: true, stundenVorher: 24 },
storno: { text: 'Kostenlose Absage bis 24 Stunden vor dem Termin.' },
recht: { gaesteSind: 'verbraucher', freizeit: false },
zahlung: { modus: 'vorort', vorOrtArten: ['bar', 'ec'] },
...over,
})
const einstellung = (over) => {
const erg = normalisiereEinstellung(eingabeEinstellung(over), { now: JETZT })
assert.equal(erg.fehler, undefined, JSON.stringify(erg.fehler))
return erg.einstellung
}
const ctxMit = (e = einstellung()) => ({ feature: feature(), project: { $id: PROJEKT, subdomain: 'salon' }, einstellung: e, version: 1 })
const eingabe = (over = {}) => ({
leistung: S_SOFORT,
ressource: 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,
})
const NAH = { tag: TAG_NAH, zustimmungen: { storno: true, vorzeitig: true } }
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()
/** 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
}
}
// ---------------------------------------------------------------- Absagen
test('sageAbGast: bis absageBisStd vor Beginn; abgesagt von gast, Zellen frei, G06 mit CANCEL (Sequenz + 1), K21 abgesagt', async () => {
const ctx = await speichere()
const { id, buchung: b, verwaltung, mailStart } = await gebucht(ctx)
assert.equal(claims(id).length, 10)
const jetzt = plus(JETZT, 3 * 60 * MIN)
assert.deepEqual(await sageAbGast(verwaltung, jetzt), { status: 'abgesagt' })
await warteAufVersand()
const nachher = buchung(id)
assert.equal(nachher.status, 'abgesagt')
assert.equal(nachher.abgesagtVon, 'gast')
assert.equal(iso(nachher.abgesagtAt), iso(jetzt))
assert.equal(iso(nachher.updatedAt), iso(jetzt))
assert.equal(nachher.widerrufenAt, undefined, 'eine Absage ist kein Widerruf')
assert.equal(nachher.icsSequenz, b.icsSequenz)
assert.equal(claims(id).length, 0, 'Zellen frei')
const neu = sink.sent.slice(mailStart + 2) // G02 und K20 der Bestaetigung ausgenommen
assert.equal(neu.length, 2)
const g06 = an(neu, 'max@example.com')[0]
assert.equal(g06.subject, 'Ihr Termin am 26.10.2026 wurde abgesagt')
assert.equal(g06.icalEvent.method, 'CANCEL')
const ics = entfalte(g06.icalEvent.content)
assert.match(ics, /STATUS:CANCELLED/)
assert.match(ics, /SEQUENCE:1\r\n/)
assert.match(ics, new RegExp(`UID:${id}@termine.webklar.com`))
assert.match(g06.text, /Sie selbst/)
assert.equal('bcc' in g06, false)
const k21 = an(neu, 'info@salon.de')[0]
assert.equal(k21.subject, 'Termin abgesagt: Damenschnitt am 26.10.2026 um 10:00')
assert.equal(k21.from.name, 'WEBklar')
assert.equal('icalEvent' in k21, false)
assert.equal('bcc' in k21, false)
assert.deepEqual(arten(), ['G01', 'G02', 'G06', 'K20', 'K21'])
// die freie Zeit ist wieder buchbar
const neuerGast = await halte(ctx, eingabe({ email: 'zwei@example.com' }), jetzt)
assert.equal(neuerGast.status, 'gehalten')
})
test('sageAbGast: genau an der Frist noch ja, eine Millisekunde spaeter 409 "Absage nur telefonisch", nichts aendert sich', async () => {
const ctx = await speichere()
const { id, buchung: b, verwaltung, mailStart } = await gebucht(ctx)
const start = new Date(b.startsAt)
const standVorher = claimsStand()
const err = await fehlerVon(() => sageAbGast(verwaltung, plus(start, -24 * 60 * MIN + 1)))
assert.equal(err.status, 409)
assert.deepEqual(err.fehler, { _: 'Absage nur telefonisch: 06301 1234' })
assert.equal(err.code, 'frist_vorbei')
assert.equal(buchung(id).status, 'bestaetigt')
assert.equal(claimsStand(), standVorher)
assert.equal(sink.sent.length, mailStart + 2, 'keine neuen Mails')
assert.deepEqual(await sageAbGast(verwaltung, plus(start, -24 * 60 * MIN)), { status: 'abgesagt' })
})
test('sageAbGast: ohne Telefonnummer des Betriebs steht ein allgemeiner Hinweis, nach Beginn und fuer erledigte Termine "nicht mehr moeglich"', async () => {
const ctx = await speichere()
const { id, buchung: b, verwaltung } = await gebucht(ctx)
const start = new Date(b.startsAt)
const spaet = plus(start, -60 * MIN)
aw.seed('termineConfig', { ...aw.docs('termineConfig').get(FEATURE), einstellung: JSON.stringify({ ...ctx.einstellung, betrieb: { ...ctx.einstellung.betrieb, telefon: '' } }) })
assert.deepEqual((await fehlerVon(() => sageAbGast(verwaltung, spaet))).fehler, { _: 'Absage nur direkt beim Betrieb möglich.' })
const begonnen = await fehlerVon(() => sageAbGast(verwaltung, plus(start, 5 * MIN)))
assert.equal(begonnen.status, 409)
assert.deepEqual(begonnen.fehler, { _: 'Dieser Termin kann nicht mehr abgesagt werden.' })
assert.equal(begonnen.code, 'nicht_moeglich')
aw.seed('termineBookings', { ...buchung(id), status: 'erledigt' })
assert.equal((await fehlerVon(() => sageAbGast(verwaltung, JETZT))).code, 'nicht_moeglich')
})
test('sageAbGast: zweiter Klick und paralleler Klick aendern nichts und senden nichts doppelt', async () => {
const ctx = await speichere()
const { verwaltung } = await gebucht(ctx)
const erg = await Promise.all([sageAbGast(verwaltung, JETZT), sageAbGast(verwaltung, JETZT)])
await warteAufVersand()
assert.deepEqual(erg.map((x) => x.status), ['abgesagt', 'abgesagt'])
assert.deepEqual(erg.filter((x) => x.schon).length, 1)
assert.deepEqual(arten(), ['G01', 'G02', 'G06', 'K20', 'K21'])
assert.deepEqual(await sageAbGast(verwaltung, plus(JETZT, MIN)), { status: 'abgesagt', schon: true })
await warteAufVersand()
assert.deepEqual(arten(), ['G01', 'G02', 'G06', 'K20', 'K21'])
})
test('sageAbGast: Anfrage (nie eine Einladung erhalten) => G06 ohne Kalenderdatei (R21), K21 geht trotzdem; Zellen frei', async () => {
const ctx = await speichere()
const { id, buchung: b, verwaltung } = await gebucht(ctx, { leistung: S_ANFRAGE })
assert.equal(b.status, 'angefragt')
assert.equal(claims(id).length, 12)
const vorher = sink.sent.length
await sageAbGast(verwaltung, JETZT)
await warteAufVersand()
const neu = sink.sent.slice(vorher)
const g06 = an(neu, 'max@example.com')[0]
assert.equal(g06.subject, 'Ihr Termin am 26.10.2026 wurde abgesagt')
assert.equal('icalEvent' in g06, false)
assert.equal(an(neu, 'info@salon.de')[0].subject, 'Termin abgesagt: Besichtigung am 26.10.2026 um 10:00')
assert.equal(buchung(id).status, 'abgesagt')
assert.equal(claims(id).length, 0)
assert.deepEqual(arten(), ['G01', 'G03', 'G06', 'K20', 'K21'])
})
test('sageAbGast: nur mit Verwaltungstoken; Bestaetigungstoken, unbekannte Token, Terminende + 1 Tag, Modus off', async () => {
const ctx = await speichere()
const { id, buchung: b, verwaltung, token } = await gebucht(ctx)
for (const falsch of ['', 'x', 'A'.repeat(43), null, undefined, 42, token, verwaltungsToken('andere-id')]) {
const err = await fehlerVon(() => sageAbGast(falsch, JETZT))
assert.equal(err.status, 404, String(falsch).slice(0, 20))
}
const ende = new Date(b.endsAt)
const abgelaufen = await fehlerVon(() => sageAbGast(verwaltung, plus(ende, TAG_MS)))
assert.equal(abgelaufen.status, 404)
assert.equal(abgelaufen.code, 'abgelaufen')
assert.equal(abgelaufen.betrieb.name, 'Salon Müller')
config.termine.mode = 'off'
assert.equal((await fehlerVon(() => sageAbGast(verwaltung, JETZT))).status, 404)
config.termine.mode = 'pilot'
assert.equal(buchung(id).status, 'bestaetigt')
await assert.rejects(() => sageAbGast(verwaltung, undefined), TypeError)
})
test('sageAbGast: eine abgeschaltete Funktion (disabled) erlaubt weiter das Absagen', async () => {
const ctx = await speichere()
const { id, verwaltung } = await gebucht(ctx)
aw.seed('portalFeatures', feature('disabled'))
assert.deepEqual(await sageAbGast(verwaltung, JETZT), { status: 'abgesagt' })
assert.equal(buchung(id).status, 'abgesagt')
})
test('sageAbGast: scheitern die Mails, bleibt die Absage bestehen (Log ohne Gastdaten)', async () => {
const ctx = await speichere()
const { id, verwaltung } = await gebucht(ctx)
const erg = await mitDefektemPostausgang(() => sageAbGast(verwaltung, JETZT))
assert.deepEqual(erg, { status: 'abgesagt' })
assert.equal(buchung(id).status, 'abgesagt')
assert.equal(claims(id).length, 0)
const protokoll = JSON.stringify(log.error.mock.calls.map((c) => c.arguments))
assert.match(protokoll, /G06/)
assert.match(protokoll, /K21/)
assert.doesNotMatch(protokoll, /max@example\.com|Mustermann/)
})
test('sageAbGast: schlaegt das Freigeben der Zellen fehl, bleibt die Absage bestehen und die Mails gehen raus', async () => {
const ctx = await speichere()
const { id, verwaltung, mailStart } = await gebucht(ctx)
aw.failNext({ collection: 'termineClaims', method: 'DELETE', status: 500 })
assert.deepEqual(await sageAbGast(verwaltung, JETZT), { status: 'abgesagt' })
await warteAufVersand()
assert.equal(buchung(id).status, 'abgesagt')
assert.ok(claims(id).length > 0, 'die Zellen bleiben liegen (gelten als frei, der Tagesjob raeumt sie)')
assert.equal(sink.sent.length, mailStart + 4, 'G02 und K20, dann G06 und K21')
assert.match(JSON.stringify(log.error.mock.calls.map((c) => c.arguments)), /Zellen/)
})
// ---------------------------------------------------------------- Verschieben
test('verschiebeGast: 10:00 -> 10:30 (ueberlappend) gelingt; Zeiten, Zellen, Sequenz 1, G07 mit REQUEST, K21 verschoben', async () => {
const ctx = await speichere()
const { id, buchung: alt, verwaltung, mailStart } = await gebucht(ctx)
const jetzt = plus(JETZT, 3 * 60 * MIN)
const erg = await verschiebeGast(verwaltung, { tag: TAG_FERN, zeit: '10:30' }, jetzt)
await warteAufVersand()
assert.deepEqual(erg, { status: 'bestaetigt', termin: { datum: 'Montag, 26.10.2026', zeit: '10:30' } })
const b = buchung(id)
assert.equal(iso(b.startsAt), '2026-10-26T09:30:00.000Z') // MEZ
assert.equal(iso(b.endsAt), '2026-10-26T10:15:00.000Z')
assert.equal(iso(b.blockEndsAt), '2026-10-26T10:20:00.000Z')
assert.equal(b.status, 'bestaetigt')
assert.equal(b.ressourceId, R1)
assert.equal(b.icsSequenz, 1)
assert.equal(iso(b.updatedAt), iso(jetzt))
assert.equal(b.manageHash, alt.manageHash, 'der Verwaltungslink bleibt gleich')
const erwartet = zellenFuer(b.startsAt, b.blockEndsAt).map(iso)
assert.equal(erwartet.length, 10)
assert.deepEqual(zeitenIso(claims(id)), erwartet, 'genau die Zellen des neuen Blocks')
assert.ok(claims(id).every((c) => c.ressourceId === R1))
// belegeZellen(ausser) legt nur Fehlendes an: die Zellen, die schon zum alten Block gehoerten, behalten ihr bis
const bis = new Set(claims(id).map((c) => iso(c.bis)))
assert.deepEqual([...bis].sort(), [iso(alt.blockEndsAt), iso(b.blockEndsAt)].sort())
const neu = sink.sent.slice(mailStart + 2)
assert.equal(neu.length, 2)
const g07 = an(neu, 'max@example.com')[0]
assert.equal(g07.subject, 'Ihr Termin wurde verschoben')
assert.equal(g07.icalEvent.method, 'REQUEST')
const ics = entfalte(g07.icalEvent.content)
assert.match(ics, /SEQUENCE:1\r\n/)
assert.match(ics, /DTSTART:20261026T093000Z/)
assert.match(ics, new RegExp(`UID:${id}@termine.webklar.com`))
assert.match(g07.text, /Montag, 26\.10\.2026/)
assert.match(g07.text, /10:00/)
assert.match(g07.text, /10:30/)
const k21 = an(neu, 'info@salon.de')[0]
assert.equal(k21.subject, 'Termin verschoben: Damenschnitt, jetzt am 26.10.2026 um 10:30')
assert.match(k21.text, /10:00/)
assert.deepEqual(arten(), ['G01', 'G02', 'G07', 'K20', 'K21'])
// zweites Verschieben: Sequenz 2, nur noch die Zellen der neuen Zeit
await verschiebeGast(verwaltung, { tag: TAG_FERN, zeit: '14:00' }, jetzt)
await warteAufVersand()
assert.equal(buchung(id).icsSequenz, 2)
assert.deepEqual(zeitenIso(claims(id)), zellenFuer(buchung(id).startsAt, buchung(id).blockEndsAt).map(iso))
assert.match(entfalte(an(sink.sent, 'max@example.com').at(-1).icalEvent.content), /SEQUENCE:2\r\n/)
})
test('verschiebeGast: auf eine belegte Zeit => 409 SLOT_VERGEBEN, alte Zellen, Buchung und Mails unveraendert', async () => {
const ctx = await speichere()
const a = await gebucht(ctx)
await gebucht(ctx, { zeit: '11:00', email: 'zwei@example.com' }) // Block 11:00-11:50 auf derselben Ressource
const standVorher = claimsStand()
const mails = sink.sent.length
const docVorher = JSON.stringify(buchung(a.id))
for (const zeit of ['10:30', '11:00', '11:15']) {
const err = await fehlerVon(() => verschiebeGast(a.verwaltung, { tag: TAG_FERN, zeit }, JETZT))
assert.equal(err.status, 409, zeit)
assert.equal(err.code, 'SLOT_VERGEBEN')
assert.ok(err.fehler.zeit)
}
assert.equal(claimsStand(), standVorher)
assert.equal(JSON.stringify(buchung(a.id)), docVorher)
assert.equal(sink.sent.length, mails)
assert.equal(eintraege().filter((d) => d.art === 'G07' || d.art === 'K21').length, 0)
})
test('verschiebeGast: verliert das Belegen den Wettlauf (Zellen gehoeren einer anderen Buchung), bleibt alles beim Alten', async () => {
const ctx = await speichere()
const a = await gebucht(ctx)
// eine fremde belegende Buchung vom Folgetag (nicht im Tageslesen) haelt Zellen zur Zielzeit: nur die Datenbank sieht den Konflikt
aw.seed('termineBookings', {
$id: 'x-fremd', featureId: FEATURE, customerId: KUNDE, projectId: PROJEKT, ressourceId: R1, status: 'bestaetigt', quelle: 'online',
startsAt: '2026-10-27T09:00:00.000Z', endsAt: '2026-10-27T09:45:00.000Z', blockEndsAt: '2026-10-27T09:50:00.000Z',
})
for (const zelle of zellenFuer('2026-10-26T13:00:00.000Z', '2026-10-26T13:50:00.000Z')) {
aw.seed('termineClaims', { $id: zellenId(FEATURE, R1, zelle), featureId: FEATURE, ressourceId: R1, bookingId: 'x-fremd', zelle: iso(zelle), bis: '2026-10-26T13:50:00.000Z' })
}
const standVorher = claimsStand()
const docVorher = JSON.stringify(buchung(a.id))
const mails = sink.sent.length
const err = await fehlerVon(() => verschiebeGast(a.verwaltung, { tag: TAG_FERN, zeit: '14:00' }, JETZT)) // 14:00 MEZ = 13:00Z
assert.equal(err.status, 409)
assert.equal(err.code, 'SLOT_VERGEBEN')
assert.equal(claimsStand(), standVorher, 'alte Zellen unveraendert, keine neuen')
assert.equal(JSON.stringify(buchung(a.id)), docVorher)
assert.equal(sink.sent.length, mails)
})
test('verschiebeGast: auf einen anderen Tag; die alten Zellen sind weg, die neuen da (Wanduhr Berlin, MESZ -> MEZ)', async () => {
const ctx = await speichere()
const { id, verwaltung } = await gebucht(ctx, { tag: TAG_NAH, zustimmungen: { storno: true, vorzeitig: true }, zeit: '10:00' })
const erg = await verschiebeGast(verwaltung, { tag: TAG_FERN, zeit: '09:00' }, JETZT)
assert.deepEqual(erg.termin, { datum: 'Montag, 26.10.2026', zeit: '09:00' })
const b = buchung(id)
assert.equal(iso(b.startsAt), '2026-10-26T08:00:00.000Z')
assert.deepEqual(zeitenIso(claims(id)), zellenFuer(b.startsAt, b.blockEndsAt).map(iso))
assert.equal(claims(id).length, 10)
})
test('verschiebeGast: Frist wie beim Absagen (409 "Absage nur telefonisch", unveraendert); eine Anfrage laesst sich verschieben, G07 ohne Kalenderdatei', async () => {
const ctx = await speichere()
const a = await gebucht(ctx)
const start = new Date(a.buchung.startsAt)
const err = await fehlerVon(() => verschiebeGast(a.verwaltung, { tag: TAG_FERN, zeit: '14:00' }, plus(start, -24 * 60 * MIN + 1)))
assert.deepEqual([err.status, err.code, err.fehler._], [409, 'frist_vorbei', 'Absage nur telefonisch: 06301 1234'])
assert.equal(buchung(a.id).startsAt, a.buchung.startsAt)
assert.equal((await verschiebeGast(a.verwaltung, { tag: TAG_FERN, zeit: '14:00' }, plus(start, -24 * 60 * MIN))).status, 'bestaetigt')
const anfrage = await gebucht(ctx, { leistung: S_ANFRAGE, zeit: '12:00', email: 'zwei@example.com' })
const vorher = sink.sent.length
const erg = await verschiebeGast(anfrage.verwaltung, { tag: TAG_FERN, zeit: '15:00' }, JETZT)
await warteAufVersand()
assert.equal(erg.status, 'angefragt')
assert.equal(buchung(anfrage.id).status, 'angefragt')
const g07 = an(sink.sent.slice(vorher), 'zwei@example.com')[0]
assert.equal(g07.subject, 'Ihr Termin wurde verschoben')
assert.equal('icalEvent' in g07, false, 'unbestaetigte Anfrage: keine Einladung')
assert.equal(an(sink.sent.slice(vorher), 'info@salon.de')[0].subject, 'Termin verschoben: Besichtigung, jetzt am 26.10.2026 um 15:00')
})
test('verschiebeGast: Eingaben pruefen (400 mit Feldern), nicht angebotene Zeit und dieselbe Zeit', async () => {
const ctx = await speichere()
const { id, verwaltung } = await gebucht(ctx)
const faelle = [
[{}, ['tag', 'zeit']],
[{ tag: 'morgen', zeit: '10:30' }, ['tag']],
[{ tag: '2026-02-30', zeit: '10:30' }, ['tag']],
[{ tag: TAG_FERN, zeit: '25:00' }, ['zeit']],
[{ tag: TAG_FERN, zeit: 1030 }, ['zeit']],
[{ tag: 5, zeit: null }, ['tag', 'zeit']],
]
for (const [roh, felder] of faelle) {
const err = await fehlerVon(() => verschiebeGast(verwaltung, roh, JETZT))
assert.equal(err.status, 400, JSON.stringify(roh))
assert.deepEqual(Object.keys(err.fehler).sort(), felder, JSON.stringify(roh))
}
assert.equal((await fehlerVon(() => verschiebeGast(verwaltung, null, JETZT))).status, 400)
assert.equal((await fehlerVon(() => verschiebeGast(verwaltung, undefined, JETZT))).status, 400)
// nicht im Raster, ausserhalb der Oeffnungszeit, an einem Tag ohne Plan: 409 Zeit vergeben
for (const roh of [{ tag: TAG_FERN, zeit: '10:07' }, { tag: TAG_FERN, zeit: '08:00' }, { tag: '2026-10-27', zeit: '10:00' }]) {
const err = await fehlerVon(() => verschiebeGast(verwaltung, roh, JETZT))
assert.equal(err.status, 409, JSON.stringify(roh))
assert.equal(err.code, 'SLOT_VERGEBEN')
}
// dieselbe Zeit: nichts zu tun
const gleich = await fehlerVon(() => verschiebeGast(verwaltung, { tag: TAG_FERN, zeit: '10:00' }, JETZT))
assert.equal(gleich.status, 400)
assert.ok(gleich.fehler.zeit)
assert.equal(buchung(id).icsSequenz, 0)
assert.deepEqual(arten(), ['G01', 'G02', 'K20'])
})
test('verschiebeGast: Person nicht waehlbar => auf einen anderen Platz, wenn der gleiche belegt ist; sonst bleibt die Ressource', async () => {
const frei = await speichere(einstellung({ regeln: { ...eingabeEinstellung().regeln, personWaehlbar: false } }))
const a = await gebucht(frei, { zeit: '10:00' })
const b = await gebucht(frei, { zeit: '14:00', email: 'zwei@example.com' }) // R1 14:00-14:50
assert.equal(a.buchung.ressourceId, R1)
assert.equal(b.buchung.ressourceId, R1)
// 11:00: R1 und R2 frei => die eigene Ressource bleibt
await verschiebeGast(a.verwaltung, { tag: TAG_FERN, zeit: '11:00' }, JETZT)
assert.equal(buchung(a.id).ressourceId, R1)
// 14:00: R1 ist belegt, R2 frei => Wechsel zu Ben; Zellen nur noch auf R2, auch zur selben Zeit nicht doppelt
const erg = await verschiebeGast(a.verwaltung, { tag: TAG_FERN, zeit: '14:00' }, JETZT)
await warteAufVersand()
assert.equal(erg.status, 'bestaetigt')
const neu = buchung(a.id)
assert.equal(neu.ressourceId, R2)
assert.equal(neu.ressourceName, 'Ben')
assert.equal(claims(a.id).length, 10)
assert.ok(claims(a.id).every((c) => c.ressourceId === R2))
assert.deepEqual(zeitenIso(claims(a.id)), zellenFuer(neu.startsAt, neu.blockEndsAt).map(iso))
assert.equal(claims(b.id).length, 10, 'die Zellen der anderen Buchung bleiben')
// zurueck auf die Zeit von vorhin: R1 ist wieder die bevorzugte? Nein - die eigene Ressource ist jetzt R2 und frei
await verschiebeGast(a.verwaltung, { tag: TAG_FERN, zeit: '10:00' }, JETZT)
assert.equal(buchung(a.id).ressourceId, R2)
// Person waehlbar (Vorgabe): nur die eigene Ressource wird angeboten
const gewaehlt = await speichere()
const c = await gebucht(gewaehlt, { zeit: '12:00', email: 'drei@example.com' })
await gebucht(gewaehlt, { zeit: '16:00', email: 'vier@example.com' }) // R1 16:00-16:50
const err = await fehlerVon(() => verschiebeGast(c.verwaltung, { tag: TAG_FERN, zeit: '16:00' }, JETZT))
assert.equal(err.code, 'SLOT_VERGEBEN')
assert.equal(buchung(c.id).ressourceId, R1)
})
test('verschiebeGast: zwei Gaeste verschieben gleichzeitig auf dieselbe Zeit => genau einer gewinnt, der andere behaelt alles', async () => {
const ctx = await speichere()
const a = await gebucht(ctx, { zeit: '10:00' })
const b = await gebucht(ctx, { zeit: '12:00', email: 'zwei@example.com' })
const standB = JSON.stringify(zeitenIso(claims(b.id)))
const ergebnisse = await Promise.allSettled([
verschiebeGast(a.verwaltung, { tag: TAG_FERN, zeit: '15:00' }, JETZT),
verschiebeGast(b.verwaltung, { tag: TAG_FERN, zeit: '15:00' }, JETZT),
])
await warteAufVersand()
assert.deepEqual(ergebnisse.map((x) => x.status).sort(), ['fulfilled', 'rejected'])
const verlierer = ergebnisse[0].status === 'rejected' ? ergebnisse[0] : ergebnisse[1]
assert.equal(verlierer.reason.code, 'SLOT_VERGEBEN')
const [gewinnerId, verliererId] = ergebnisse[0].status === 'fulfilled' ? [a.id, b.id] : [b.id, a.id]
assert.equal(iso(buchung(gewinnerId).startsAt), '2026-10-26T14:00:00.000Z')
assert.equal(claims(gewinnerId).length, 10)
assert.equal(claims(verliererId).length, 10)
if (verliererId === b.id) assert.equal(JSON.stringify(zeitenIso(claims(b.id))), standB)
assert.equal(buchung(verliererId).icsSequenz, 0)
assert.equal(eintraege().filter((d) => d.art === 'G07').length, 1)
})
test('verschiebeGast: Doppelklick auf dieselbe neue Zeit verschiebt einmal (Sequenz 1, eine G07)', async () => {
const ctx = await speichere()
const { id, verwaltung } = await gebucht(ctx)
const ergebnisse = await Promise.allSettled([
verschiebeGast(verwaltung, { tag: TAG_FERN, zeit: '10:30' }, JETZT),
verschiebeGast(verwaltung, { tag: TAG_FERN, zeit: '10:30' }, JETZT),
])
await warteAufVersand()
assert.deepEqual(ergebnisse.map((x) => x.status).sort(), ['fulfilled', 'rejected'])
assert.equal(ergebnisse.find((x) => x.status === 'rejected').reason.status, 400)
assert.equal(buchung(id).icsSequenz, 1)
assert.equal(eintraege().filter((d) => d.art === 'G07').length, 1)
assert.equal(claims(id).length, 10)
})
test('verschiebeGast: scheitert das Speichern der Buchung, werden die neuen Zellen wieder entfernt', async () => {
const ctx = await speichere()
const { id, verwaltung } = await gebucht(ctx)
const standVorher = claimsStand()
const docVorher = JSON.stringify(buchung(id))
const mails = sink.sent.length
aw.failNext({ collection: 'termineBookings', method: 'PATCH', status: 500 })
const err = await fehlerVon(() => verschiebeGast(verwaltung, { tag: TAG_FERN, zeit: '10:30' }, JETZT))
assert.equal(err.status, 500)
assert.equal(JSON.stringify(buchung(id)), docVorher)
assert.equal(claimsStand(), standVorher, 'nur die alten Zellen')
assert.equal(sink.sent.length, mails)
})
test('verschiebeGast: scheitern die Mails, bleibt die neue Zeit bestehen; Dauer und Pause bleiben die der Buchung', async () => {
const ctx = await speichere()
const { id, verwaltung } = await gebucht(ctx) // 45 + 5 Minuten
// der Betrieb aendert die Leistung nachtraeglich auf 60 + 10 Minuten
await speichere(einstellung({ leistungen: [{ ...eingabeEinstellung().leistungen[0], dauer: 60, pufferNach: 10 }, eingabeEinstellung().leistungen[1]] }))
const erg = await mitDefektemPostausgang(() => verschiebeGast(verwaltung, { tag: TAG_FERN, zeit: '10:30' }, JETZT))
assert.equal(erg.status, 'bestaetigt')
const b = buchung(id)
assert.equal(iso(b.startsAt), '2026-10-26T09:30:00.000Z')
assert.equal(iso(b.endsAt), '2026-10-26T10:15:00.000Z', 'weiter 45 Minuten')
assert.equal(iso(b.blockEndsAt), '2026-10-26T10:20:00.000Z', 'weiter 5 Minuten Pause')
assert.equal(claims(id).length, 10)
const protokoll = JSON.stringify(log.error.mock.calls.map((c) => c.arguments))
assert.match(protokoll, /G07/)
assert.match(protokoll, /K21/)
assert.doesNotMatch(protokoll, /max@example\.com|Mustermann/)
})
test('verschiebeGast: eine fuer die alte Zeit gesendete Erinnerung wird zurueckgesetzt, damit die neue Zeit wieder eine bekommt', async () => {
const ctx = await speichere()
const { id, buchung: b, verwaltung } = await gebucht(ctx, { erinnerung: true })
const keine = await gebucht(ctx, { zeit: '14:00', email: 'zwei@example.com' })
aw.seed('termineBookings', { ...b, erinnerungGesendetAt: iso(plus(JETZT, 60 * MIN)) })
await verschiebeGast(verwaltung, { tag: TAG_FERN, zeit: '10:30' }, JETZT)
assert.equal(buchung(id).erinnerungGesendetAt ?? null, null)
assert.equal(buchung(id).erinnerung, true, 'der Wunsch nach einer Erinnerung bleibt')
await verschiebeGast(keine.verwaltung, { tag: TAG_FERN, zeit: '15:00' }, JETZT)
assert.equal(buchung(keine.id).erinnerungGesendetAt ?? null, null)
})
test('verschiebeGast: ohne Zustimmung zum vorzeitigen Beginn nicht in die Widerrufsfrist einer kostenpflichtigen Verbraucherbuchung (R28)', async () => {
const plan = { 1: [['09:00', '18:00']], 2: [['09:00', '18:00']] }
const ctx = await speichere(einstellung({ ressourcen: [{ id: R1, name: 'Anna', plan }, { id: R2, name: 'Ben', plan }] }))
const a = await gebucht(ctx) // Mo 26.10. 10:00, Vertrag Mo 05.10. => Fristende Mo 19.10.; 21 Tage Vorlauf: keine Zustimmung noetig gewesen
assert.equal(JSON.parse(a.buchung.nachweis).vorzeitig, undefined)
const standVorher = claimsStand()
const docVorher = JSON.stringify(buchung(a.id))
const mails = sink.sent.length
// innerhalb der Frist (auch am letzten Tag, 17:00 Uhr): 409, nichts aendert sich
for (const [tag, zeit] of [['2026-10-12', '11:00'], ['2026-10-19', '17:00']]) {
const err = await fehlerVon(() => verschiebeGast(a.verwaltung, { tag, zeit }, JETZT))
assert.equal(err.status, 409, `${tag} ${zeit}`)
assert.equal(err.code, 'zustimmung_noetig')
assert.deepEqual(err.fehler, { _: 'Dieser Termin liegt innerhalb der Widerrufsfrist. Bitte neu buchen oder anrufen: 06301 1234.' })
}
assert.equal(claimsStand(), standVorher)
assert.equal(JSON.stringify(buchung(a.id)), docVorher)
assert.equal(sink.sent.length, mails)
assert.equal(eintraege().filter((d) => d.art === 'G07').length, 0)
// am Tag nach dem Fristende geht es
const erg = await verschiebeGast(a.verwaltung, { tag: '2026-10-20', zeit: '09:00' }, JETZT)
assert.deepEqual(erg.termin, { datum: 'Dienstag, 20.10.2026', zeit: '09:00' })
assert.equal(iso(buchung(a.id).startsAt), '2026-10-20T07:00:00.000Z')
// mit der Zustimmung zum vorzeitigen Beginn (beim Buchen gegeben) ist es erlaubt
const b = await gebucht(ctx, { ...NAH, zeit: '12:00', email: 'zwei@example.com' })
assert.ok(JSON.parse(b.buchung.nachweis).vorzeitig)
await verschiebeGast(b.verwaltung, { tag: '2026-10-19', zeit: '17:00' }, JETZT)
assert.equal(iso(buchung(b.id).startsAt), '2026-10-19T15:00:00.000Z')
// ohne Preis, als Unternehmer oder bei einer Anfrage gilt die Regel nicht
const frei = await speichere(
einstellung({
ressourcen: [{ id: R1, name: 'Anna', plan }, { id: R2, name: 'Ben', plan }],
leistungen: [...eingabeEinstellung().leistungen, { id: 's_bbbbbbb3', name: 'Kennenlernen', dauer: 30, preisArt: 'keiner', buchungsart: 'sofort', ressourcen: [R1, R2] }],
}),
)
const gratis = await gebucht(frei, { leistung: 's_bbbbbbb3', zeit: '14:00', email: 'drei@example.com' })
await verschiebeGast(gratis.verwaltung, { tag: '2026-10-12', zeit: '11:00' }, JETZT)
assert.equal(iso(buchung(gratis.id).startsAt), '2026-10-12T09:00:00.000Z')
const anfrage = await gebucht(frei, { leistung: S_ANFRAGE, zeit: '16:00', email: 'vier@example.com' })
await verschiebeGast(anfrage.verwaltung, { tag: '2026-10-12', zeit: '15:00' }, JETZT)
assert.equal(iso(buchung(anfrage.id).startsAt), '2026-10-12T13:00:00.000Z')
const unternehmer = await speichere(einstellung({ ressourcen: [{ id: R1, name: 'Anna', plan }, { id: R2, name: 'Ben', plan }], recht: { gaesteSind: 'unternehmer', freizeit: false } }))
const u = await gebucht(unternehmer, { zeit: '16:00', email: 'fuenf@example.com' })
assert.equal(u.buchung.verbraucher, false)
await verschiebeGast(u.verwaltung, { tag: '2026-10-12', zeit: '09:00' }, JETZT)
assert.equal(iso(buchung(u.id).startsAt), '2026-10-12T07:00:00.000Z')
})
test('verschiebeGast: Token, Modus und Uhr werden geprueft; abgesagte Termine lassen sich nicht verschieben', async () => {
const ctx = await speichere()
const { id, verwaltung, token } = await gebucht(ctx)
const roh = { tag: TAG_FERN, zeit: '10:30' }
for (const falsch of ['', 'A'.repeat(43), null, token]) assert.equal((await fehlerVon(() => verschiebeGast(falsch, roh, JETZT))).status, 404)
await assert.rejects(() => verschiebeGast(verwaltung, roh, undefined), TypeError)
await sageAbGast(verwaltung, JETZT)
const err = await fehlerVon(() => verschiebeGast(verwaltung, roh, JETZT))
assert.deepEqual([err.status, err.code], [409, 'nicht_moeglich'])
assert.equal(buchung(id).status, 'abgesagt')
assert.equal(claims(id).length, 0)
})
// ---------------------------------------------------------------- Zeiten fuer das Verschieben
test('zeitenFuerToken { tag }: freie Zeiten ohne die eigene Buchung (die eigene Zeit ist wieder frei), nur HH:MM', async () => {
const ctx = await speichere()
const a = await gebucht(ctx) // R1 10:00-10:50
await gebucht(ctx, { zeit: '12:00', email: 'zwei@example.com' }) // R1 12:00-12:50
const erg = await zeitenFuerToken(a.verwaltung, { tag: TAG_FERN }, JETZT)
assert.deepEqual(Object.keys(erg), ['zeiten'])
for (const z of ['09:00', '10:00', '10:15', '10:30', '11:00']) assert.ok(erg.zeiten.includes(z), `${z} ist frei`)
for (const z of ['11:15', '11:30', '11:45', '12:00', '12:30']) assert.ok(!erg.zeiten.includes(z), `${z} ueberlappt die andere Buchung (12:00, Block 50 Minuten)`)
assert.ok(erg.zeiten.includes('13:00'))
assert.deepEqual([...erg.zeiten].sort(), erg.zeiten, 'aufsteigend')
assert.ok(erg.zeiten.every((z) => /^\d\d:\d\d$/.test(z)))
assert.equal(JSON.stringify(erg).includes(FEATURE), false)
})
test('zeitenFuerToken { monat }: Tage mit freien Zeiten und Monatsdaten; die eigene Zeit zaehlt als frei', async () => {
// Montag nur 10:00-10:50 offen: genau eine Startzeit, die der Termin selbst belegt
const eng = einstellung({
ressourcen: [{ id: R1, name: 'Anna', plan: { 1: [['10:00', '10:50']] } }],
leistungen: [{ id: S_SOFORT, name: 'Damenschnitt', dauer: 45, pufferNach: 5, preisArt: 'fest', preisCent: 4500, ressourcen: [R1] }],
})
const ctx = await speichere(eng)
const a = await gebucht(ctx, { zustimmungen: { storno: true, vorzeitig: false } })
const erg = await zeitenFuerToken(a.verwaltung, { monat: '2026-10' }, JETZT)
assert.deepEqual(Object.keys(erg).sort(), ['monat', 'tage'])
assert.deepEqual(erg.monat, monatInfo('2026-10'))
assert.ok(erg.tage.includes(TAG_FERN), 'der Tag des eigenen Termins bleibt waehlbar')
assert.ok(erg.tage.includes('2026-10-19'))
assert.ok(!erg.tage.includes('2026-10-27'), 'Dienstag hat keinen Plan')
assert.deepEqual([...erg.tage].sort(), erg.tage)
assert.ok(erg.tage.every((t) => t.startsWith('2026-10-')))
const tag = await zeitenFuerToken(a.verwaltung, { tag: TAG_FERN }, JETZT)
assert.deepEqual(tag.zeiten, ['10:00'])
})
test('zeitenFuerToken: Eingaben, Token, Frist und Uhr', async () => {
const ctx = await speichere()
const a = await gebucht(ctx)
for (const roh of [{}, { tag: 'x' }, { monat: '2026-13' }, { monat: 5 }, null, undefined]) {
const err = await fehlerVon(() => zeitenFuerToken(a.verwaltung, roh, JETZT))
assert.equal(err.status, 400, JSON.stringify(roh))
}
assert.ok((await fehlerVon(() => zeitenFuerToken(a.verwaltung, { tag: 'x' }, JETZT))).fehler.tag)
assert.ok((await fehlerVon(() => zeitenFuerToken(a.verwaltung, { monat: '2026-13' }, JETZT))).fehler.monat)
assert.equal((await fehlerVon(() => zeitenFuerToken(a.token, { tag: TAG_FERN }, JETZT))).status, 404, 'Bestaetigungstoken genuegt nicht')
assert.equal((await fehlerVon(() => zeitenFuerToken('A'.repeat(43), { tag: TAG_FERN }, JETZT))).status, 404)
// nach der Absagefrist wird nichts angeboten
const spaet = plus(new Date(a.buchung.startsAt), -60 * MIN)
assert.equal((await fehlerVon(() => zeitenFuerToken(a.verwaltung, { tag: TAG_FERN }, spaet))).code, 'frist_vorbei')
await assert.rejects(() => zeitenFuerToken(a.verwaltung, { tag: TAG_FERN }, undefined), TypeError)
assert.equal(buchung(a.id).status, 'bestaetigt', 'die Abfrage aendert nichts')
})
// ---------------------------------------------------------------- Widerruf per Link
test('kannWiderrufen: sofort ab bestaetigtAt, angenommene Anfrage ab angenommenAt; Fristende, Beginn, Verbraucher, Handeintrag', () => {
const basis = { buchungsart: 'sofort', verbraucher: true, status: 'bestaetigt', quelle: 'online', bestaetigtAt: iso(JETZT), startsAt: iso(plus(JETZT, 30 * TAG_MS)) }
// Vertrag Montag 05.10.: Fristende Montag 19.10., 23:59:59,999 Uhr Berliner Zeit (MESZ) = 21:59:59,999 UTC
assert.equal(kannWiderrufen(basis, new Date('2026-10-19T21:59:59.999Z')), true)
assert.equal(kannWiderrufen(basis, new Date('2026-10-19T22:00:00.000Z')), false)
assert.equal(kannWiderrufen(basis, plus(new Date(basis.startsAt), 0)), false, 'Termin hat begonnen')
assert.equal(kannWiderrufen({ ...basis, startsAt: iso(plus(JETZT, 3 * TAG_MS)) }, plus(JETZT, 3 * TAG_MS - 1)), true, 'bis zum Beginn')
assert.equal(kannWiderrufen({ ...basis, verbraucher: false }, JETZT), false)
assert.equal(kannWiderrufen({ ...basis, status: 'angefragt' }, JETZT), false)
assert.equal(kannWiderrufen({ ...basis, status: 'abgesagt' }, JETZT), false)
assert.equal(kannWiderrufen({ ...basis, quelle: 'manuell' }, JETZT), false)
assert.equal(kannWiderrufen({ ...basis, bestaetigtAt: undefined }, JETZT), false)
const anfrage = { ...basis, buchungsart: 'anfrage', bestaetigtAt: undefined, angenommenAt: iso(plus(JETZT, 2 * TAG_MS)) }
assert.equal(kannWiderrufen(anfrage, plus(JETZT, 3 * TAG_MS)), true, 'angenommene Anfrage')
// Annahme Mittwoch 07.10.: Fristende Mittwoch 21.10. (21:59:59,999 UTC)
assert.equal(kannWiderrufen(anfrage, new Date('2026-10-21T21:59:59.999Z')), true, 'die Frist zaehlt ab der Annahme')
assert.equal(kannWiderrufen(anfrage, new Date('2026-10-21T22:00:00.000Z')), false)
assert.equal(kannWiderrufen({ ...anfrage, angenommenAt: undefined }, JETZT), false, 'ohne Annahme kein Vertrag')
assert.equal(kannWiderrufen({ ...anfrage, bestaetigtAt: iso(JETZT), angenommenAt: undefined }, JETZT), false, 'bestaetigtAt allein macht eine Anfrage nicht verbindlich')
})
test('kannWiderrufen: Fristende nach §§ 187, 188, 193 BGB im Berliner Kalender - Zeitumstellung, Wochenende, Feiertag, Tageswechsel', () => {
const buchungAm = (vertrag) => ({ buchungsart: 'sofort', verbraucher: true, status: 'bestaetigt', quelle: 'online', bestaetigtAt: vertrag, startsAt: '2027-02-01T09:00:00.000Z' })
const grenze = (vertrag, letzterMoment) => {
const b = buchungAm(vertrag)
const ende = new Date(letzterMoment)
assert.equal(kannWiderrufen(b, ende), true, `${vertrag}: noch ${letzterMoment}`)
assert.equal(kannWiderrufen(b, new Date(ende.getTime() + 1)), false, `${vertrag}: nach ${letzterMoment} nicht mehr`)
}
// Sommerzeit endet am 25.10.: Vertrag Montag 12.10. 10:00 MESZ => letzter Tag Montag 26.10. (MEZ), 23:59:59,999 = 22:59:59,999 UTC.
// (14 x 24 Stunden wuerden schon am 26.10. um 08:00 UTC enden)
grenze('2026-10-12T08:00:00.000Z', '2026-10-26T22:59:59.999Z')
// Sommerzeit beginnt am 29.03.: Vertrag Montag 16.03. 10:00 MEZ => letzter Tag Montag 30.03. (MESZ), 21:59:59,999 UTC
grenze('2026-03-16T09:00:00.000Z', '2026-03-30T21:59:59.999Z')
// der 14. Tag ist ein Samstag (24.10.) => Montag 26.10.; der Sonntag dazwischen zaehlt noch mit
grenze('2026-10-10T10:00:00.000Z', '2026-10-26T22:59:59.999Z')
assert.equal(kannWiderrufen(buchungAm('2026-10-10T10:00:00.000Z'), new Date('2026-10-25T10:00:00.000Z')), true)
// der 14. Tag ist Freitag, 25.12. (Feiertag), danach Samstag 26.12. (Feiertag) und Sonntag => Montag 28.12.
grenze('2026-12-11T10:00:00.000Z', '2026-12-28T22:59:59.999Z')
// Vertrag um 23:30 MESZ am 05.10. (21:30 UTC) zaehlt noch fuer den 05.10., um 00:30 MESZ am 06.10. (22:30 UTC am 05.10.) fuer den 06.10.
grenze('2026-10-05T21:30:00.000Z', '2026-10-19T21:59:59.999Z')
grenze('2026-10-05T22:30:00.000Z', '2026-10-20T21:59:59.999Z')
})
test('widerrufeGast: 2 Stunden vor dem Termin trotz abgelaufener Stornofrist; abgesagt von gast, Zellen frei, G08 zugeordnet, K21 widerrufen', async () => {
const ctx = await speichere()
const { id, buchung: b, verwaltung, mailStart } = await gebucht(ctx, NAH)
const jetzt = plus(new Date(b.startsAt), -2 * 60 * MIN) // 12.10.2026 06:00Z = 08:00 MESZ
assert.equal((await fehlerVon(() => sageAbGast(verwaltung, jetzt))).code, 'frist_vorbei', 'Absagen waere zu spaet')
assert.equal((await vorschau(verwaltung, jetzt)).aktionen.widerrufen, true)
const erg = await widerrufeGast(verwaltung, { name: ' Max Mustermann ' }, jetzt)
await warteAufVersand()
assert.deepEqual(erg, { eingang: { datum: '12.10.2026', uhrzeit: '08:00' } })
const nachher = buchung(id)
assert.equal(nachher.status, 'abgesagt')
assert.equal(nachher.abgesagtVon, 'gast')
assert.equal(iso(nachher.widerrufenAt), iso(jetzt))
assert.equal(iso(nachher.abgesagtAt), iso(jetzt))
assert.match(nachher.absageGrund, /Widerruf/)
assert.match(nachher.absageGrund, /Max Mustermann/)
assert.equal(claims(id).length, 0)
const neu = sink.sent.slice(mailStart + 2)
assert.equal(neu.length, 2)
const g08 = an(neu, 'max@example.com')[0]
assert.equal(g08.subject, 'Eingangsbestätigung Ihres Widerrufs')
assert.match(g08.text, /12\.10\.2026, 08:00 Uhr/)
assert.ok(g08.text.includes(buchungsNummer(id)))
assert.match(g08.text, /Der Termin ist abgesagt/)
assert.doesNotMatch(g08.text, /Mustermann/, 'kein Text des Gastes in G08')
assert.equal('icalEvent' in g08, false)
const k21 = an(neu, 'info@salon.de')[0]
assert.equal(k21.subject, 'Termin widerrufen: Damenschnitt am 12.10.2026 um 10:00')
assert.match(k21.text, /12\.10\.2026, 08:00 Uhr/)
assert.deepEqual(arten(), ['G01', 'G02', 'G08', 'K20', 'K21'])
})
test('widerrufeGast: Eingang in Berliner Zeit (22:30 UTC = 00:30 am Folgetag)', async () => {
const ctx = await speichere()
const { verwaltung } = await gebucht(ctx)
const erg = await widerrufeGast(verwaltung, { name: 'Max' }, new Date('2026-10-10T22:30:00.000Z'))
assert.deepEqual(erg, { eingang: { datum: '11.10.2026', uhrzeit: '00:30' } })
})
test('widerrufeGast: die Frist gilt ab der Bestaetigung bis Fristende; am letzten Moment wirksam, danach nur gespeichert und gemeldet (nach_frist)', async () => {
const ctx = await speichere()
const a = await gebucht(ctx) // Termin 21 Tage nach der Buchung
const b = await gebucht(ctx, { zeit: '12:00', email: 'zwei@example.com' })
const vorher = sink.sent.length
// Vertrag Montag 05.10. => Fristende Montag 19.10., 23:59:59,999 MESZ
const fristEnde = new Date('2026-10-19T21:59:59.999Z')
assert.deepEqual(await widerrufeGast(a.verwaltung, { name: 'Max' }, fristEnde), { eingang: { datum: '19.10.2026', uhrzeit: '23:59' } })
assert.equal(buchung(a.id).status, 'abgesagt')
const erg = await widerrufeGast(b.verwaltung, { name: 'Erika Beispiel' }, plus(fristEnde, 1))
await warteAufVersand()
assert.deepEqual(erg, { eingang: { datum: '20.10.2026', uhrzeit: '00:00' } })
const nachher = buchung(b.id)
assert.equal(nachher.status, 'bestaetigt', 'der Termin bleibt')
assert.equal(nachher.widerrufenAt, undefined)
assert.equal(claims(b.id).length, 10)
assert.match(nachher.absageGrund, /nach Ablauf der Frist/)
assert.match(nachher.absageGrund, /Erika Beispiel/)
const neu = sink.sent.slice(vorher).filter((m) => /Widerruf/i.test(m.subject))
assert.equal(neu.length, 4)
const g08 = an(neu, 'zwei@example.com')[0]
assert.equal(g08.subject, 'Eingangsbestätigung Ihres Widerrufs')
assert.match(g08.text, /Widerrufsfrist bei Eingang Ihrer Erklärung bereits abgelaufen/)
assert.doesNotMatch(g08.text, /Der Termin ist abgesagt/)
assert.equal(an(neu, 'info@salon.de').find((m) => /nach Ablauf/i.test(m.subject)).subject, 'Widerruf nach Ablauf der Frist: Damenschnitt am 26.10.2026 um 12:00')
})
test('Widerruf nach Terminbeginn bei noch laufender Frist (R27): vermerkt, G08 ohne "abgelaufen", K21 begonnen, Termin bleibt; nach Fristende nach_frist', async () => {
const ctx = await speichere()
const a = await gebucht(ctx, NAH) // 12.10.2026 10:00 MESZ, Vertrag 05.10.: die Frist laeuft bis 19.10.
const b = await gebucht(ctx, { ...NAH, zeit: '12:00', email: 'zwei@example.com' })
const antwortA = { eingang: { datum: '12.10.2026', uhrzeit: '10:20' } }
const vorher = sink.sent.length
// ohne Link, 20 Minuten nach Beginn
const jetztA = plus(new Date(a.buchung.startsAt), 20 * MIN)
assert.deepEqual(await widerrufeOhneLink(ctx, { name: 'Max Mustermann', email: 'max@example.com', nummer: buchungsNummer(a.id) }, jetztA), antwortA)
// mit Link, 10 Minuten nach Beginn
const jetztB = plus(new Date(b.buchung.startsAt), 10 * MIN)
assert.deepEqual(await widerrufeGast(b.verwaltung, { name: 'Erika Beispiel' }, jetztB), { eingang: { datum: '12.10.2026', uhrzeit: '12:10' } })
await warteAufVersand()
for (const [x, grund] of [[a, 'Max Mustermann'], [b, 'Erika Beispiel']]) {
const nachher = buchung(x.id)
assert.equal(nachher.status, 'bestaetigt', 'der Termin bleibt')
assert.equal(nachher.widerrufenAt, undefined)
assert.equal(claims(x.id).length, 10)
assert.match(nachher.absageGrund, /nach Terminbeginn/)
assert.ok(nachher.absageGrund.includes(grund))
}
const neu = sink.sent.slice(vorher)
assert.equal(neu.length, 4)
const g08 = an(neu, 'max@example.com')[0]
assert.equal(g08.subject, 'Eingangsbestätigung Ihres Widerrufs')
assert.match(g08.text, /12\.10\.2026, 10:20 Uhr/)
assert.match(g08.text, /prüft Ihre Erklärung und meldet sich bei Ihnen/)
assert.doesNotMatch(g08.text, /abgelaufen|Der Termin ist abgesagt/)
assert.ok(!g08.text.includes(buchungsNummer(a.id)), 'neutraler Satz, keine Zusage zur Buchung')
const k21 = an(neu, 'info@salon.de').find((m) => m.text.includes('max@example.com'))
assert.equal(k21.subject, 'Widerruf nach Terminbeginn: Damenschnitt am 12.10.2026 um 10:00')
assert.match(k21.text, /Ein Widerruf ist eingegangen, nachdem der Termin bereits begonnen hatte\./)
assert.match(k21.text, /wertersatz|Wertersatz/)
assert.doesNotMatch(k21.text, /Widerrufsfrist bei Eingang bereits abgelaufen/)
assert.equal(eintraege().filter((d) => d.art === 'G08').length, 2)
// nach Fristende (Montag 19.10., 23:59:59,999) dieselbe Erklaerung: jetzt wirklich nach der Frist
const vorher2 = sink.sent.length
await widerrufeOhneLink(ctx, { name: 'Max Mustermann', email: 'max@example.com', nummer: buchungsNummer(a.id) }, new Date('2026-10-19T22:00:00.000Z'))
await warteAufVersand()
const spaet = sink.sent.slice(vorher2)
assert.match(an(spaet, 'max@example.com')[0].text, /Widerrufsfrist bei Eingang Ihrer Erklärung bereits abgelaufen/)
assert.equal(an(spaet, 'info@salon.de')[0].subject, 'Widerruf nach Ablauf der Frist: Damenschnitt am 12.10.2026 um 10:00')
assert.match(buchung(a.id).absageGrund, /nach Ablauf der Frist/)
})
test('widerrufeGast: eine nicht angenommene Anfrage kann nicht widerrufen werden (409), nur absagen; ebenso Unternehmer', async () => {
const ctx = await speichere()
const anfrage = await gebucht(ctx, { leistung: S_ANFRAGE })
assert.equal(anfrage.buchung.status, 'angefragt')
const mails = sink.sent.length
const standVorher = claimsStand()
const err = await fehlerVon(() => widerrufeGast(anfrage.verwaltung, { name: 'Max' }, JETZT))
assert.deepEqual([err.status, err.code], [409, 'kein_widerruf'])
assert.ok(err.fehler._)
assert.equal(buchung(anfrage.id).status, 'angefragt')
assert.equal(buchung(anfrage.id).widerrufenAt, undefined)
assert.equal(buchung(anfrage.id).absageGrund, undefined)
assert.equal(claimsStand(), standVorher)
assert.equal(sink.sent.length, mails)
assert.deepEqual(await sageAbGast(anfrage.verwaltung, JETZT), { status: 'abgesagt' })
const unternehmer = await speichere(einstellung({ recht: { gaesteSind: 'unternehmer', freizeit: false } }))
const u = await gebucht(unternehmer, { zeit: '14:00', email: 'zwei@example.com' })
assert.equal(u.buchung.verbraucher, false)
assert.equal((await fehlerVon(() => widerrufeGast(u.verwaltung, { name: 'Max' }, JETZT))).code, 'kein_widerruf')
assert.equal(buchung(u.id).status, 'bestaetigt')
})
test('widerrufeGast: eine vom Betrieb angenommene Anfrage ist widerrufbar, die Frist laeuft ab angenommenAt', async () => {
const ctx = await speichere()
const { id, buchung: b, verwaltung } = await gebucht(ctx, { leistung: S_ANFRAGE })
// Annahme durch den Betrieb (Aufgabe 14) von Hand nachgestellt
const angenommen = plus(JETZT, 2 * TAG_MS)
aw.seed('termineBookings', { ...b, status: 'bestaetigt', angenommenAt: iso(angenommen) })
const fristEnde = new Date('2026-10-21T21:59:59.999Z') // Annahme Mittwoch 07.10. => Fristende Mittwoch 21.10.
assert.equal((await vorschau(verwaltung, fristEnde)).aktionen.widerrufen, true)
assert.equal((await vorschau(verwaltung, plus(fristEnde, 1))).aktionen.widerrufen, false)
const jetzt = plus(angenommen, 13 * TAG_MS) // 20.10.2026, der Termin am 26.10. liegt noch in der Zukunft
const erg = await widerrufeGast(verwaltung, { name: 'Max' }, jetzt)
assert.ok(erg.eingang)
assert.equal(buchung(id).status, 'abgesagt')
assert.equal(iso(buchung(id).widerrufenAt), iso(jetzt))
assert.equal(claims(id).length, 0)
})
test('widerrufeGast: zweiter und paralleler Klick melden denselben Eingang und senden nichts doppelt', async () => {
const ctx = await speichere()
const { id, verwaltung } = await gebucht(ctx)
const erste = plus(JETZT, 60 * MIN)
const erg = await Promise.all([widerrufeGast(verwaltung, { name: 'Max' }, erste), widerrufeGast(verwaltung, { name: 'Max' }, erste)])
await warteAufVersand()
assert.deepEqual(erg[0], erg[1])
assert.deepEqual(arten(), ['G01', 'G02', 'G08', 'K20', 'K21'])
const spaeter = await widerrufeGast(verwaltung, { name: 'Max' }, plus(erste, 3 * 60 * MIN))
await warteAufVersand()
assert.deepEqual(spaeter, erg[0], 'der Eingang der ersten Erklaerung')
assert.deepEqual(arten(), ['G01', 'G02', 'G08', 'K20', 'K21'])
assert.equal(iso(buchung(id).widerrufenAt), iso(erste))
})
test('widerrufeGast: Name Pflicht (400), Token und Uhr; scheitern die Mails, bleibt der Widerruf bestehen', async () => {
const ctx = await speichere()
const { id, verwaltung, token } = await gebucht(ctx)
for (const name of ['', ' ', undefined, null, 5, 'x'.repeat(101)]) {
const err = await fehlerVon(() => widerrufeGast(verwaltung, { name }, JETZT))
assert.equal(err.status, 400, String(name).slice(0, 5))
assert.deepEqual(Object.keys(err.fehler), ['name'])
}
assert.equal((await fehlerVon(() => widerrufeGast(verwaltung, null, JETZT))).status, 400)
assert.equal((await fehlerVon(() => widerrufeGast(token, { name: 'Max' }, JETZT))).status, 404)
assert.equal((await fehlerVon(() => widerrufeGast('A'.repeat(43), { name: 'Max' }, JETZT))).status, 404)
await assert.rejects(() => widerrufeGast(verwaltung, { name: 'Max' }, undefined), TypeError)
assert.equal(buchung(id).status, 'bestaetigt')
const erg = await mitDefektemPostausgang(() => widerrufeGast(verwaltung, { name: 'Max' }, JETZT))
assert.ok(erg.eingang)
assert.equal(buchung(id).status, 'abgesagt')
const protokoll = JSON.stringify(log.error.mock.calls.map((c) => c.arguments))
assert.match(protokoll, /G08/)
assert.match(protokoll, /K21/)
assert.doesNotMatch(protokoll, /max@example\.com|Mustermann/)
})
// ---------------------------------------------------------------- Widerruf ohne Link
test('widerrufeOhneLink: Treffer mit Widerrufsrecht; Adresse klein und getrimmt, Nummer egal in welcher Schreibweise; G08 an die angegebene Adresse', async () => {
const ctx = await speichere()
const { id, buchung: b, mailStart } = await gebucht(ctx, NAH)
const jetzt = plus(new Date(b.startsAt), -2 * 60 * MIN)
const erg = await widerrufeOhneLink(ctx, { name: ' Max Mustermann ', email: ' Max@Example.COM ', nummer: ` ${buchungsNummer(id).toLowerCase()} ` }, jetzt)
await warteAufVersand()
assert.deepEqual(erg, { eingang: { datum: '12.10.2026', uhrzeit: '08:00' } })
const nachher = buchung(id)
assert.equal(nachher.status, 'abgesagt')
assert.equal(nachher.abgesagtVon, 'gast')
assert.equal(iso(nachher.widerrufenAt), iso(jetzt))
assert.equal(claims(id).length, 0)
const neu = sink.sent.slice(mailStart + 2)
const g08 = an(neu, 'max@example.com')[0]
assert.equal(g08.subject, 'Eingangsbestätigung Ihres Widerrufs')
assert.ok(g08.text.includes(buchungsNummer(id)))
assert.match(g08.text, /Der Termin ist abgesagt/)
assert.doesNotMatch(g08.text, /Mustermann/)
assert.equal(an(neu, 'info@salon.de')[0].subject, 'Termin widerrufen: Damenschnitt am 12.10.2026 um 10:00')
assert.deepEqual(arten(), ['G01', 'G02', 'G08', 'K20', 'K21'])
})
test('widerrufeOhneLink: ohne Treffer dieselbe Antwort; G08 ohne Bezug an die angegebene Adresse, K21 nicht_zugeordnet an den Betrieb', async () => {
const ctx = await speichere()
const { id, buchung: b, mailStart } = await gebucht(ctx, NAH)
const jetzt = plus(new Date(b.startsAt), -2 * 60 * MIN)
const doc = JSON.stringify(buchung(id))
const stand = claimsStand()
const richtig = await widerrufeOhneLink(ctx, { name: 'Erika', email: 'erika@example.com', nummer: 'ZZZZZZZZ' }, jetzt) // Nummer passt zu keiner Buchung
const fremdeAdresse = await widerrufeOhneLink(ctx, { name: 'Max', email: 'anderer@example.com', nummer: buchungsNummer(id) }, jetzt) // Nummer passt, Adresse nicht
const kaputt = await widerrufeOhneLink(ctx, { name: 'Hans', email: 'hans@example.com', nummer: 'abc' }, jetzt)
const leer = await widerrufeOhneLink(ctx, { name: 'Lena', email: 'lena@example.com', nummer: '' }, jetzt)
const keineZeichenkette = await widerrufeOhneLink(ctx, { name: 'Tom', email: 'tom@example.com', nummer: 12345678 }, jetzt)
await warteAufVersand()
const antworten = [richtig, fremdeAdresse, kaputt, leer, keineZeichenkette]
for (const a of antworten) assert.deepEqual(a, { eingang: { datum: '12.10.2026', uhrzeit: '08:00' } })
assert.equal(JSON.stringify(buchung(id)), doc, 'nichts an der Buchung geaendert')
assert.equal(claimsStand(), stand)
const neu = sink.sent.slice(mailStart + 2)
assert.equal(neu.length, 10, '5 x G08 + 5 x K21')
for (const adresse of ['erika@example.com', 'anderer@example.com', 'hans@example.com', 'lena@example.com', 'tom@example.com']) {
const g08 = an(neu, adresse)
assert.equal(g08.length, 1, adresse)
assert.equal(g08[0].subject, 'Eingangsbestätigung Ihres Widerrufs')
assert.doesNotMatch(g08[0].text, /Der Termin ist abgesagt|Widerrufsfrist|Buchung [A-Z0-9]{8}/)
assert.doesNotMatch(g08[0].text, /Erika|Hans|Lena|Tom\b|ZZZZZZZZ/, 'kein vom Gast eingegebener Text')
assert.ok(g08[0].text.includes('12.10.2026, 08:00 Uhr'))
}
const k21 = an(neu, 'info@salon.de')
assert.equal(k21.length, 5)
for (const m of k21) assert.equal(m.subject, 'Widerruf ohne passende Buchung')
const erika = k21.find((m) => m.text.includes('erika@example.com'))
assert.match(erika.text, /Erika/)
assert.match(erika.text, /ZZZZZZZZ/, 'die angegebene Nummer steht beim Betrieb zur Pruefung')
assert.equal('bcc' in erika, false)
assert.equal(eintraege().filter((d) => d.art === 'K21' && d.bookingId !== '-').length, 0)
})
test('widerrufeOhneLink: Treffer nach Ablauf der Frist => nur gespeichert, G08 nach_frist und K21 nach_frist, Termin bleibt', async () => {
const ctx = await speichere()
const { id, mailStart } = await gebucht(ctx)
const jetzt = plus(JETZT, 15 * TAG_MS)
const erg = await widerrufeOhneLink(ctx, { name: 'Max Mustermann', email: 'max@example.com', nummer: buchungsNummer(id) }, jetzt)
await warteAufVersand()
assert.deepEqual(erg, { eingang: { datum: '20.10.2026', uhrzeit: '10:00' } })
const b = buchung(id)
assert.equal(b.status, 'bestaetigt')
assert.equal(b.widerrufenAt, undefined)
assert.equal(claims(id).length, 10)
assert.match(b.absageGrund, /nach Ablauf der Frist/)
assert.match(b.absageGrund, /Max Mustermann/)
const neu = sink.sent.slice(mailStart + 2)
assert.match(an(neu, 'max@example.com')[0].text, /Widerrufsfrist bei Eingang Ihrer Erklärung bereits abgelaufen/)
assert.equal(an(neu, 'info@salon.de')[0].subject, 'Widerruf nach Ablauf der Frist: Damenschnitt am 26.10.2026 um 10:00')
})
test('widerrufeOhneLink: Buchung ohne Widerrufsrecht (nicht angenommene Anfrage, Unternehmer) => wie ohne Treffer, nichts geaendert', async () => {
const ctx = await speichere()
const anfrage = await gebucht(ctx, { leistung: S_ANFRAGE })
const unternehmer = await speichere(einstellung({ recht: { gaesteSind: 'unternehmer', freizeit: false } }))
const u = await gebucht(unternehmer, { zeit: '14:00', email: 'zwei@example.com' })
const vorher = sink.sent.length
const docA = JSON.stringify(buchung(anfrage.id))
const docU = JSON.stringify(buchung(u.id))
const a1 = await widerrufeOhneLink(ctx, { name: 'Max', email: 'max@example.com', nummer: buchungsNummer(anfrage.id) }, JETZT)
const a2 = await widerrufeOhneLink(unternehmer, { name: 'Zwei', email: 'zwei@example.com', nummer: buchungsNummer(u.id) }, JETZT)
await warteAufVersand()
assert.deepEqual(a1, a2)
assert.equal(JSON.stringify(buchung(anfrage.id)), docA)
assert.equal(JSON.stringify(buchung(u.id)), docU)
assert.equal(claims(anfrage.id).length, 12)
const neu = sink.sent.slice(vorher)
assert.equal(an(neu, 'info@salon.de').filter((m) => m.subject === 'Widerruf ohne passende Buchung').length, 2)
assert.equal(an(neu, 'max@example.com').length, 1)
})
test('widerrufeOhneLink: vierter G08 an dieselbe Adresse am Tag wird nicht gesendet, die Antwort bleibt gleich; am naechsten Tag wieder', async () => {
const ctx = await speichere()
const roh = { name: 'Max', email: 'max@example.com', nummer: 'ZZZZZZZZ' }
const antworten = []
for (let i = 0; i < 4; i += 1) antworten.push(await widerrufeOhneLink(ctx, roh, plus(JETZT, i * MIN)))
await warteAufVersand()
for (const a of antworten) assert.deepEqual(a, { eingang: { datum: '05.10.2026', uhrzeit: a.eingang.uhrzeit } })
assert.deepEqual(antworten.map((a) => a.eingang.uhrzeit), ['10:00', '10:01', '10:02', '10:03'], 'die Zeit der jeweiligen Erklaerung')
assert.equal(eintraege().filter((d) => d.art === 'G08').length, 3)
assert.equal(eintraege().filter((d) => d.art === 'K21').length, 4, 'der Betrieb bekommt jede Erklaerung')
const protokoll = JSON.stringify([...log.error.mock.calls, ...log.warn.mock.calls].map((c) => c.arguments))
assert.match(protokoll, /G08/)
assert.doesNotMatch(protokoll, /max@example\.com|Max\b/)
await widerrufeOhneLink(ctx, roh, plus(JETZT, TAG_MS))
await warteAufVersand()
assert.equal(eintraege().filter((d) => d.art === 'G08').length, 4)
})
test('widerrufeOhneLink: 20 G08 je Funktion und Stunde, 30 K21 nicht_zugeordnet je Funktion und Stunde; weitere werden still uebersprungen', async () => {
const ctx = await speichere()
const antworten = []
for (let i = 0; i < 32; i += 1) antworten.push(await widerrufeOhneLink(ctx, { name: `Gast ${i}`, email: `gast${i}@example.com`, nummer: 'ZZZZZZZZ' }, JETZT))
await warteAufVersand()
for (const a of antworten) assert.deepEqual(a, antworten[0])
assert.equal(eintraege().filter((d) => d.art === 'G08').length, 20)
assert.equal(eintraege().filter((d) => d.art === 'K21').length, 30)
// eine Stunde spaeter beginnen beide Fenster neu
await widerrufeOhneLink(ctx, { name: 'Spaet', email: 'spaet@example.com', nummer: 'ZZZZZZZZ' }, plus(JETZT, 60 * MIN))
await warteAufVersand()
assert.equal(eintraege().filter((d) => d.art === 'G08').length, 21)
assert.equal(eintraege().filter((d) => d.art === 'K21').length, 31)
})
test('widerrufeOhneLink: ein Treffer bekommt seine Mails immer und zaehlt nicht mit; die Grenzen gelten nur ohne Treffer (R30)', async () => {
const ctx = await speichere()
const a = await gebucht(ctx) // max@example.com
const anfrage = await gebucht(ctx, { leistung: S_ANFRAGE, zeit: '12:00', email: 'vier@example.com' }) // nicht angenommen: kein Widerrufsrecht
const g08 = () => eintraege().filter((d) => d.art === 'G08')
const k21 = () => eintraege().filter((d) => d.art === 'K21')
// Adresse max@ (3 Erklaerungen ins Leere), dann Funktion und K21-Grenze ausschoepfen
for (let i = 0; i < 3; i += 1) await widerrufeOhneLink(ctx, { name: 'Max', email: 'max@example.com', nummer: 'ZZZZZZZZ' }, JETZT)
for (let i = 0; i < 28; i += 1) await widerrufeOhneLink(ctx, { name: `Gast ${i}`, email: `gast${i}@example.com`, nummer: 'ZZZZZZZZ' }, JETZT)
await warteAufVersand()
assert.equal(g08().length, 20, 'G08: Funktionsgrenze erreicht')
const kNichtZugeordnet = k21().length
assert.equal(kNichtZugeordnet, 30, 'K21 nicht_zugeordnet: Grenze erreicht')
const nochEine = await widerrufeOhneLink(ctx, { name: 'Noch', email: 'neu@example.com', nummer: 'ZZZZZZZZ' }, JETZT)
await warteAufVersand()
assert.equal(g08().length, 20, 'ohne Treffer bleibt die Grenze')
assert.equal(k21().length, 30)
// Treffer mit Widerrufsrecht: G08 (an die Adresse mit erschoepftem Tageskontingent) und K21 gehen trotzdem raus
const erg = await widerrufeOhneLink(ctx, { name: 'Max', email: 'max@example.com', nummer: buchungsNummer(a.id) }, plus(JETZT, MIN))
await warteAufVersand()
assert.deepEqual(erg, { eingang: { datum: '05.10.2026', uhrzeit: '10:01' } })
assert.deepEqual(nochEine, { eingang: { datum: '05.10.2026', uhrzeit: '10:00' } })
assert.equal(buchung(a.id).status, 'abgesagt', 'der Widerruf ist wirksam')
assert.equal(g08().length, 21)
assert.equal(g08().filter((d) => d.bookingId === a.id).length, 1)
assert.equal(k21().filter((d) => d.bookingId === a.id).length, 1, 'der Betrieb erfaehrt davon')
// Treffer ohne Widerrufsrecht (nicht angenommene Anfrage): G08 und K21 nicht_zugeordnet ebenfalls ohne Grenze
await widerrufeOhneLink(ctx, { name: 'Vier', email: 'vier@example.com', nummer: buchungsNummer(anfrage.id) }, plus(JETZT, 2 * MIN))
await warteAufVersand()
assert.equal(g08().length, 22)
assert.equal(k21().length, 32)
assert.equal(buchung(anfrage.id).status, 'angefragt', 'die Anfrage bleibt unveraendert')
// der Treffer hat nichts verbraucht: ohne Treffer bleibt es bei der Grenze, nach einer Stunde geht es wieder
await widerrufeOhneLink(ctx, { name: 'Max', email: 'max@example.com', nummer: 'ZZZZZZZZ' }, plus(JETZT, 3 * MIN))
await warteAufVersand()
assert.equal(g08().length, 22)
})
test('widerrufeOhneLink: Eingaben - Name und E-Mail Pflicht (400), sonst nichts gespeichert und keine Mail', async () => {
const ctx = await speichere()
const faelle = [
[{ email: 'max@example.com', nummer: 'ZZZZZZZZ' }, ['name']],
[{ name: ' ', email: 'max@example.com' }, ['name']],
[{ name: 'x'.repeat(101), email: 'max@example.com' }, ['name']],
[{ name: 'Max', email: 'a@example.com, b@example.com' }, ['email']],
[{ name: 'Max', email: 'keine-adresse' }, ['email']],
[{ name: 'Max', email: 5 }, ['email']],
[{ name: '', email: '' }, ['email', 'name']],
]
for (const [roh, felder] of faelle) {
const err = await fehlerVon(() => widerrufeOhneLink(ctx, roh, JETZT))
assert.equal(err.status, 400, JSON.stringify(roh))
assert.deepEqual(Object.keys(err.fehler).sort(), felder, JSON.stringify(roh))
}
assert.equal((await fehlerVon(() => widerrufeOhneLink(ctx, null, JETZT))).status, 400)
assert.equal(eintraege().length, 0)
await assert.rejects(() => widerrufeOhneLink(ctx, { name: 'Max', email: 'max@example.com' }, undefined), TypeError)
await assert.rejects(() => widerrufeOhneLink({ feature: feature(), einstellung: null }, { name: 'Max', email: 'max@example.com' }, JETZT), TypeError)
await assert.rejects(() => widerrufeOhneLink(null, { name: 'Max', email: 'max@example.com' }, JETZT), TypeError)
})
test('widerrufeOhneLink: Erklaerung zu einer schon widerrufenen Buchung aendert nichts und sendet nichts; Antwort wie immer', async () => {
const ctx = await speichere()
const { id } = await gebucht(ctx)
const roh = { name: 'Max', email: 'max@example.com', nummer: buchungsNummer(id) }
await widerrufeOhneLink(ctx, roh, JETZT)
await warteAufVersand()
const mails = eintraege().length
const zweite = await widerrufeOhneLink(ctx, roh, plus(JETZT, 5 * MIN))
await warteAufVersand()
assert.deepEqual(zweite, { eingang: { datum: '05.10.2026', uhrzeit: '10:05' } })
assert.equal(iso(buchung(id).widerrufenAt), iso(JETZT))
assert.equal(eintraege().length, mails)
})
test('Mail G06 ohne Kalenderdatei: anGast mit ohneIcs laesst die Datei weg, ohne die Option bleibt sie', async () => {
const { anGast } = await import('../server/services/termine/mails.js')
const ctx = await speichere()
const { buchung: b } = await gebucht(ctx)
await anGast('G06', ctx, b, { von: 'gast' }, JETZT)
await anGast('G06', ctx, b, { von: 'gast', ohneIcs: true }, JETZT)
await warteAufVersand()
const g06 = sink.sent.filter((m) => /abgesagt/.test(m.subject))
assert.equal(g06.length, 2)
assert.deepEqual(g06.map((m) => 'icalEvent' in m).sort(), [false, true])
})