Files
Webklar-Kundenbereich/test/termineKontext.test.js
T
ClaudeandClaude Opus 5.5 3a0b0089dd Terminplaner: eigener Betreff fuer die Ablehnung einer Anfrage (G04)
Eingang (G03) und Ablehnung (G04) hatten denselben Betreff "Ihre Terminanfrage bei {betrieb}".
G04 heisst jetzt "Ihre/Deine Terminanfrage bei {betrieb} – leider abgelehnt".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:40:52 +00:00

1090 lines
60 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { mailSink } from './support/mailSink.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
// Kontext, Einstellungs-Speicher und Mail-Werte des Terminplaners. Nachbau von Appwrite mit Schema fuer die Termine-
// Collections und portalFeatures, 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'),
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 { renderMail, loadTemplate } = await import('../server/services/mailTemplates.js')
const { normalisiereEinstellung } = await import('../server/services/termine/einstellungen.js')
const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js')
const { buchungsNummer, verwaltungsToken } = await import('../server/services/termine/token.js')
const K = await import('../server/services/termine/kontext.js')
const M = await import('../server/services/termine/mails.js')
const { ladeEinstellung, speichereEinstellung, kontextPerSiteKey, kontextPerFeature, websiteUrl, resetKontextForTests } = K
const { BETREFFE, werteFuer, anGast, anBetrieb } = M
after(() => setMailTransportForTests(null))
const JETZT = new Date('2026-10-05T08:00:00.000Z')
const T0 = new Date('2026-10-05T12:00:00.000Z')
const SEKUNDEN = (n, von = T0) => new Date(von.getTime() + n * 1000)
const KUNDE = 'c_kunde01'
const PROJEKT = 'p_projekt01'
const FEATURE = 'f_p_projekt01_termine'
const SITE_KEY = 'abcdefghijklmnop'
const BUCHUNG_ID = '0b7e7a2c-1f0e-4b51-9a8f-0123456789ab'
const TOKEN = 'A'.repeat(43)
const R1 = 'r_aaaaaaa1'
const R2 = 'r_aaaaaaa2'
const S1 = 's_bbbbbbb1'
const ABSENDER_VORHER = config.smtp.from
const MODUS_VORHER = config.termine.mode
const BASIS_VORHER = config.publicBaseUrl
const feature = (over = {}) => ({
$id: FEATURE,
customerId: KUNDE,
projectId: PROJEKT,
featureKey: 'termine',
state: 'active',
enabled: true,
siteKey: SITE_KEY,
...over,
})
const eingabe = (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',
ressourcen: [
{ id: R1, name: 'Anna', plan: { 1: [['09:00', '18:00']] } },
{ id: R2, name: 'Ben', plan: { 1: [['09:00', '18:00']] } },
],
leistungen: [{ id: S1, name: 'Damenschnitt', dauer: 45, preisArt: 'fest', preisCent: 4500, ressourcen: [R1, R2] }],
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, optionen = {}) => {
const erg = normalisiereEinstellung(eingabe(over), { now: JETZT, ...optionen })
assert.equal(erg.fehler, undefined, JSON.stringify(erg.fehler))
return erg.einstellung
}
const betriebMit = (felder) => ({ ...eingabe().betrieb, ...felder })
const ctxMit = (over = {}, projekt = { $id: PROJEKT, subdomain: 'salon' }, optionen = {}) => ({
feature: feature(),
project: projekt,
einstellung: einstellung(over, optionen),
version: 1,
})
const buchung = (over = {}) => ({
$id: BUCHUNG_ID,
featureId: FEATURE,
leistungId: S1,
leistungName: 'Damenschnitt',
ressourceId: R1,
ressourceName: 'Anna',
startsAt: '2026-10-12T08:00:00.000+00:00', // Berlin: Montag, 12.10.2026, 10:00
endsAt: '2026-10-12T08:45:00.000+00:00',
status: 'bestaetigt',
quelle: 'online',
gastName: 'Max Mustermann',
gastEmail: 'max@example.com',
gastTelefon: '0171 1234567',
gastNachricht: 'Bitte ohne Föhnen',
preisCent: 4500,
preisArt: 'fest',
buchungsart: 'sofort',
verbraucher: true,
icsSequenz: 0,
...over,
})
const ALT_START = '2026-10-12T07:00:00.000+00:00' // 09:00 Berlin
const EINGANG = new Date('2026-10-05T08:07:00.000Z') // 10:07 Berlin
const ERKLAERUNG = { gastName: 'Erika Beispiel', gastEmail: 'erika@example.com', gastTelefon: '', gastNachricht: 'Ich widerrufe.' }
async function leere() {
for (const collection of ['portalFeatures', 'termineConfig', 'termineOutbox', 'websiteProjects', 'customers']) {
for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id)
}
}
let sink
let log
beforeEach(async () => {
await leere()
resetKontextForTests()
resetOutboxForTests()
config.termine.mode = 'pilot'
config.smtp.from = 'noreply@webklar.com'
sink = mailSink()
setMailTransportForTests(sink)
log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}) }
})
afterEach(async () => {
await warteAufVersand()
mock.restoreAll()
config.termine.mode = MODUS_VORHER
config.smtp.from = ABSENDER_VORHER
config.publicBaseUrl = BASIS_VORHER
})
/** Anzahl der Anfragen an den Appwrite-Nachbau, die fn ausloest. */
async function zaehleAnfragen(fn) {
const echt = globalThis.fetch
let n = 0
globalThis.fetch = (url, optionen) => {
n += 1
return echt(url, optionen)
}
try {
await fn()
} finally {
globalThis.fetch = echt
}
return n
}
/** Alle Lese-Anfragen an die Collection scheitern wie in Appwrite mit 404 collection_not_found. */
async function mitFehlenderCollection(collection, fn) {
const echt = globalThis.fetch
globalThis.fetch = async (url, optionen) => {
if (new RegExp(`/collections/${collection}/documents`).test(String(url)) && (optionen?.method ?? 'GET') === 'GET') {
const body = { message: 'Collection with the requested ID could not be found.', code: 404, type: 'collection_not_found' }
return new Response(JSON.stringify(body), { status: 404, headers: { 'content-type': 'application/json' } })
}
return echt(url, optionen)
}
try {
await fn()
} finally {
globalThis.fetch = echt
}
}
/** Kalenderdatei ohne die Zeilenfaltung (CRLF + Leerzeichen). */
const entfalte = (ics) => ics.replace(/\r\n /g, '')
const seedFeature = (over) => aw.seed('portalFeatures', feature(over))
const seedProjekt = (over = {}) => aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, subdomain: 'salon', ...over })
const gespeichert = () => aw.docs('termineConfig').get(FEATURE)
// ---------------------------------------------------------------- Einstellungs-Speicher
test('speichereEinstellung: erstes Speichern legt das Dokument unter der Funktions-ID an, Laden liefert Einstellung und Version', async () => {
assert.equal(await ladeEinstellung(FEATURE), null, 'noch nichts gespeichert')
const e = einstellung()
const erg = await speichereEinstellung(feature(), e, { version: 0, by: 'kunde:c_kunde01', now: JETZT })
assert.equal(erg.version, 1)
assert.deepEqual(erg.einstellung, e)
const doc = gespeichert()
assert.equal(doc.customerId, KUNDE)
assert.equal(doc.projectId, PROJEKT)
assert.equal(doc.version, 1)
assert.equal(doc.updatedBy, 'kunde:c_kunde01')
assert.equal(new Date(doc.updatedAt).getTime(), JETZT.getTime())
assert.equal(typeof doc.einstellung, 'string', 'JSON-Text im Attribut einstellung')
assert.deepEqual(JSON.parse(doc.einstellung), e)
assert.equal(Object.hasOwn(doc, 'data'), false, 'nie ein Attribut data')
assert.deepEqual(await ladeEinstellung(FEATURE), { einstellung: e, version: 1 })
})
test('speichereEinstellung: zweites Speichern mit der gelesenen Version zaehlt hoch und ersetzt die Einstellung', async () => {
await speichereEinstellung(feature(), einstellung(), { version: 0 })
const neu = einstellung({ betrieb: betriebMit({ name: 'Salon Neu' }) })
const erg = await speichereEinstellung(feature(), neu, { version: 1, by: 'x'.repeat(300) })
assert.equal(erg.version, 2)
const geladen = await ladeEinstellung(FEATURE)
assert.equal(geladen.version, 2)
assert.equal(geladen.einstellung.betrieb.name, 'Salon Neu')
assert.equal(gespeichert().updatedBy.length, 100, 'updatedBy auf 100 Zeichen gekuerzt')
})
test('speichereEinstellung: veraltete Version => 409 EINSTELLUNG_VERALTET, gespeichert bleibt der Stand des Ersten', async () => {
await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ name: 'Erster' }) }), { version: 0 })
const veraltet = (err) => err.status === 409 && err.code === 'EINSTELLUNG_VERALTET' && /neu laden/i.test(err.message)
await assert.rejects(() => speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ name: 'Zweiter' }) }), { version: 0 }), veraltet)
await assert.rejects(() => speichereEinstellung(feature(), einstellung(), { version: 7 }), veraltet, 'auch eine zu neue Version')
await assert.rejects(() => speichereEinstellung(feature(), einstellung(), {}), veraltet, 'ohne Version')
const geladen = await ladeEinstellung(FEATURE)
assert.equal(geladen.version, 1)
assert.equal(geladen.einstellung.betrieb.name, 'Erster')
})
test('speichereEinstellung: zwei gleichzeitige Speicherungen derselben Version => genau eine gewinnt', async () => {
const [a, b] = await Promise.allSettled([
speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ name: 'A' }) }), { version: 0 }),
speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ name: 'B' }) }), { version: 0 }),
])
assert.deepEqual([a.status, b.status].sort(), ['fulfilled', 'rejected'])
const verlierer = a.status === 'rejected' ? a : b
assert.equal(verlierer.reason.status, 409)
assert.equal((await ladeEinstellung(FEATURE)).version, 1)
})
test('speichereEinstellung: Eingaben ohne Funktion oder ohne Einstellung werfen TypeError und schreiben nichts', async () => {
await assert.rejects(() => speichereEinstellung({}, einstellung(), { version: 0 }), TypeError)
await assert.rejects(() => speichereEinstellung(feature(), null, { version: 0 }), TypeError)
await assert.rejects(() => speichereEinstellung(feature(), einstellung(), { version: 0, now: 'gestern' }), TypeError)
assert.equal(aw.docs('termineConfig').size, 0)
})
test('ladeEinstellung: unlesbares JSON gilt als nicht gespeichert (mit Log); fehlende Collection ist ein Fehler, kein "nichts"', async () => {
aw.seed('termineConfig', { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, einstellung: '{kaputt', version: 3 })
assert.equal(await ladeEinstellung(FEATURE), null)
assert.equal(log.error.mock.callCount(), 1)
// ueberschreiben darf man sie mit der gespeicherten Version
const erg = await speichereEinstellung(feature(), einstellung(), { version: 3 })
assert.equal(erg.version, 4)
seedFeature()
await mitFehlenderCollection('termineConfig', async () => {
await assert.rejects(() => ladeEinstellung(FEATURE), (err) => err.status === 404 && err.type === 'collection_not_found')
await assert.rejects(() => kontextPerFeature(FEATURE), (err) => err.type === 'collection_not_found')
await assert.rejects(() => kontextPerSiteKey(SITE_KEY, T0), (err) => err.type === 'collection_not_found')
})
})
test('R40 speichereEinstellung mit Funktion: laeuft nach der Versionspruefung, bekommt die bisherige Einstellung (erst null), Ergebnis wird gespeichert', async () => {
const aufrufe = []
const erste = einstellung({ betrieb: betriebMit({ name: 'Erste' }) })
const erg = await speichereEinstellung(feature(), async (bisher) => {
aufrufe.push(bisher)
return erste
}, { version: 0, by: 'kunde', now: JETZT })
assert.deepEqual(aufrufe, [null], 'noch nichts gespeichert')
assert.equal(erg.version, 1)
assert.deepEqual(erg.einstellung, erste)
assert.equal(gespeichert().updatedBy, 'kunde')
const zweite = einstellung({ betrieb: betriebMit({ name: 'Zweite' }) })
const erg2 = await speichereEinstellung(feature(), (bisher) => {
aufrufe.push(bisher) // auch eine synchrone Funktion
return zweite
}, { version: 1 })
assert.deepEqual(aufrufe[1], erste, 'bisher = die gespeicherte Einstellung')
assert.equal(erg2.version, 2)
assert.equal((await ladeEinstellung(FEATURE)).einstellung.betrieb.name, 'Zweite')
})
test('R40 speichereEinstellung mit Funktion: veraltete Version ruft die Funktion nie auf; ein Fehler der Funktion geht unveraendert durch und schreibt nichts', async () => {
await speichereEinstellung(feature(), einstellung(), { version: 0 })
let aufrufe = 0
await assert.rejects(
() => speichereEinstellung(feature(), async () => { aufrufe += 1; return einstellung() }, { version: 0 }),
(err) => err.status === 409 && err.code === 'EINSTELLUNG_VERALTET',
)
assert.equal(aufrufe, 0, 'Version zuerst')
const eigener = Object.assign(new Error('Eingabe'), { status: 400, fehler: { 'leistungen.s_x': 'Noch 2 künftige Termine.' } })
await assert.rejects(() => speichereEinstellung(feature(), async () => { throw eigener }, { version: 1 }), (err) => err === eigener)
const geladen = await ladeEinstellung(FEATURE)
assert.equal(geladen.version, 1, 'nichts geschrieben')
// das Sperrschloss ist wieder frei
assert.equal((await speichereEinstellung(feature(), async () => einstellung(), { version: 1 })).version, 2)
})
test('R40 speichereEinstellung mit Funktion: Ergebnis ohne Objekt => TypeError und nichts geschrieben; Eingaben ohne Funktion bleiben TypeError', async () => {
for (const unsinn of [null, undefined, 'x', 5, []]) {
await assert.rejects(() => speichereEinstellung(feature(), async () => unsinn, { version: 0 }), TypeError, String(unsinn))
}
assert.equal(aw.docs('termineConfig').size, 0)
await assert.rejects(() => speichereEinstellung({}, async () => einstellung(), { version: 0 }), TypeError)
})
test('R40 speichereEinstellung mit version null: keine Versionspruefung, Version zaehlt trotzdem hoch (Feed-Link neu erzeugen)', async () => {
await speichereEinstellung(feature(), einstellung(), { version: 0 })
await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ name: 'Zwei' }) }), { version: 1 })
const erg = await speichereEinstellung(feature(), async (bisher) => ({ ...bisher, farbe: '#112233' }), { version: null })
assert.equal(erg.version, 3)
const geladen = await ladeEinstellung(FEATURE)
assert.equal(geladen.einstellung.farbe, '#112233')
assert.equal(geladen.einstellung.betrieb.name, 'Zwei', 'baut auf dem aktuellen Stand auf')
// nur null spart die Pruefung; undefined und falsche Versionen bleiben 409
await assert.rejects(() => speichereEinstellung(feature(), async (b) => b, {}), (err) => err.status === 409)
await assert.rejects(() => speichereEinstellung(feature(), async (b) => b, { version: 99 }), (err) => err.status === 409)
// ohne Dokument sieht die Funktion null (mit der Einstellung als Objekt bleibt version null auch erlaubt)
await deleteDocument('termineConfig', FEATURE)
let gesehen
await speichereEinstellung(feature(), async (bisher) => { gesehen = bisher; return einstellung() }, { version: null })
assert.equal(gesehen, null)
})
test('R40 speichereEinstellung mit Funktion: gleichzeitige Aufrufe laufen nacheinander, der zweite sieht das Ergebnis des ersten', async () => {
await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ name: 'Start' }) }), { version: 0 })
const gesehen = []
const aendere = (name) => async (bisher) => {
gesehen.push(bisher.betrieb.name)
await new Promise((resolve) => setTimeout(resolve, 20)) // der Zweite haette Zeit, dazwischenzufunken
return { ...bisher, betrieb: { ...bisher.betrieb, name } }
}
await Promise.all([
speichereEinstellung(feature(), aendere('A'), { version: null }),
speichereEinstellung(feature(), aendere('B'), { version: null }),
])
assert.equal(gesehen[0], 'Start')
assert.ok(['A', 'B'].includes(gesehen[1]), 'der Zweite baut auf dem Ergebnis des Ersten auf')
assert.equal((await ladeEinstellung(FEATURE)).version, 3)
})
// ---------------------------------------------------------------- Kontext
test('kontextPerSiteKey: aktive Funktion mit Einstellung => ctx { feature, project, einstellung, version }', async () => {
seedFeature()
seedProjekt()
const e = einstellung()
await speichereEinstellung(feature(), e, { version: 0 })
const ctx = await kontextPerSiteKey(SITE_KEY, T0)
assert.equal(ctx.feature.$id, FEATURE)
assert.equal(ctx.feature.siteKey, SITE_KEY)
assert.equal(ctx.project.subdomain, 'salon')
assert.deepEqual(ctx.einstellung, e)
assert.equal(ctx.version, 1)
})
test('R41 kontextPerSiteKey: solange Pflichtangaben fehlen (Telefon; Anschrift bei Widerrufsbelehrung) ist die Funktion oeffentlich nicht buchbar (null); das Portal sieht sie weiter', async () => {
seedFeature()
seedProjekt()
const entwurf = { entwurf: true }
const ohneTelefon = einstellung({ betrieb: betriebMit({ telefon: '' }) }, entwurf)
await speichereEinstellung(feature(), ohneTelefon, { version: 0 })
assert.equal(await kontextPerSiteKey(SITE_KEY, T0), null, 'Telefon fehlt')
const portal = await kontextPerFeature(FEATURE)
assert.ok(portal && portal.einstellung, 'kontextPerFeature (Portal, Gaesteseiten per Token) bleibt erreichbar')
assert.deepEqual(portal.einstellung.betrieb.telefon, '')
await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ adresse: '' }) }, entwurf), { version: 1 })
assert.equal(await kontextPerSiteKey(SITE_KEY, SEKUNDEN(1)), null, 'Anschrift fehlt bei Verbraucher + sofort')
// ohne Widerrufsbelehrung (nur Unternehmer) reicht das Telefon
await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ adresse: '' }), recht: { gaesteSind: 'unternehmer', freizeit: false } }, entwurf), { version: 2 })
assert.ok(await kontextPerSiteKey(SITE_KEY, SEKUNDEN(2)), 'Unternehmer-Betrieb ohne Anschrift ist buchbar')
// vollstaendig: buchbar; die Speicherung leert den gemerkten null-Eintrag sofort
await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ adresse: '', telefon: '' }) }, entwurf), { version: 3 })
assert.equal(await kontextPerSiteKey(SITE_KEY, SEKUNDEN(3)), null)
await speichereEinstellung(feature(), einstellung(), { version: 4 })
const ctx = await kontextPerSiteKey(SITE_KEY, SEKUNDEN(4))
assert.equal(ctx.einstellung.betrieb.telefon, '06301 1234')
})
test('kontextPerSiteKey: Modus off => null ohne Anfrage; ungueltiger siteKey => null ohne Anfrage', async () => {
seedFeature()
await speichereEinstellung(feature(), einstellung(), { version: 0 })
config.termine.mode = 'off'
assert.equal(await zaehleAnfragen(async () => assert.equal(await kontextPerSiteKey(SITE_KEY, T0), null)), 0)
config.termine.mode = 'on'
for (const schlecht of ['', 'zu-kurz', 'ABCDEFGHIJKLMNOP', 'abcdefghijklmno1', 'abcdefghijklmnopq', null, undefined, 42, {}]) {
assert.equal(await zaehleAnfragen(async () => assert.equal(await kontextPerSiteKey(schlecht, T0), null, String(schlecht))), 0, String(schlecht))
}
assert.ok((await kontextPerSiteKey(SITE_KEY, T0)).einstellung, 'bei on und gueltigem Schluessel gibt es den ctx')
})
test('kontextPerSiteKey: unbekannter Schluessel, andere Funktion, Zustand ungleich active und fehlende Einstellung => null', async () => {
assert.equal(await kontextPerSiteKey('bcdefghijklmnopq', T0), null, 'unbekannter Schluessel')
seedFeature({ featureKey: 'news' })
await speichereEinstellung(feature(), einstellung(), { version: 0 })
assert.equal(await kontextPerSiteKey(SITE_KEY, T0), null, 'siteKey gehoert zu einer anderen Funktion')
await deleteDocument('portalFeatures', FEATURE)
for (const state of ['off', 'ordered', 'setup', 'disabled']) {
resetKontextForTests()
seedFeature({ state })
assert.equal(await kontextPerSiteKey(SITE_KEY, T0), null, state)
await deleteDocument('portalFeatures', FEATURE)
}
resetKontextForTests()
await deleteDocument('termineConfig', FEATURE)
seedFeature()
assert.equal(await kontextPerSiteKey(SITE_KEY, T0), null, 'aktiv, aber noch nie eingestellt: fuer Gaeste nicht aktiv')
})
test('kontextPerSiteKey: 30 Sekunden im Speicher je siteKey (auch "unbekannt"), danach neue Abfrage', async () => {
seedFeature()
await speichereEinstellung(feature(), einstellung(), { version: 0 })
const erste = await kontextPerSiteKey(SITE_KEY, T0)
assert.ok(erste)
assert.equal(await zaehleAnfragen(async () => assert.equal(await kontextPerSiteKey(SITE_KEY, SEKUNDEN(29)), erste)), 0, 'nach 29 s aus dem Speicher')
assert.ok((await zaehleAnfragen(async () => assert.ok(await kontextPerSiteKey(SITE_KEY, SEKUNDEN(31))))) > 0, 'nach 31 s neu gelesen')
// ein unbekannter Schluessel wird ebenfalls gemerkt (ein Besucher mit zufaelligen Schluesseln belastet Appwrite nicht)
const fremd = 'cdefghijklmnopqr'
assert.equal(await kontextPerSiteKey(fremd, T0), null)
seedFeature({ $id: 'f_p_fremd_termine', siteKey: fremd })
assert.equal(await kontextPerSiteKey(fremd, SEKUNDEN(10)), null, 'noch gemerkt')
})
test('speichereEinstellung leert den Speicher: der naechste kontextPerSiteKey sieht die neue Einstellung sofort', async () => {
seedFeature()
await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ name: 'Alt' }) }), { version: 0 })
assert.equal((await kontextPerSiteKey(SITE_KEY, T0)).einstellung.betrieb.name, 'Alt')
await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ name: 'Neu' }) }), { version: 1 })
const ctx = await kontextPerSiteKey(SITE_KEY, SEKUNDEN(1))
assert.equal(ctx.einstellung.betrieb.name, 'Neu')
assert.equal(ctx.version, 2)
})
test('kontextPerSiteKey: eine Abfrage, die waehrend eines Speicherns lief, merkt ihr altes Ergebnis nicht', async () => {
seedFeature()
await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ name: 'Alt' }) }), { version: 0 })
resetKontextForTests()
const echt = globalThis.fetch
let freigeben
const gate = new Promise((resolve) => (freigeben = resolve))
let gesperrt = false
globalThis.fetch = async (url, optionen) => {
const antwort = await echt(url, optionen) // der Leser hat den alten Stand schon
if (!gesperrt && /\/termineConfig\/documents\//.test(String(url)) && (optionen?.method ?? 'GET') === 'GET') {
gesperrt = true
await gate
}
return antwort
}
try {
const lesen = kontextPerSiteKey(SITE_KEY, T0)
while (!gesperrt) await new Promise((resolve) => setTimeout(resolve, 5))
await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ name: 'Neu' }) }), { version: 1 })
freigeben()
assert.equal((await lesen).einstellung.betrieb.name, 'Alt', 'der laufende Aufruf liefert, was er gelesen hat')
} finally {
globalThis.fetch = echt
freigeben()
}
assert.equal((await kontextPerSiteKey(SITE_KEY, SEKUNDEN(1))).einstellung.betrieb.name, 'Neu', 'aber nichts Veraltetes im Speicher')
})
test('kontextPerFeature: active und disabled liefern ctx, auch ohne Einstellung (einstellung null, version 0); sonst null', async () => {
seedProjekt()
seedFeature()
const ohne = await kontextPerFeature(FEATURE)
assert.equal(ohne.einstellung, null)
assert.equal(ohne.version, 0)
assert.equal(ohne.project.$id, PROJEKT)
await speichereEinstellung(feature(), einstellung(), { version: 0 })
assert.equal((await kontextPerFeature(FEATURE)).version, 1)
await deleteDocument('portalFeatures', FEATURE)
seedFeature({ state: 'disabled' })
const aus = await kontextPerFeature(FEATURE)
assert.equal(aus.feature.state, 'disabled', 'abgeschaltete Funktion bleibt fuer Absagen und Widerruf erreichbar')
assert.equal(aus.einstellung.betrieb.name, 'Salon Müller')
for (const over of [{ state: 'off' }, { state: 'setup' }, { featureKey: 'news' }]) {
await deleteDocument('portalFeatures', FEATURE)
seedFeature(over)
assert.equal(await kontextPerFeature(FEATURE), null, JSON.stringify(over))
}
assert.equal(await kontextPerFeature('f_gibt_es_nicht'), null)
assert.equal(await kontextPerFeature(''), null)
assert.equal(await kontextPerFeature(undefined), null)
})
test('unlesbares Einstellungs-JSON: der Kontext traegt die echte Version (einstellung null), Speichern mit ihr ueberschreibt den Schaden', async () => {
seedFeature()
seedProjekt()
aw.seed('termineConfig', { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, einstellung: '{kaputt', version: 3 })
const ctx = await kontextPerFeature(FEATURE)
assert.equal(ctx.einstellung, null, 'nichts Lesbares')
assert.equal(ctx.version, 3, 'aber die gespeicherte Version, nicht 0')
assert.equal(await ladeEinstellung(FEATURE), null, 'ladeEinstellung bleibt: unlesbar = nicht gespeichert')
assert.equal(await kontextPerSiteKey(SITE_KEY, T0), null, 'oeffentlich nicht buchbar')
// mit Version 0 (dem alten Wert des Kontexts) ging es nie; mit der echten gelingt es und die Funktion bekommt bisher = null
await assert.rejects(() => speichereEinstellung(feature(), einstellung(), { version: 0 }), (err) => err.status === 409)
let gesehen
const erg = await speichereEinstellung(feature(), async (bisher) => { gesehen = bisher; return einstellung() }, { version: ctx.version })
assert.equal(gesehen, null)
assert.equal(erg.version, 4)
const danach = await kontextPerFeature(FEATURE)
assert.equal(danach.version, 4)
assert.equal(danach.einstellung.betrieb.name, 'Salon Müller')
})
test('kontextPerFeature: Modus off => null ohne Anfrage; fehlendes Projekt => project null', async () => {
seedFeature()
await speichereEinstellung(feature(), einstellung(), { version: 0 })
assert.equal((await kontextPerFeature(FEATURE)).project, null, 'kein websiteProjects-Dokument')
config.termine.mode = 'off'
assert.equal(await zaehleAnfragen(async () => assert.equal(await kontextPerFeature(FEATURE), null)), 0)
})
test('websiteUrl: betrieb.websiteUrl, sonst Live-Domain, sonst Vorschau-Subdomain, sonst Portal-Adresse', () => {
assert.equal(websiteUrl(ctxMit()), 'https://salon.de/', 'Angabe des Betriebs gewinnt auch gegen Live-Domain und Subdomain')
assert.equal(websiteUrl(ctxMit({}, { subdomain: 'salon', liveDomain: 'live.example.com' })), 'https://salon.de/')
const ohneUrl = { betrieb: betriebMit({ websiteUrl: null }) }
// Live-Domain vor Subdomain, auch wenn die Subdomain in analyseHosts zuerst steht
assert.equal(websiteUrl(ctxMit(ohneUrl, { subdomain: 'salon', liveDomain: 'www.salon.de' })), 'https://www.salon.de')
assert.equal(websiteUrl(ctxMit(ohneUrl, { subdomain: 'salon', liveDomain: 'https://WWW.Salon.de/start?x=1' })), 'https://www.salon.de', 'Schema, Pfad und Gross-/Kleinschreibung fallen weg')
assert.equal(websiteUrl(ctxMit(ohneUrl, { liveDomain: 'salon.de' })), 'https://salon.de', 'Live-Domain ohne Subdomain')
// keine verwertbare Live-Domain => Vorschau-Subdomain
assert.equal(websiteUrl(ctxMit(ohneUrl, { subdomain: 'salon' })), 'https://salon.project.webklar.com')
assert.equal(websiteUrl(ctxMit(ohneUrl, { subdomain: 'salon', liveDomain: '' })), 'https://salon.project.webklar.com')
assert.equal(websiteUrl(ctxMit(ohneUrl, { subdomain: 'salon', liveDomain: 'kein host!' })), 'https://salon.project.webklar.com', 'ungueltige Live-Domain wird uebersprungen')
assert.equal(websiteUrl(ctxMit(ohneUrl, { subdomain: 'salon', liveDomain: 'salon.project.webklar.com' })), 'https://salon.project.webklar.com', 'Live-Domain gleich Subdomain-Host')
// nichts Brauchbares => Portal-Adresse
assert.equal(websiteUrl(ctxMit(ohneUrl, { $id: PROJEKT })), config.publicBaseUrl, 'Projekt ohne Hosts')
assert.equal(websiteUrl(ctxMit(ohneUrl, null)), config.publicBaseUrl, 'ohne Projekt')
assert.equal(websiteUrl({ einstellung: null, project: null }), config.publicBaseUrl)
assert.equal(websiteUrl(null), config.publicBaseUrl)
})
// ---------------------------------------------------------------- Betreffzeilen
const FAELLE = [
['G01', buchung(), { verifyToken: TOKEN }],
['G01', null, { fall: 'zu_viele', an: 'max@example.com' }],
['G02', buchung(), {}],
['G02', buchung({ buchungsart: 'anfrage' }), { anfrageAngenommen: true }],
['G03', buchung({ status: 'angefragt', buchungsart: 'anfrage' }), {}],
['G04', buchung({ status: 'abgelehnt' }), {}],
['G04', buchung({ status: 'abgelehnt' }), { nachricht: 'Leider ausgebucht.' }],
['G05', buchung(), {}],
['G06', buchung({ status: 'abgesagt' }), { von: 'gast' }],
['G06', buchung({ status: 'abgesagt' }), { von: 'betrieb', grund: 'Krankheit', nachricht: 'Tut uns leid.' }],
['G07', buchung({ icsSequenz: 1 }), { altStart: ALT_START }],
['G08', buchung(), { fall: 'zugeordnet', eingang: EINGANG }],
['G08', buchung(), { fall: 'nach_frist', eingang: EINGANG }],
['G08', buchung(), { fall: 'begonnen', eingang: EINGANG }],
['G08', null, { fall: 'nicht_zugeordnet', eingang: EINGANG, an: 'erika@example.com' }],
['K20', buchung(), {}],
['K20', buchung({ status: 'angefragt' }), { fall: 'anfrage' }],
['K21', buchung(), { fall: 'abgesagt' }],
['K21', buchung(), { fall: 'verschoben', altStart: ALT_START }],
['K21', buchung(), { fall: 'widerrufen', eingang: EINGANG }],
['K21', buchung(), { fall: 'nach_frist', eingang: EINGANG }],
['K21', buchung(), { fall: 'begonnen', eingang: EINGANG }],
['K21', ERKLAERUNG, { fall: 'nicht_zugeordnet', eingang: EINGANG }],
]
test('werteFuer: jede Vorlage und jeder Fall rendert in beiden Anreden ohne fehlende oder unbekannte Platzhalter und Bloecke', () => {
for (const anrede of ['sie', 'du']) {
const ctx = ctxMit({ anrede })
for (const [art, b, extra] of FAELLE) {
const name = `${art} ${extra.fall ?? ''} ${anrede}`
const { values, blocks } = werteFuer(art, ctx, b, extra)
const mail = renderMail(art, values, blocks)
assert.deepEqual(mail.problems, [], name)
assert.equal(mail.subject, values.betreff, name)
assert.ok(mail.subject.length > 5 && mail.subject.length < 200, `${name}: ${mail.subject}`)
assert.ok(!mail.html.includes('{{') && !mail.text.includes('{{'), name)
for (const wert of Object.values(values)) assert.equal(typeof wert, 'string', name)
assert.ok(Object.keys(values).every((key) => loadTemplate(art).placeholders.includes(key)), name)
if (art.startsWith('G')) assert.deepEqual(['sie', 'du'].filter((a) => blocks[a]), [anrede], `${name}: genau eine Anrede`)
else assert.equal(blocks.sie || blocks.du, undefined, `${name}: K-Mails ohne Anrede-Bloecke`)
}
}
})
test('BETREFFE: jede Gastvorlage hat Sie- und Du-Fassung, K20/K21 die Du-Fassung; Du ohne Sie/Ihr, Sie ohne du/dein', () => {
for (const art of ['G01', 'G02', 'G03', 'G04', 'G05', 'G06', 'G07', 'G08']) {
assert.deepEqual(Object.keys(BETREFFE[art]).sort(), ['du', 'sie'], art)
assert.deepEqual(Object.keys(BETREFFE[art].du), Object.keys(BETREFFE[art].sie), `${art}: gleiche Faelle`)
for (const muster of Object.values(BETREFFE[art].du)) assert.doesNotMatch(muster, /\b(Sie|Ihr\w*)\b/, `${art} Du: ${muster}`)
for (const muster of Object.values(BETREFFE[art].sie)) assert.doesNotMatch(muster, /\b([Dd]u|[Dd]ein\w*|[Dd]ich)\b/, `${art} Sie: ${muster}`)
}
assert.deepEqual(Object.keys(BETREFFE.K20), ['du'])
assert.deepEqual(Object.keys(BETREFFE.K21), ['du'])
assert.throws(() => (BETREFFE.G01.sie.bestaetigen = 'x'), TypeError, 'Tabelle ist eingefroren')
})
test('Betreff je Anrede: Sie-Form und Du-Form (G01, G02, G03/G04, G05)', () => {
const sie = ctxMit()
const du = ctxMit({ anrede: 'du' })
const betreff = (art, ctx, b, extra = {}) => werteFuer(art, ctx, b, extra).values.betreff
assert.equal(betreff('G01', sie, buchung(), { verifyToken: TOKEN }), 'Bitte bestätigen Sie Ihren Termin bei Salon Müller')
assert.equal(betreff('G01', du, buchung(), { verifyToken: TOKEN }), 'Bitte bestätige deinen Termin bei Salon Müller')
assert.equal(betreff('G02', sie, buchung()), 'Ihr Termin bei Salon Müller am 12.10.2026')
assert.equal(betreff('G02', du, buchung()), 'Dein Termin bei Salon Müller am 12.10.2026')
assert.equal(betreff('G05', sie, buchung()), 'Erinnerung: Ihr Termin am 12.10.2026 um 10:00')
assert.equal(betreff('G05', du, buchung()), 'Erinnerung: Dein Termin am 12.10.2026 um 10:00')
assert.equal(betreff('G06', du, buchung(), { von: 'gast' }), 'Dein Termin am 12.10.2026 wurde abgesagt')
// Eingang (G03) und Ablehnung (G04) einer Anfrage sind schon am Betreff zu unterscheiden
assert.equal(betreff('G03', sie, buchung()), 'Ihre Terminanfrage bei Salon Müller')
assert.equal(betreff('G04', sie, buchung({ status: 'abgelehnt' })), 'Ihre Terminanfrage bei Salon Müller – leider abgelehnt')
assert.equal(betreff('G04', du, buchung({ status: 'abgelehnt' })), 'Deine Terminanfrage bei Salon Müller – leider abgelehnt')
assert.notEqual(betreff('G03', sie, buchung()), betreff('G04', sie, buchung({ status: 'abgelehnt' })))
assert.equal(betreff('G08', du, buchung(), { fall: 'zugeordnet', eingang: EINGANG }), 'Eingangsbestätigung deines Widerrufs')
for (const [art, b, extra] of FAELLE.filter(([a]) => a.startsWith('G'))) {
assert.doesNotMatch(betreff(art, du, b, extra), /\b(Sie|Ihr\w*)\b/, `${art} Du`)
}
})
test('Betreffzeilen der Betriebsmails: Buchung/Anfrage und je Fall von K21; nie ein Gastname', () => {
const ctx = ctxMit()
const betreff = (art, b, extra = {}) => werteFuer(art, ctx, b, extra).values.betreff
assert.equal(betreff('K20', buchung()), 'Neue Buchung: Damenschnitt am 12.10.2026 um 10:00')
assert.equal(betreff('K20', buchung(), { fall: 'anfrage' }), 'Neue Terminanfrage: Damenschnitt am 12.10.2026 um 10:00')
assert.equal(betreff('K21', buchung(), { fall: 'abgesagt' }), 'Termin abgesagt: Damenschnitt am 12.10.2026 um 10:00')
assert.match(betreff('K21', buchung(), { fall: 'verschoben', altStart: ALT_START }), /^Termin verschoben: Damenschnitt, jetzt am 12\.10\.2026 um 10:00$/)
assert.match(betreff('K21', buchung(), { fall: 'widerrufen', eingang: EINGANG }), /^Termin widerrufen: /)
assert.match(betreff('K21', buchung(), { fall: 'nach_frist', eingang: EINGANG }), /^Widerruf nach Ablauf der Frist: /)
assert.equal(betreff('K21', buchung(), { fall: 'begonnen', eingang: EINGANG }), 'Widerruf nach Terminbeginn: Damenschnitt am 12.10.2026 um 10:00')
assert.equal(betreff('K21', ERKLAERUNG, { fall: 'nicht_zugeordnet', eingang: EINGANG }), 'Widerruf ohne passende Buchung')
for (const [art, b, extra] of FAELLE) {
const text = betreff(art, b, extra)
assert.ok(!text.includes('Max') && !text.includes('Mustermann') && !text.includes('Erika') && !text.includes('Beispiel'), `${art}: ${text}`)
}
})
// ---------------------------------------------------------------- G01 und G08: kein Text des Gastes
test('G01 und G08 enthalten nichts, was der Gast eingegeben hat (weder Name noch Telefon noch Nachricht)', () => {
const boese = buchung({ gastName: 'Mallory Eve', gastTelefon: '0999 666666', gastNachricht: 'KAUFEN-SIE-JETZT http://boese.example' })
for (const anrede of ['sie', 'du']) {
const ctx = ctxMit({ anrede })
for (const [art, extra] of [
['G01', { verifyToken: TOKEN }],
['G08', { fall: 'zugeordnet', eingang: EINGANG }],
['G08', { fall: 'nach_frist', eingang: EINGANG }],
]) {
const { values, blocks } = werteFuer(art, ctx, boese, extra)
const mail = renderMail(art, values, blocks)
for (const geheim of ['Mallory', 'Eve', '0999', 'KAUFEN', 'boese.example']) {
assert.ok(!mail.html.includes(geheim) && !mail.text.includes(geheim) && !mail.subject.includes(geheim), `${art} ${anrede}: ${geheim}`)
}
}
}
})
test('G01: genau einer von bestaetigen und zu_viele; zu_viele ohne Link und ohne Termindaten', () => {
const ctx = ctxMit()
const normal = werteFuer('G01', ctx, buchung(), { verifyToken: TOKEN })
assert.deepEqual(normal.blocks, { sie: true, bestaetigen: true })
assert.equal(normal.values.link, `${config.publicBaseUrl}/termin.html#v=${TOKEN}`)
assert.equal(normal.values.leistung, 'Damenschnitt')
assert.equal(normal.values.datum, 'Montag, 12.10.2026')
assert.equal(normal.values.zeit, '10:00')
const zuViele = werteFuer('G01', ctx, null, { fall: 'zu_viele' })
assert.deepEqual(zuViele.blocks, { sie: true, zu_viele: true })
const mail = renderMail('G01', zuViele.values, zuViele.blocks)
assert.doesNotMatch(mail.html, /termin\.html#v=/)
assert.doesNotMatch(mail.html, /Termin bestätigen/)
assert.match(mail.html, /höchstmögliche Zahl offener Termine/)
assert.throws(() => werteFuer('G01', ctx, buchung(), {}), TypeError, 'Bestaetigungstoken fehlt')
assert.throws(() => werteFuer('G01', ctx, buchung(), { verifyToken: 'kurz' }), TypeError, 'Bestaetigungstoken ungueltig')
assert.throws(() => werteFuer('G01', ctx, buchung(), { fall: 'beides', verifyToken: TOKEN }), TypeError)
assert.throws(() => werteFuer('G01', ctx, null, { verifyToken: TOKEN }), TypeError, 'bestaetigen braucht eine Buchung')
})
// ---------------------------------------------------------------- G02
test('G02 Verbraucherbuchung: Widerrufsblock mit Belehrung, Widerrufslink und Hinweis; kein kein_widerruf', () => {
const ctx = ctxMit()
const { values, blocks } = werteFuer('G02', ctx, buchung(), {})
assert.equal(blocks.widerruf, true)
assert.equal(blocks.kein_widerruf, undefined)
assert.match(values.widerrufsbelehrung, /^Widerrufsbelehrung/)
assert.match(values.widerrufsbelehrung, /Salon Müller, Hauptstr\. 1, 67722 Winnweiler, Telefon: 06301 1234, E-Mail: info@salon\.de/)
assert.match(values.widerrufsbelehrung, /Muster-Widerrufsformular/)
const mail = renderMail('G02', values, blocks)
assert.deepEqual(mail.problems, [])
assert.match(mail.html, /Vertrag widerrufen/)
assert.match(mail.html, /Widerrufsrecht\./)
assert.ok(mail.html.includes(`href="${values.widerruf_link}"`))
})
test('G02 ohne Verbraucher: Unternehmer-Buchung hat weder widerruf noch kein_widerruf; Handbuchung auch nicht', () => {
const unternehmer = ctxMit({ recht: { gaesteSind: 'unternehmer', freizeit: false } })
const a = werteFuer('G02', unternehmer, buchung({ verbraucher: false }), {})
assert.equal(a.blocks.widerruf, undefined)
assert.equal(a.blocks.kein_widerruf, undefined)
assert.equal(a.values.widerrufsbelehrung, '–')
const mail = renderMail('G02', a.values, a.blocks)
assert.deepEqual(mail.problems, [])
assert.doesNotMatch(mail.html, /Widerruf/)
const manuell = werteFuer('G02', ctxMit(), buchung({ quelle: 'manuell', verbraucher: false }), {})
assert.equal(manuell.blocks.widerruf, undefined)
assert.equal(manuell.blocks.kein_widerruf, undefined, 'Handbuchung: Belehrung ist Sache des Betriebs')
})
test('G02 Freizeitleistung mit festem Termin: kein_widerruf statt widerruf (nicht fuer reine Unternehmer-Betriebe)', () => {
for (const gaesteSind of ['verbraucher', 'gemischt']) {
const { values, blocks } = werteFuer('G02', ctxMit({ recht: { gaesteSind, freizeit: true } }), buchung({ verbraucher: false }), {})
assert.equal(blocks.kein_widerruf, true, gaesteSind)
assert.equal(blocks.widerruf, undefined, gaesteSind)
const mail = renderMail('G02', values, blocks)
assert.match(mail.html, /kein Widerrufsrecht \(§ 312g Abs\. 2 Nr\. 9 BGB\)/)
assert.doesNotMatch(mail.html, /Vertrag widerrufen/)
}
const nurUnternehmer = werteFuer('G02', ctxMit({ recht: { gaesteSind: 'unternehmer', freizeit: true } }), buchung({ verbraucher: false }), {})
assert.equal(nurUnternehmer.blocks.kein_widerruf, undefined)
})
test('G02 mit Widerrufsrecht, aber ohne Anschrift oder Telefon des Betriebs: Fehler statt unvollstaendiger Belehrung', () => {
// Entwurf: nur so gibt es eine Einstellung ohne die Pflichtangaben (R41); die Mail prueft sie trotzdem selbst
const entwurf = { entwurf: true }
for (const felder of [{ adresse: '' }, { telefon: '' }]) {
const ctx = ctxMit({ betrieb: betriebMit(felder) }, undefined, entwurf)
assert.throws(() => werteFuer('G02', ctx, buchung(), {}), TypeError, JSON.stringify(felder))
}
// ohne Widerrufsrecht braucht die Mail beides nicht
const ctx = ctxMit({ betrieb: betriebMit({ adresse: '', telefon: '' }), recht: { gaesteSind: 'unternehmer', freizeit: false } }, undefined, entwurf)
assert.doesNotThrow(() => werteFuer('G02', ctx, buchung({ verbraucher: false }), {}))
})
test('G02: Preis- und Zahlungszeilen nur mit Preis, Person nur bei mehreren Ressourcen, Storno nur mit Text, Anfrage-Satz nur auf Wunsch', () => {
const voll = werteFuer('G02', ctxMit(), buchung(), { anfrageAngenommen: true })
assert.deepEqual(Object.keys(voll.blocks).sort(), ['anfrage_angenommen', 'person', 'preis', 'sie', 'storno', 'widerruf'])
assert.equal(voll.values.preis, '45,00 € (inkl. USt)')
assert.equal(voll.values.zahlung, 'Bezahlung vor Ort: bar, EC-Karte')
assert.equal(voll.values.person, 'Anna')
assert.equal(voll.values.storno_text, 'Kostenlose Absage bis 24 Stunden vor dem Termin.')
assert.equal(voll.values.dauer, '45 Minuten')
assert.equal(voll.values.ort, 'Hauptstr. 1, 67722 Winnweiler')
assert.equal(voll.values.nummer, buchungsNummer(BUCHUNG_ID))
assert.equal(voll.values.gast_name, 'Max Mustermann')
const kleinab = werteFuer('G02', ctxMit({ betrieb: betriebMit({ kleinunternehmer: true }) }), buchung({ preisArt: 'ab', preisCent: 4500 }), {})
assert.equal(kleinab.values.preis, 'ab 45,00 € (kein Ausweis der USt nach § 19 UStG)')
const schlicht = werteFuer(
'G02',
ctxMit({ ressourcen: [eingabe().ressourcen[0]], leistungen: [{ id: S1, name: 'Damenschnitt', dauer: 45, ressourcen: [R1] }], storno: { text: '' } }),
buchung({ preisArt: 'keiner', preisCent: 0, buchungsart: 'sofort', dauer: 45, endsAt: '2026-10-12T09:30:00.000+00:00' }),
{},
)
assert.deepEqual(Object.keys(schlicht.blocks).sort(), ['sie', 'widerruf'], 'weder Preis noch Person noch Storno noch Anfrage')
assert.equal(schlicht.values.preis, '–')
assert.equal(schlicht.values.dauer, '1 Stunde 30 Minuten')
const mail = renderMail('G02', schlicht.values, schlicht.blocks)
assert.deepEqual(mail.problems, [])
assert.doesNotMatch(mail.html, />Preis</)
assert.doesNotMatch(mail.html, />Bei</)
})
// ---------------------------------------------------------------- Links
test('Links zeigen auf config.publicBaseUrl: Bestaetigung #v=, Verwaltung #m= (auch widerruf_link), Portal dashboard.html#termine', () => {
config.publicBaseUrl = 'https://portal.example.test'
const ctx = ctxMit()
const manage = `https://portal.example.test/termin.html#m=${verwaltungsToken(BUCHUNG_ID)}`
const g01 = werteFuer('G01', ctx, buchung(), { verifyToken: TOKEN })
assert.equal(g01.values.link, `https://portal.example.test/termin.html#v=${TOKEN}`)
assert.ok(renderMail('G01', g01.values, g01.blocks).html.includes(`href="${g01.values.link}"`))
const g02 = werteFuer('G02', ctx, buchung(), {})
assert.equal(g02.values.link, manage)
assert.equal(g02.values.widerruf_link, manage)
for (const art of ['G03', 'G05', 'G07']) {
assert.equal(werteFuer(art, ctx, buchung(), { altStart: ALT_START }).values.link, manage, art)
}
for (const [art, extra] of [['K20', {}], ['K21', { fall: 'abgesagt' }]]) {
const { values, blocks } = werteFuer(art, ctx, buchung(), extra)
assert.equal(values.portal_link, 'https://portal.example.test/dashboard.html#termine', art)
assert.ok(renderMail(art, values, blocks).html.includes('href="https://portal.example.test/dashboard.html#termine"'), art)
}
assert.doesNotMatch(JSON.stringify(g01) + JSON.stringify(g02), /project\.webklar\.com/)
})
test('Impressum und Datenschutz im Fuss: Angaben des Betriebs, sonst dessen Website, sonst das Portal (nie "–" als href)', () => {
const mitAngaben = werteFuer('G03', ctxMit(), buchung(), {}).values
assert.equal(mitAngaben.impressum_url, 'https://salon.de/impressum')
assert.equal(mitAngaben.datenschutz_url, 'https://salon.de/datenschutz')
const ohneLinks = { impressumUrl: null, datenschutzUrl: null }
const web = werteFuer('G03', ctxMit({ betrieb: betriebMit(ohneLinks) }), buchung(), {}).values
assert.equal(web.impressum_url, 'https://salon.de/')
assert.equal(web.datenschutz_url, 'https://salon.de/')
const live = werteFuer('G03', ctxMit({ betrieb: betriebMit({ ...ohneLinks, websiteUrl: null }) }, { subdomain: 'salon', liveDomain: 'www.salon.de' }), buchung(), {}).values
assert.equal(live.impressum_url, 'https://www.salon.de', 'ohne Angabe die Live-Domain, nicht die Vorschau')
const nackt = werteFuer('G03', ctxMit({ betrieb: betriebMit({ ...ohneLinks, websiteUrl: null }) }, null), buchung(), {}).values
assert.equal(nackt.impressum_url, config.publicBaseUrl)
assert.equal(nackt.datenschutz_url, config.publicBaseUrl)
// Entwurf (R41): nur so gibt es eine Einstellung ohne Telefon und Anschrift; die Mail setzt dann "–"
const leer = werteFuer('G03', ctxMit({ betrieb: betriebMit({ telefon: '', adresse: '' }) }, undefined, { entwurf: true }), buchung(), {}).values
assert.equal(leer.betrieb_telefon, '–')
assert.equal(leer.betrieb_adresse, '–')
assert.equal(mitAngaben.farbe, '#7c3aed')
})
// ---------------------------------------------------------------- G03 bis G08
test('G04: Block nachricht nur mit Nachricht des Betriebs', () => {
const ctx = ctxMit()
assert.deepEqual(werteFuer('G04', ctx, buchung(), {}).blocks, { sie: true })
const mit = werteFuer('G04', ctx, buchung(), { nachricht: 'Leider ausgebucht.' })
assert.deepEqual(mit.blocks, { sie: true, nachricht: true })
assert.match(renderMail('G04', mit.values, mit.blocks).html, /Leider ausgebucht\./)
assert.deepEqual(werteFuer('G04', ctx, buchung(), { nachricht: ' ' }).blocks, { sie: true })
})
test('G05: Person nur bei mehreren Ressourcen', () => {
assert.equal(werteFuer('G05', ctxMit(), buchung(), {}).blocks.person, true)
const einzel = ctxMit({ ressourcen: [eingabe().ressourcen[0]], leistungen: [{ id: S1, name: 'Damenschnitt', dauer: 45, ressourcen: [R1] }] })
assert.equal(werteFuer('G05', einzel, buchung(), {}).blocks.person, undefined)
})
test('G06: absage_von ist anredeneutral (Sie selbst / Du selbst / Betriebsname); Grund und Nachricht nur bei Absage durch den Betrieb', () => {
const sie = ctxMit()
const du = ctxMit({ anrede: 'du' })
const extra = { grund: 'Krankheit', nachricht: 'Wir melden uns.' }
const vonGast = werteFuer('G06', sie, buchung(), { von: 'gast', ...extra })
assert.equal(vonGast.values.absage_von, 'Sie selbst')
assert.deepEqual(vonGast.blocks, { sie: true }, 'Grund/Nachricht des Betriebs gehoeren nicht zu einer Absage des Gastes')
assert.equal(werteFuer('G06', du, buchung(), { von: 'gast' }).values.absage_von, 'Du selbst')
for (const von of ['betrieb', 'system']) {
const erg = werteFuer('G06', du, buchung(), { von, ...extra })
assert.equal(erg.values.absage_von, 'Salon Müller', von)
assert.deepEqual(erg.blocks, { du: true, grund: true, nachricht: true }, von)
assert.equal(erg.values.grund, 'Krankheit')
const mail = renderMail('G06', erg.values, erg.blocks)
assert.match(mail.html, /Krankheit/)
assert.match(mail.html, /Wir melden uns\./)
}
assert.deepEqual(werteFuer('G06', sie, buchung(), { von: 'betrieb', grund: 'Krankheit' }).blocks, { sie: true, grund: true })
assert.deepEqual(werteFuer('G06', sie, buchung(), { von: 'betrieb', nachricht: 'Hallo' }).blocks, { sie: true, nachricht: true })
assert.throws(() => werteFuer('G06', sie, buchung(), {}), TypeError, 'von ist Pflicht')
assert.throws(() => werteFuer('G06', sie, buchung(), { von: 'jemand' }), TypeError)
})
test('G07: alte und neue Zeit in Berliner Zeit', () => {
const { values } = werteFuer('G07', ctxMit(), buchung({ icsSequenz: 1 }), { altStart: ALT_START })
assert.deepEqual([values.alt_datum, values.alt_zeit, values.datum, values.zeit], ['Montag, 12.10.2026', '09:00', 'Montag, 12.10.2026', '10:00'])
assert.throws(() => werteFuer('G07', ctxMit(), buchung(), {}), TypeError, 'altStart fehlt')
})
test('G08: Eingang als "TT.MM.JJJJ, HH:MM Uhr" (Berliner Zeit), Nummer nur der gefundenen Buchung, hoechstens ein Fallblock', () => {
const ctx = ctxMit()
const zugeordnet = werteFuer('G08', ctx, buchung(), { fall: 'zugeordnet', eingang: EINGANG })
assert.equal(zugeordnet.values.eingang, '05.10.2026, 10:07 Uhr')
assert.equal(zugeordnet.values.nummer, buchungsNummer(BUCHUNG_ID))
assert.deepEqual(zugeordnet.blocks, { sie: true, zugeordnet: true })
assert.match(renderMail('G08', zugeordnet.values, zugeordnet.blocks).html, new RegExp(`Buchung ${buchungsNummer(BUCHUNG_ID)}`))
const spaet = werteFuer('G08', ctx, buchung(), { fall: 'nach_frist', eingang: EINGANG })
assert.deepEqual(spaet.blocks, { sie: true, nach_frist: true })
const keine = werteFuer('G08', ctx, null, { fall: 'nicht_zugeordnet', eingang: EINGANG })
assert.deepEqual(keine.blocks, { sie: true }, 'weder zugeordnet noch nach_frist')
assert.equal(keine.values.nummer, '–')
// Widerruf nach Terminbeginn (Frist laeuft noch): neutraler Satz ohne Fallblock und ohne Nummer
const begonnen = werteFuer('G08', ctx, buchung(), { fall: 'begonnen', eingang: EINGANG })
assert.deepEqual(begonnen.blocks, { sie: true })
assert.equal(begonnen.values.nummer, '–')
const text = renderMail('G08', begonnen.values, begonnen.blocks).text
assert.doesNotMatch(text, /abgelaufen|Der Termin ist abgesagt/)
assert.match(text, /prüft Ihre Erklärung und meldet sich bei Ihnen/)
// Nummer aus der Buchung, nie aus einer Eingabe des Gastes
assert.throws(() => werteFuer('G08', ctx, null, { fall: 'zugeordnet', eingang: EINGANG }), TypeError)
assert.throws(() => werteFuer('G08', ctx, buchung(), { eingang: EINGANG }), TypeError, 'fall ist Pflicht')
assert.throws(() => werteFuer('G08', ctx, buchung(), { fall: 'zugeordnet' }), TypeError, 'eingang ist Pflicht')
assert.throws(() => werteFuer('G08', ctx, buchung(), { fall: 'zugeordnet', eingang: 'heute' }), TypeError)
})
test('K20 und K21: Gastangaben, "–" fuer Leeres, Kundenname, ein Fallblock je K21', () => {
const ctx = ctxMit()
const k20 = werteFuer('K20', ctx, buchung({ gastTelefon: '', gastNachricht: '' }), { kundeName: 'Silke Müller' })
assert.deepEqual(k20.blocks, {})
assert.deepEqual(
[k20.values.kunde_name, k20.values.gast_name, k20.values.gast_email, k20.values.gast_telefon, k20.values.gast_nachricht, k20.values.person],
['Silke Müller', 'Max Mustermann', 'max@example.com', '–', '–', 'Anna'],
)
assert.deepEqual(werteFuer('K20', ctx, buchung(), { fall: 'anfrage' }).blocks, { anfrage: true })
assert.equal(werteFuer('K20', ctx, buchung(), {}).values.kunde_name, 'Salon Müller', 'ohne Kundenname der Betriebsname')
for (const fall of ['abgesagt', 'verschoben', 'widerrufen', 'nach_frist', 'begonnen']) {
const { blocks } = werteFuer('K21', ctx, buchung(), { fall, altStart: ALT_START, eingang: EINGANG })
assert.deepEqual(blocks, { [fall]: true }, fall)
}
const frei = werteFuer('K21', ctx, ERKLAERUNG, { fall: 'nicht_zugeordnet', eingang: EINGANG })
assert.deepEqual(frei.blocks, { nicht_zugeordnet: true })
assert.equal(frei.values.gast_name, 'Erika Beispiel')
assert.equal(frei.values.gast_telefon, '–')
assert.equal(frei.values.eingang, '05.10.2026, 10:07 Uhr')
assert.throws(() => werteFuer('K21', ctx, buchung(), {}), TypeError, 'fall ist Pflicht')
assert.throws(() => werteFuer('K21', ctx, buchung(), { fall: 'verschoben' }), TypeError, 'altStart ist Pflicht')
assert.throws(() => werteFuer('K21', ctx, buchung(), { fall: 'widerrufen' }), TypeError, 'eingang ist Pflicht')
})
test('werteFuer: unbekannte Vorlage, fehlende Einstellung und unbrauchbare Buchung werfen TypeError', () => {
assert.throws(() => werteFuer('G99', ctxMit(), buchung(), {}), TypeError)
assert.throws(() => werteFuer('G02', { feature: feature(), einstellung: null }, buchung(), {}), TypeError)
assert.throws(() => werteFuer('G02', ctxMit(), buchung({ startsAt: 'irgendwann' }), {}), TypeError)
assert.throws(() => werteFuer('G02', ctxMit(), buchung({ leistungName: '' }), {}), TypeError)
assert.throws(() => werteFuer('G02', ctxMit(), buchung({ $id: undefined }), {}), TypeError)
})
// ---------------------------------------------------------------- Einreihen
const eintraege = () => [...aw.docs('termineOutbox').values()]
const NUMMER = () => buchungsNummer(BUCHUNG_ID)
test('anGast G02: Outbox-Eintrag an den Gast, Absender Betriebsname, Antworten an Betrieb, Einladung (REQUEST) ohne BCC', async () => {
const doc = await anGast('G02', ctxMit(), buchung(), {}, JETZT)
assert.deepEqual(
{ featureId: doc.featureId, bookingId: doc.bookingId, art: doc.art, an: doc.an, fromName: doc.fromName, replyTo: doc.replyTo, icsMethod: doc.icsMethod },
{ featureId: FEATURE, bookingId: BUCHUNG_ID, art: 'G02', an: 'max@example.com', fromName: 'Salon Müller', replyTo: 'info@salon.de', icsMethod: 'REQUEST' },
)
assert.equal(doc.subject, 'Ihr Termin bei Salon Müller am 12.10.2026')
await warteAufVersand()
assert.equal(sink.sent.length, 1)
const [mail] = sink.sent
assert.deepEqual(mail.from, { address: 'noreply@webklar.com', name: 'Salon Müller' })
assert.deepEqual(mail.to, [{ address: 'max@example.com', name: '' }])
assert.deepEqual(mail.replyTo, [{ address: 'info@salon.de', name: '' }])
assert.equal('bcc' in mail, false)
assert.equal(mail.icalEvent.method, 'REQUEST')
const ics = entfalte(mail.icalEvent.content)
assert.match(ics, /METHOD:REQUEST/)
assert.match(ics, new RegExp(`UID:${BUCHUNG_ID}@termine\\.webklar\\.com`))
assert.match(ics, /SEQUENCE:0/)
assert.match(ics, /DTSTART:20261012T080000Z/)
assert.match(ics, /DTEND:20261012T084500Z/)
assert.match(ics, /SUMMARY:Damenschnitt – Salon Müller/)
assert.match(ics, /LOCATION:Hauptstr\. 1\\, 67722 Winnweiler/)
assert.match(ics, /ORGANIZER;CN="Salon Müller":mailto:info@salon\.de/)
assert.match(ics, /ATTENDEE;CN="Max Mustermann";ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:max@example\.com/)
assert.ok(mail.html.includes(`termin.html#m=${verwaltungsToken(BUCHUNG_ID)}`))
assert.match(mail.text, new RegExp(NUMMER()))
})
test('anGast G07: Einladung mit der Sequenz der Buchung (nach der Verschiebung erhoeht)', async () => {
await anGast('G07', ctxMit(), buchung({ icsSequenz: 1 }), { altStart: ALT_START }, JETZT)
await warteAufVersand()
const [mail] = sink.sent
assert.equal(mail.subject, 'Ihr Termin wurde verschoben')
assert.equal(mail.icalEvent.method, 'REQUEST')
assert.match(mail.icalEvent.content, /SEQUENCE:1/)
assert.match(mail.icalEvent.content, new RegExp(`UID:${BUCHUNG_ID}@termine\\.webklar\\.com`))
})
test('anGast G06: Absage (CANCEL) mit derselben UID und hoeherer Sequenz; extra.sequenz uebersteuert', async () => {
await anGast('G06', ctxMit(), buchung({ status: 'abgesagt', icsSequenz: 2 }), { von: 'gast' }, JETZT)
await anGast('G06', ctxMit(), buchung({ $id: 'b2', status: 'abgesagt' }), { von: 'betrieb', grund: 'Krankheit', sequenz: 7 }, JETZT)
await warteAufVersand()
assert.equal(sink.sent.length, 2)
for (const mail of sink.sent) {
assert.equal(mail.icalEvent.method, 'CANCEL')
assert.match(mail.icalEvent.content, /METHOD:CANCEL/)
assert.match(mail.icalEvent.content, /STATUS:CANCELLED/)
}
const sequenzen = Object.fromEntries(sink.sent.map((m) => [/UID:([^@\r\n]+)@/.exec(m.icalEvent.content)[1], /SEQUENCE:(\d+)/.exec(m.icalEvent.content)[1]]))
assert.deepEqual(sequenzen, { [BUCHUNG_ID]: '3', b2: '7' })
const vonGast = sink.sent.find((m) => m.icalEvent.content.includes(BUCHUNG_ID))
assert.equal(vonGast.subject, 'Ihr Termin am 12.10.2026 wurde abgesagt')
assert.match(vonGast.text, /Sie selbst/)
})
test('anGast: G01, G03, G04, G05 und G08 gehen ohne Kalenderdatei raus', async () => {
const ctx = ctxMit()
await anGast('G01', ctx, buchung(), { verifyToken: TOKEN }, JETZT)
await anGast('G03', ctx, buchung({ $id: 'b3' }), {}, JETZT)
await anGast('G04', ctx, buchung({ $id: 'b4' }), { nachricht: 'Leider nicht.' }, JETZT)
await anGast('G05', ctx, buchung({ $id: 'b5' }), {}, JETZT)
await anGast('G08', ctx, buchung({ $id: 'b8' }), { fall: 'zugeordnet', eingang: EINGANG }, JETZT)
await warteAufVersand()
assert.equal(sink.sent.length, 5)
for (const mail of sink.sent) {
assert.equal('icalEvent' in mail, false, mail.subject)
assert.equal('bcc' in mail, false)
}
assert.deepEqual(eintraege().map((d) => d.icsMethod).filter(Boolean), [])
})
test('anGast ohne Buchung: Adresse aus extra.an, Bezug "-" oder extra.bookingId (G01 zu_viele, G08 nicht_zugeordnet)', async () => {
const ctx = ctxMit({ anrede: 'du' })
const a = await anGast('G01', ctx, null, { fall: 'zu_viele', an: 'dritter@example.com' }, JETZT)
const b = await anGast('G08', ctx, null, { fall: 'nicht_zugeordnet', eingang: EINGANG, an: 'erika@example.com', bookingId: 'widerruf' }, JETZT)
assert.deepEqual([a.an, a.bookingId, b.an, b.bookingId], ['dritter@example.com', '-', 'erika@example.com', 'widerruf'])
assert.equal(a.subject, 'Dein Termin bei Salon Müller konnte nicht vorgemerkt werden')
await warteAufVersand()
assert.equal(sink.sent.length, 2)
await assert.rejects(() => anGast('G02', ctx, buchung({ gastEmail: '' }), {}, JETZT), TypeError, 'ohne Empfaenger')
})
test('anGast: Betriebsmails und unbekannte Vorlagen werden abgewiesen, nichts wird gespeichert', async () => {
await assert.rejects(() => anGast('K20', ctxMit(), buchung(), {}, JETZT), TypeError)
await assert.rejects(() => anGast('G09', ctxMit(), buchung(), {}, JETZT), TypeError)
await assert.rejects(() => anGast('G02', ctxMit(), buchung(), {}, 'gestern'), TypeError)
assert.equal(eintraege().length, 0)
})
test('anBetrieb K20: an die Betriebs-E-Mail, Absender WEBklar, ohne Antworten-an und BCC, Kalendereintrag (PUBLISH) ohne Gast', async () => {
aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: 'silke@example.com' })
const doc = await anBetrieb('K20', ctxMit(), buchung(), {}, JETZT)
assert.deepEqual(
{ an: doc.an, fromName: doc.fromName, replyTo: doc.replyTo, art: doc.art, bookingId: doc.bookingId, icsMethod: doc.icsMethod },
{ an: 'info@salon.de', fromName: 'WEBklar', replyTo: '', art: 'K20', bookingId: BUCHUNG_ID, icsMethod: 'PUBLISH' },
)
assert.equal(doc.subject, 'Neue Buchung: Damenschnitt am 12.10.2026 um 10:00')
await warteAufVersand()
const [mail] = sink.sent
assert.deepEqual(mail.from, { address: 'noreply@webklar.com', name: 'WEBklar' })
assert.deepEqual(mail.to, [{ address: 'info@salon.de', name: '' }])
assert.ok(!mail.replyTo, 'kein Antworten-an')
assert.equal('bcc' in mail, false)
assert.match(mail.text, /Hallo Silke Müller,/)
assert.match(mail.text, /Max Mustermann/)
assert.equal(mail.icalEvent.method, 'PUBLISH')
const ics = mail.icalEvent.content
assert.match(ics, /METHOD:PUBLISH/)
assert.match(ics, /SUMMARY:Damenschnitt: Max Mustermann/)
assert.doesNotMatch(ics, /ATTENDEE/)
assert.match(ics, new RegExp(`UID:${BUCHUNG_ID}@termine\\.webklar\\.com`))
})
test('anBetrieb: K20 "anfrage" ohne Kalendereintrag, K21 je Fall; ohne Kundendokument der Betriebsname als Anrede', async () => {
const ctx = ctxMit()
await anBetrieb('K20', ctx, buchung({ $id: 'b_anfrage', status: 'angefragt' }), { block: 'anfrage' }, JETZT)
await anBetrieb('K21', ctx, buchung({ $id: 'b_abgesagt' }), { block: 'abgesagt' }, JETZT)
await anBetrieb('K21', ctx, buchung({ $id: 'b_verschoben' }), { block: 'verschoben', extra: { altStart: ALT_START } }, JETZT)
await anBetrieb('K21', ctx, null, { block: 'nicht_zugeordnet', extra: { eingang: EINGANG, bookingId: 'widerruf' } }, JETZT)
await warteAufVersand()
assert.equal(sink.sent.length, 4)
for (const mail of sink.sent) {
assert.equal('icalEvent' in mail, false, mail.subject)
assert.deepEqual(mail.to, [{ address: 'info@salon.de', name: '' }])
assert.match(mail.text, /Hallo Salon Müller,/, 'kein Kundendokument => Betriebsname')
}
const betreffe = sink.sent.map((m) => m.subject).sort()
assert.deepEqual(betreffe, [
'Neue Terminanfrage: Damenschnitt am 12.10.2026 um 10:00',
'Termin abgesagt: Damenschnitt am 12.10.2026 um 10:00',
'Termin verschoben: Damenschnitt, jetzt am 12.10.2026 um 10:00',
'Widerruf ohne passende Buchung',
])
assert.deepEqual(eintraege().map((d) => d.bookingId).sort(), ['b_abgesagt', 'b_anfrage', 'b_verschoben', 'widerruf'])
})
test('anBetrieb: Gaestemails und unbekannte Vorlagen werden abgewiesen, K21 braucht einen Fall', async () => {
await assert.rejects(() => anBetrieb('G02', ctxMit(), buchung(), {}, JETZT), TypeError)
await assert.rejects(() => anBetrieb('K21', ctxMit(), buchung(), {}, JETZT), TypeError, 'ohne block')
await assert.rejects(() => anBetrieb('K20', ctxMit(), buchung(), {}, 'gestern'), TypeError)
assert.equal(eintraege().length, 0)
})