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>
1090 lines
60 KiB
JavaScript
1090 lines
60 KiB
JavaScript
import { test, after, beforeEach, afterEach, mock } from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||
import { mailSink } from './support/mailSink.js'
|
||
import { schemaKeys } from './support/schema.js'
|
||
import { useTestEnv } from './support/testEnv.js'
|
||
import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
|
||
|
||
// 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)
|
||
})
|