1519 lines
85 KiB
JavaScript
1519 lines
85 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, fehlendePflichtangaben } = 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 { leerFragebogen, normalisiereFragebogen, FRAGEBOGEN_MAX } = await import('../server/services/termine/fragebogen.js')
|
||
const { activate } = await import('../server/services/siteFeatures.js')
|
||
const K = await import('../server/services/termine/kontext.js')
|
||
const M = await import('../server/services/termine/mails.js')
|
||
const { ladeEinstellung, ladeStand, speichereEinstellung, speichereFragebogen, kontextPerSiteKey, kontextPerFeature, kontextFuerPortal, websiteUrl, liveHost, 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, fragebogen: leerFragebogen() }, 'ohne gespeicherten Fragebogen der leere')
|
||
})
|
||
|
||
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')
|
||
})
|
||
})
|
||
|
||
// ---------------------------------------------------------------- ladeStand (TP2 D4: Stand fuer das Einrichtungs-Ticket)
|
||
|
||
test('ladeStand: ohne termineConfig-Dokument null, mit Dokument Einstellung, Version, updatedAt und der Fragebogen als gespeicherte Angaben', async () => {
|
||
assert.equal(await ladeStand(FEATURE), null, 'noch nichts gespeichert')
|
||
const e = einstellung()
|
||
const fb = frage({ branche: 'salon', bestaetigt: ['branche'], einbau: { modus: 'knopf', knopfText: 'Termin buchen', stelle: 'Startseite' } })
|
||
await speichereEinstellung(feature(), e, { version: 0, now: JETZT, fragebogen: fb })
|
||
const stand = await ladeStand(FEATURE)
|
||
assert.deepEqual(Object.keys(stand).sort(), ['einstellung', 'fragebogen', 'updatedAt', 'version'])
|
||
assert.deepEqual(stand.einstellung, e)
|
||
assert.equal(stand.version, 1)
|
||
assert.equal(new Date(stand.updatedAt).getTime(), JETZT.getTime())
|
||
assert.deepEqual(stand.fragebogen, JSON.parse(gespeichert().fragebogen))
|
||
assert.equal(stand.fragebogen.einbau.knopfText, 'Termin buchen')
|
||
await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ name: 'Salon Neu' }) }), { version: 1, now: SEKUNDEN(60) })
|
||
const zweit = await ladeStand(FEATURE)
|
||
assert.equal(zweit.version, 2)
|
||
assert.equal(zweit.einstellung.betrieb.name, 'Salon Neu')
|
||
assert.equal(new Date(zweit.updatedAt).getTime(), SEKUNDEN(60).getTime())
|
||
assert.deepEqual(zweit.fragebogen, stand.fragebogen, 'der Fragebogen bleibt, wenn nur die Einstellung gespeichert wird')
|
||
assert.equal(log.error.mock.callCount(), 0, 'lesbare Dokumente: keine Logzeile')
|
||
})
|
||
|
||
test('ladeStand: kaputtes fragebogen-JSON gibt fragebogen null bei gueltiger Einstellung (mit Log), kaputte Einstellung gibt einstellung null mit der Version', async () => {
|
||
const e = einstellung()
|
||
aw.seed('termineConfig', { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, einstellung: JSON.stringify(e), version: 2, updatedAt: JETZT.toISOString(), updatedBy: '', fragebogen: '{kaputt' })
|
||
const stand = await ladeStand(FEATURE)
|
||
assert.deepEqual(stand.einstellung, e)
|
||
assert.equal(stand.fragebogen, null)
|
||
assert.equal(stand.version, 2)
|
||
assert.equal(log.error.mock.callCount(), 1)
|
||
assert.match(String(log.error.mock.calls[0].arguments[0]), /Fragebogen f_p_projekt01_termine/)
|
||
|
||
const fb = frage({ branche: 'salon' })
|
||
await deleteDocument('termineConfig', FEATURE)
|
||
aw.seed('termineConfig', { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, einstellung: '{kaputt', version: 3, updatedAt: JETZT.toISOString(), updatedBy: '', fragebogen: JSON.stringify(fb) })
|
||
const kaputt = await ladeStand(FEATURE)
|
||
assert.equal(kaputt.einstellung, null)
|
||
assert.equal(kaputt.version, 3, 'die Version des Dokuments bleibt')
|
||
assert.deepEqual(kaputt.fragebogen, fb)
|
||
assert.equal(log.error.mock.callCount(), 2)
|
||
})
|
||
|
||
test('ladeStand: fehlendes oder leeres fragebogen-Attribut, ein Nicht-Objekt und fehlende Zeit ergeben null; Version ohne Zahl ist 0', async () => {
|
||
const e = einstellung()
|
||
const doc = { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, einstellung: JSON.stringify(e), version: 1, updatedAt: JETZT.toISOString(), updatedBy: '' }
|
||
for (const fragebogen of [undefined, null, '']) {
|
||
await deleteDocument('termineConfig', FEATURE).catch(() => {})
|
||
aw.seed('termineConfig', { ...doc, ...(fragebogen === undefined ? {} : { fragebogen }) })
|
||
assert.equal((await ladeStand(FEATURE)).fragebogen, null, String(fragebogen))
|
||
}
|
||
assert.equal(log.error.mock.callCount(), 0, 'nichts gespeichert ist kein Fehler')
|
||
for (const roh of ['[1,2]', '"text"', '42', 'null']) {
|
||
await deleteDocument('termineConfig', FEATURE)
|
||
aw.seed('termineConfig', { ...doc, fragebogen: roh })
|
||
assert.equal((await ladeStand(FEATURE)).fragebogen, null, roh)
|
||
}
|
||
await deleteDocument('termineConfig', FEATURE)
|
||
aw.seed('termineConfig', { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, einstellung: JSON.stringify(e) })
|
||
const ohneZeit = await ladeStand(FEATURE)
|
||
assert.equal(ohneZeit.updatedAt, null)
|
||
assert.equal(ohneZeit.version, 0)
|
||
assert.deepEqual(ohneZeit.einstellung, e)
|
||
})
|
||
|
||
test('ladeStand: Eingabepruefung wie ladeEinstellung, fehlende Collection ist ein Fehler und kein "nichts"', async () => {
|
||
for (const id of [undefined, null, '', 7]) await assert.rejects(() => ladeStand(id), TypeError, String(id))
|
||
await mitFehlenderCollection('termineConfig', async () => {
|
||
await assert.rejects(() => ladeStand(FEATURE), (err) => err.status === 404 && 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)
|
||
})
|
||
|
||
test('liveHost: Live-Host nur aus liveDomain, nie die Vorschau; websiteUrl nutzt denselben Host', () => {
|
||
const faelle = [
|
||
[{ subdomain: 'salon', liveDomain: 'www.salon.de' }, 'www.salon.de'],
|
||
[{ subdomain: 'salon', liveDomain: 'https://WWW.Salon.de/start?x=1' }, 'www.salon.de'],
|
||
[{ liveDomain: 'salon.de' }, 'salon.de'],
|
||
[{ subdomain: ' Salon ', liveDomain: 'salon.project.webklar.com' }, undefined],
|
||
[{ subdomain: 'salon', liveDomain: 'kein host!' }, undefined],
|
||
[{ subdomain: 'salon', liveDomain: '' }, undefined],
|
||
[{ subdomain: 'salon' }, undefined],
|
||
[{}, undefined],
|
||
[null, undefined],
|
||
]
|
||
for (const [project, erwartet] of faelle) {
|
||
assert.equal(liveHost(project), erwartet, JSON.stringify(project))
|
||
const ohneUrl = { einstellung: { betrieb: betriebMit({ websiteUrl: null }) }, project }
|
||
if (erwartet) assert.equal(websiteUrl(ohneUrl), `https://${erwartet}`, `websiteUrl folgt liveHost: ${JSON.stringify(project)}`)
|
||
}
|
||
})
|
||
|
||
// ---------------------------------------------------------------- Fragebogen im Speicher und im Kontext (TP2)
|
||
|
||
/** Normalisierter Fragebogen aus einer (teilweisen) Eingabe; die Eingabe muss gueltig sein. */
|
||
const frage = (over = {}) => {
|
||
const erg = normalisiereFragebogen({ ...leerFragebogen(), ...over })
|
||
assert.equal(erg.fehler, undefined, JSON.stringify(erg.fehler))
|
||
return erg.fragebogen
|
||
}
|
||
|
||
test('speichereFragebogen: aendert nur fragebogen, updatedAt und updatedBy; einstellung und version bleiben bytegleich, ladeEinstellung liefert den neuen Stand', async () => {
|
||
const e = einstellung()
|
||
await speichereEinstellung(feature(), e, { version: 0, by: 'kunde:c_kunde01', now: JETZT })
|
||
const vorher = { ...gespeichert() }
|
||
|
||
const gesehen = []
|
||
const erg = await speichereFragebogen(feature(), (bisher) => {
|
||
gesehen.push(structuredClone(bisher))
|
||
return { ...bisher, branche: 'salon', bestaetigt: ['wer', 'branche'], einbau: { ...bisher.einbau, modus: 'knopf', knopfText: 'Termin buchen' } }
|
||
}, { by: 'kunde:zweiter', now: SEKUNDEN(5) })
|
||
const erwartet = frage({ branche: 'salon', bestaetigt: ['branche', 'wer'], einbau: { modus: 'knopf', knopfText: 'Termin buchen' } })
|
||
assert.deepEqual(gesehen, [leerFragebogen()], 'ohne gespeicherten Fragebogen bekommt aendere den leeren')
|
||
assert.deepEqual(erg, { fragebogen: erwartet }, 'zurueck kommt der normalisierte Fragebogen')
|
||
|
||
const doc = gespeichert()
|
||
assert.equal(doc.einstellung, vorher.einstellung, 'einstellung bytegleich')
|
||
assert.equal(doc.version, vorher.version, 'version bleibt')
|
||
assert.equal(doc.version, 1)
|
||
assert.equal(doc.customerId, KUNDE)
|
||
assert.equal(doc.projectId, PROJEKT)
|
||
assert.equal(typeof doc.fragebogen, 'string', 'JSON-Text')
|
||
assert.deepEqual(JSON.parse(doc.fragebogen), erwartet)
|
||
assert.equal(doc.updatedBy, 'kunde:zweiter')
|
||
assert.equal(new Date(doc.updatedAt).getTime(), SEKUNDEN(5).getTime())
|
||
assert.deepEqual(await ladeEinstellung(FEATURE), { einstellung: e, version: 1, fragebogen: erwartet })
|
||
|
||
// zweites Mal: aendere sieht den gespeicherten Stand; updatedBy wird auf 100 Zeichen gekuerzt
|
||
await speichereFragebogen(feature(), (bisher) => {
|
||
gesehen.push(structuredClone(bisher))
|
||
return { ...bisher, preiseGeprueft: true }
|
||
}, { by: 'x'.repeat(300) })
|
||
assert.deepEqual(gesehen[1], erwartet)
|
||
assert.equal(gespeichert().updatedBy.length, 100)
|
||
assert.equal(gespeichert().einstellung, vorher.einstellung)
|
||
assert.equal((await ladeEinstellung(FEATURE)).fragebogen.preiseGeprueft, true)
|
||
assert.equal((await ladeEinstellung(FEATURE)).version, 1)
|
||
})
|
||
|
||
test('speichereFragebogen: auch eine asynchrone aendere-Funktion; unbekannte Schluessel und __proto__ aus der Eingabe landen nie im Speicher', async () => {
|
||
await speichereEinstellung(feature(), einstellung(), { version: 0 })
|
||
const boese = JSON.parse('{"branche":"salon","__proto__":{"admin":true},"fremd":1,"einbau":{"stelle":"Startseite","__proto__":{"x":1},"zusatz":"y"}}')
|
||
const erg = await speichereFragebogen(feature(), async () => boese, {})
|
||
assert.deepEqual(erg.fragebogen, frage({ branche: 'salon', einbau: { stelle: 'Startseite' } }))
|
||
assert.deepEqual(JSON.parse(gespeichert().fragebogen), erg.fragebogen)
|
||
assert.equal(Object.hasOwn(JSON.parse(gespeichert().fragebogen), 'fremd'), false)
|
||
})
|
||
|
||
test('speichereFragebogen: ungueltige Eingabe => 400 mit err.fehler, eigene Fehler gehen unveraendert durch, nichts wird geschrieben; falsche Argumente => TypeError', async () => {
|
||
await speichereEinstellung(feature(), einstellung(), { version: 0 })
|
||
await speichereFragebogen(feature(), () => frage({ branche: 'salon' }), {})
|
||
const vorher = { ...gespeichert() }
|
||
|
||
await assert.rejects(
|
||
() => speichereFragebogen(feature(), (bisher) => ({ ...bisher, einbau: { ...bisher.einbau, modus: 'popup' } }), {}),
|
||
(err) => err.status === 400 && typeof err.fehler['einbau.modus'] === 'string',
|
||
)
|
||
for (const unsinn of [null, undefined, 'x', 5, []]) {
|
||
await assert.rejects(
|
||
() => speichereFragebogen(feature(), () => unsinn, {}),
|
||
(err) => err.status === 400 && typeof err.fehler._ === 'string',
|
||
String(unsinn),
|
||
)
|
||
}
|
||
const eigener = Object.assign(new Error('Eingabe'), { status: 400, fehler: { branche: 'x' } })
|
||
await assert.rejects(() => speichereFragebogen(feature(), async () => { throw eigener }, {}), (err) => err === eigener)
|
||
assert.deepEqual(gespeichert(), vorher, 'kein Schreibzugriff')
|
||
|
||
for (const unsinn of [undefined, null, 'x', {}]) await assert.rejects(() => speichereFragebogen(feature(), unsinn, {}), TypeError)
|
||
await assert.rejects(() => speichereFragebogen({}, () => frage(), {}), TypeError)
|
||
await assert.rejects(() => speichereFragebogen(feature(), () => frage(), { now: 'gestern' }), TypeError)
|
||
assert.deepEqual(gespeichert(), vorher)
|
||
// das Sperrschloss ist wieder frei
|
||
assert.equal((await speichereFragebogen(feature(), () => frage({ branche: 'kosmetik' }), {})).fragebogen.branche, 'kosmetik')
|
||
})
|
||
|
||
test('speichereFragebogen ohne termineConfig-Dokument: 409 keine_einstellung, aendere laeuft nie, nichts wird angelegt', async () => {
|
||
seedFeature()
|
||
let aufrufe = 0
|
||
await assert.rejects(
|
||
() => speichereFragebogen(feature(), () => { aufrufe += 1; return frage() }, {}),
|
||
(err) => err.status === 409 && err.code === 'keine_einstellung' && typeof err.message === 'string' && err.message !== '',
|
||
)
|
||
assert.equal(aufrufe, 0)
|
||
assert.equal(aw.docs('termineConfig').size, 0)
|
||
assert.equal(await ladeEinstellung(FEATURE), null)
|
||
})
|
||
|
||
test('speichereFragebogen: der groesste zulaessige Fragebogen bleibt im Speicher unter FRAGEBOGEN_MAX', async () => {
|
||
await speichereEinstellung(feature(), einstellung(), { version: 0 })
|
||
const alle = ['branche', 'wer', 'leistungen', 'zeiten', 'regeln', 'gastfelder', 'benachrichtigung', 'website']
|
||
await speichereFragebogen(feature(), () => frage({
|
||
branche: 'sonstiges',
|
||
bestaetigt: alle,
|
||
bundeslandGewaehlt: true,
|
||
preiseGeprueft: true,
|
||
// Zeichen, die im JSON-Text verdoppelt werden
|
||
einbau: { modus: 'inline', knopfText: '"'.repeat(40), stelle: '\\'.repeat(300), gestaltung: '"'.repeat(1000), bemerkung: '\\'.repeat(1000) },
|
||
}), {})
|
||
assert.ok(gespeichert().fragebogen.length < FRAGEBOGEN_MAX, `${gespeichert().fragebogen.length} Zeichen`)
|
||
assert.equal(JSON.parse(gespeichert().fragebogen).einbau.bemerkung.length, 1000)
|
||
})
|
||
|
||
test('speichereEinstellung ohne Option fragebogen laesst den gespeicherten Fragebogen stehen (bytegleich), auch in der Funktionsform', async () => {
|
||
await speichereEinstellung(feature(), einstellung(), { version: 0 })
|
||
await speichereFragebogen(feature(), () => frage({ branche: 'salon', bestaetigt: ['branche'] }), {})
|
||
const roh = gespeichert().fragebogen
|
||
assert.ok(roh)
|
||
|
||
await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ name: 'Neu' }) }), { version: 1 })
|
||
assert.equal(gespeichert().fragebogen, roh)
|
||
await speichereEinstellung(feature(), async (bisher) => ({ ...bisher, farbe: '#112233' }), { version: null })
|
||
assert.equal(gespeichert().fragebogen, roh)
|
||
|
||
const geladen = await ladeEinstellung(FEATURE)
|
||
assert.equal(geladen.version, 3)
|
||
assert.equal(geladen.einstellung.betrieb.name, 'Neu')
|
||
assert.deepEqual(geladen.fragebogen, frage({ branche: 'salon', bestaetigt: ['branche'] }))
|
||
})
|
||
|
||
test('speichereEinstellung mit Option fragebogen: Neuanlage auf leerer Collection schreibt Einstellung und Fragebogen in ein Dokument', async () => {
|
||
const e = einstellung()
|
||
const erg = await speichereEinstellung(feature(), e, { version: 0, by: 'kunde:c_kunde01', now: JETZT, fragebogen: { branche: 'salon' } })
|
||
assert.equal(erg.version, 1)
|
||
assert.deepEqual(erg.einstellung, e)
|
||
assert.equal(aw.docs('termineConfig').size, 1)
|
||
const doc = gespeichert()
|
||
assert.deepEqual(JSON.parse(doc.einstellung), e)
|
||
assert.deepEqual(JSON.parse(doc.fragebogen), frage({ branche: 'salon' }), 'normalisiert (alle Felder) gespeichert')
|
||
assert.equal(doc.version, 1)
|
||
assert.equal(doc.updatedBy, 'kunde:c_kunde01')
|
||
assert.deepEqual(await ladeEinstellung(FEATURE), { einstellung: e, version: 1, fragebogen: frage({ branche: 'salon' }) })
|
||
})
|
||
|
||
test('speichereEinstellung mit Option fragebogen: ersetzt den Fragebogen in derselben Sperre (Vorlage ersetzen), Objekt- und Funktionsform, Version zaehlt einmal', async () => {
|
||
await speichereEinstellung(feature(), einstellung(), { version: 0 })
|
||
const einbau = { modus: 'knopf', knopfText: 'Termin buchen', stelle: 'Startseite' }
|
||
await speichereFragebogen(feature(), () => frage({ branche: 'salon', bestaetigt: ['branche', 'wer'], preiseGeprueft: true, bundeslandGewaehlt: true, einbau }), {})
|
||
|
||
// Funktionsform: sieht den gespeicherten Fragebogen und setzt Bestaetigungen zurueck, der Einbau bleibt
|
||
const gesehen = []
|
||
const ersatz = einstellung({ betrieb: betriebMit({ name: 'Ersatz' }) })
|
||
const erg = await speichereEinstellung(feature(), ersatz, {
|
||
version: 1,
|
||
fragebogen: (bisher) => {
|
||
gesehen.push(structuredClone(bisher))
|
||
return { ...bisher, branche: 'kosmetik', bestaetigt: [], preiseGeprueft: false, bundeslandGewaehlt: false }
|
||
},
|
||
})
|
||
assert.equal(erg.version, 2)
|
||
assert.equal(gesehen.length, 1)
|
||
assert.equal(gesehen[0].branche, 'salon')
|
||
const geladen = await ladeEinstellung(FEATURE)
|
||
assert.equal(geladen.version, 2)
|
||
assert.equal(geladen.einstellung.betrieb.name, 'Ersatz')
|
||
assert.deepEqual(geladen.fragebogen, frage({ branche: 'kosmetik', einbau }))
|
||
|
||
// Objektform ersetzt vollstaendig
|
||
await speichereEinstellung(feature(), ersatz, { version: 2, fragebogen: { branche: 'beratung' } })
|
||
assert.deepEqual((await ladeEinstellung(FEATURE)).fragebogen, frage({ branche: 'beratung' }))
|
||
assert.equal((await ladeEinstellung(FEATURE)).version, 3)
|
||
})
|
||
|
||
test('speichereEinstellung mit ungueltigem fragebogen: 400 mit err.fehler, weder Einstellung noch Version noch Dokument werden geschrieben', async () => {
|
||
const falsch = (err) => err.status === 400 && typeof err.fehler === 'object' && Object.keys(err.fehler).length > 0
|
||
await assert.rejects(() => speichereEinstellung(feature(), einstellung(), { version: 0, fragebogen: { branche: 'gibtsnicht' } }), (err) => falsch(err) && typeof err.fehler.branche === 'string')
|
||
assert.equal(aw.docs('termineConfig').size, 0, 'Neuanlage scheitert ganz')
|
||
|
||
await speichereEinstellung(feature(), einstellung(), { version: 0, fragebogen: { branche: 'salon' } })
|
||
const vorher = { ...gespeichert() }
|
||
const neu = einstellung({ betrieb: betriebMit({ name: 'Anders' }) })
|
||
await assert.rejects(() => speichereEinstellung(feature(), neu, { version: 1, fragebogen: { einbau: { modus: 'popup' } } }), (err) => falsch(err) && typeof err.fehler['einbau.modus'] === 'string')
|
||
for (const unsinn of ['x', 5, [], null]) {
|
||
await assert.rejects(() => speichereEinstellung(feature(), neu, { version: 1, fragebogen: unsinn }), falsch, String(unsinn))
|
||
}
|
||
assert.deepEqual(gespeichert(), vorher)
|
||
assert.equal((await ladeEinstellung(FEATURE)).version, 1)
|
||
// eine veraltete Version meldet weiter zuerst 409
|
||
await assert.rejects(() => speichereEinstellung(feature(), neu, { version: 0, fragebogen: { branche: 'gibtsnicht' } }), (err) => err.status === 409 && err.code === 'EINSTELLUNG_VERALTET')
|
||
})
|
||
|
||
test('speichereEinstellung (Version 1) und speichereFragebogen gleichzeitig: nacheinander unter derselben Sperre, beide Aenderungen gelten, die Version steigt genau einmal', async () => {
|
||
for (const zuerst of ['einstellung', 'fragebogen']) {
|
||
await leere()
|
||
resetKontextForTests()
|
||
await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ name: 'Start' }) }), { version: 0 })
|
||
const ablauf = []
|
||
const langsam = async (name) => {
|
||
ablauf.push(`${name} start`)
|
||
await new Promise((resolve) => setTimeout(resolve, 20)) // der Zweite haette Zeit, dazwischenzufunken
|
||
ablauf.push(`${name} ende`)
|
||
}
|
||
const alsEinstellung = () => speichereEinstellung(feature(), async (bisher) => {
|
||
await langsam('einstellung')
|
||
return { ...bisher, betrieb: { ...bisher.betrieb, name: 'Neu' } }
|
||
}, { version: 1 })
|
||
const alsFragebogen = () => speichereFragebogen(feature(), async (bisher) => {
|
||
await langsam('fragebogen')
|
||
return { ...bisher, branche: 'salon' }
|
||
}, {})
|
||
await Promise.all(zuerst === 'einstellung' ? [alsEinstellung(), alsFragebogen()] : [alsFragebogen(), alsEinstellung()])
|
||
|
||
const zweiter = zuerst === 'einstellung' ? 'fragebogen' : 'einstellung'
|
||
assert.deepEqual(ablauf, [`${zuerst} start`, `${zuerst} ende`, `${zweiter} start`, `${zweiter} ende`], zuerst)
|
||
const geladen = await ladeEinstellung(FEATURE)
|
||
assert.equal(geladen.version, 2, `Version steigt genau einmal (${zuerst} zuerst)`)
|
||
assert.equal(geladen.einstellung.betrieb.name, 'Neu', zuerst)
|
||
assert.equal(geladen.fragebogen.branche, 'salon', zuerst)
|
||
}
|
||
})
|
||
|
||
test('kaputter oder unbrauchbarer Fragebogen im Dokument: leerer Fragebogen mit einer Logzeile ohne Wurf, die Einstellung bleibt gueltig; fehlend oder leer ohne Logzeile', async () => {
|
||
seedFeature()
|
||
seedProjekt()
|
||
const e = einstellung()
|
||
const basis = { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, einstellung: JSON.stringify(e), version: 2 }
|
||
const kaputt = ['{kaputt', '[]', '"x"', 'null', '5', '{"branche":"gibtsnicht"}', '{"einbau":{"modus":"popup"}}']
|
||
for (const roh of kaputt) {
|
||
log.error.mock.resetCalls()
|
||
aw.seed('termineConfig', { ...basis, fragebogen: roh })
|
||
assert.deepEqual(await ladeEinstellung(FEATURE), { einstellung: e, version: 2, fragebogen: leerFragebogen() }, roh)
|
||
assert.equal(log.error.mock.callCount(), 1, `eine Logzeile: ${roh}`)
|
||
const zeile = String(log.error.mock.calls[0].arguments[0])
|
||
assert.match(zeile, /Fragebogen f_p_projekt01_termine/)
|
||
const ctx = await kontextFuerPortal(feature())
|
||
assert.deepEqual(ctx.fragebogen, leerFragebogen(), roh)
|
||
assert.deepEqual(ctx.einstellung, e, roh)
|
||
assert.equal(ctx.version, 2, roh)
|
||
}
|
||
|
||
for (const ohne of [{}, { fragebogen: '' }, { fragebogen: null }]) {
|
||
log.error.mock.resetCalls()
|
||
aw.seed('termineConfig', { ...basis, ...ohne })
|
||
assert.deepEqual((await ladeEinstellung(FEATURE)).fragebogen, leerFragebogen(), JSON.stringify(ohne))
|
||
assert.equal(log.error.mock.callCount(), 0, JSON.stringify(ohne))
|
||
}
|
||
|
||
// der Schaden laesst sich ueberschreiben: aendere bekommt den leeren Fragebogen, die Einstellung bleibt unberuehrt
|
||
aw.seed('termineConfig', { ...basis, fragebogen: '{kaputt' })
|
||
let gesehen
|
||
await speichereFragebogen(feature(), (bisher) => { gesehen = bisher; return frage({ branche: 'salon' }) }, {})
|
||
assert.deepEqual(gesehen, leerFragebogen())
|
||
assert.equal(gespeichert().einstellung, basis.einstellung)
|
||
assert.equal(gespeichert().version, 2)
|
||
assert.deepEqual((await ladeEinstellung(FEATURE)).fragebogen, frage({ branche: 'salon' }))
|
||
})
|
||
|
||
test('ctx.fragebogen: Kontext per siteKey, per Feature und fuers Portal tragen ihn (ohne Dokument den leeren); speichereFragebogen leert den Speicher', async () => {
|
||
seedFeature()
|
||
seedProjekt()
|
||
assert.deepEqual((await kontextPerFeature(FEATURE)).fragebogen, leerFragebogen(), 'kein termineConfig-Dokument')
|
||
await speichereEinstellung(feature(), einstellung(), { version: 0 })
|
||
assert.deepEqual((await kontextPerSiteKey(SITE_KEY, T0)).fragebogen, leerFragebogen())
|
||
|
||
await speichereFragebogen(feature(), () => frage({ branche: 'salon', bestaetigt: ['branche'] }), {})
|
||
const neu = await kontextPerSiteKey(SITE_KEY, SEKUNDEN(1))
|
||
assert.deepEqual(neu.fragebogen, frage({ branche: 'salon', bestaetigt: ['branche'] }), 'sofort sichtbar: Speicher geleert')
|
||
assert.equal(neu.version, 1, 'die Version der Einstellung zaehlt nicht hoch')
|
||
assert.equal((await kontextPerFeature(FEATURE)).fragebogen.branche, 'salon')
|
||
assert.equal((await kontextFuerPortal(feature())).fragebogen.branche, 'salon')
|
||
})
|
||
|
||
test('kontextFuerPortal: das uebergebene Dokument in jedem Zustand und bei jedem Modus, ohne Nachschlagen und ohne Speicher', async () => {
|
||
seedProjekt()
|
||
const e = einstellung()
|
||
// bewusst kein portalFeatures-Dokument in der Datenbank: der Kontext nimmt das uebergebene, Modus und Zustand prueft der Aufrufer
|
||
await speichereEinstellung(feature(), e, { version: 0 })
|
||
await speichereFragebogen(feature(), () => frage({ branche: 'salon' }), {})
|
||
assert.equal(aw.docs('portalFeatures').size, 0)
|
||
|
||
for (const modus of ['pilot', 'off']) {
|
||
config.termine.mode = modus
|
||
for (const state of ['off', 'ordered', 'setup', 'active', 'disabled']) {
|
||
const doc = feature({ state })
|
||
const ctx = await kontextFuerPortal(doc)
|
||
const was = `${modus}/${state}`
|
||
assert.equal(ctx.feature, doc, was)
|
||
assert.equal(ctx.feature.state, state, was)
|
||
assert.equal(ctx.project.$id, PROJEKT, was)
|
||
assert.deepEqual(ctx.einstellung, e, was)
|
||
assert.equal(ctx.version, 1, was)
|
||
assert.equal(ctx.fragebogen.branche, 'salon', was)
|
||
}
|
||
}
|
||
|
||
// kein Speicher: eine Aenderung ist sofort sichtbar, auch ohne dass jemand den Speicher leert
|
||
await speichereFragebogen(feature(), (b) => ({ ...b, branche: 'kosmetik' }), {})
|
||
assert.equal((await kontextFuerPortal(feature())).fragebogen.branche, 'kosmetik')
|
||
|
||
// ohne termineConfig-Dokument und ohne Projekt
|
||
await deleteDocument('termineConfig', FEATURE)
|
||
const leer = await kontextFuerPortal(feature({ projectId: 'p_gibt_es_nicht', state: 'off' }))
|
||
assert.equal(leer.einstellung, null)
|
||
assert.equal(leer.version, 0)
|
||
assert.equal(leer.project, null)
|
||
assert.deepEqual(leer.fragebogen, leerFragebogen())
|
||
|
||
for (const unsinn of [undefined, null, {}, { $id: '' }, 'f_x']) await assert.rejects(() => kontextFuerPortal(unsinn), TypeError, String(unsinn))
|
||
})
|
||
|
||
test('Entwurf nie oeffentlich: kontextPerSiteKey ist fuer off, ordered, setup und disabled trotz vollstaendiger Einstellung null, kontextFuerPortal liefert sie; nach activate gibt es den Kontext', async () => {
|
||
const e = einstellung()
|
||
assert.deepEqual(fehlendePflichtangaben(e), [], 'die Einstellung ist fuer Gaeste vollstaendig')
|
||
await speichereEinstellung(feature(), e, { version: 0 })
|
||
await speichereFragebogen(feature(), () => frage({ branche: 'salon', bestaetigt: ['branche', 'wer', 'leistungen'] }), {})
|
||
seedProjekt()
|
||
|
||
for (const state of ['off', 'ordered', 'setup', 'disabled']) {
|
||
resetKontextForTests()
|
||
seedFeature({ state, ...(state === 'disabled' ? { disabledAt: JETZT.toISOString(), disabledReason: 'abo_ende' } : {}) })
|
||
assert.equal(await kontextPerSiteKey(SITE_KEY, T0), null, `oeffentlich: ${state}`)
|
||
const portal = await kontextFuerPortal(feature({ state }))
|
||
assert.deepEqual(portal.einstellung, e, `Portal: ${state}`)
|
||
assert.equal(portal.fragebogen.branche, 'salon', `Portal: ${state}`)
|
||
await deleteDocument('portalFeatures', FEATURE)
|
||
}
|
||
|
||
// Freischaltung: aus setup wird active, der Kontext folgt (hoechstens 30 Sekunden spaeter, so lange haelt der Speicher das null)
|
||
resetKontextForTests()
|
||
seedFeature({ state: 'setup' })
|
||
assert.equal(await kontextPerSiteKey(SITE_KEY, T0), null)
|
||
await activate(FEATURE, { by: 'team' })
|
||
const ctx = await kontextPerSiteKey(SITE_KEY, SEKUNDEN(31))
|
||
assert.equal(ctx.feature.state, 'active')
|
||
assert.deepEqual(ctx.einstellung, e)
|
||
assert.equal(ctx.fragebogen.branche, 'salon')
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 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)
|
||
})
|