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