Terminplaner: Kontext, Einstellungs-Speicher, Mail-Werte
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
af4029af81
commit
30d40ad6ce
3 files changed
+1656
No files matched your search
@@ -0,0 +1,194 @@
|
||||
import { config } from '../../config.js'
|
||||
import { Query, createDocument, getDocument, listDocuments, updateDocument } from '../appwriteAdmin.js'
|
||||
import { analyseHosts } from '../analyse.js'
|
||||
import { withLocks } from '../keyedLock.js'
|
||||
import { termineAus } from './schalter.js'
|
||||
import { pruefeNow, pruefeText } from './util.js'
|
||||
|
||||
/**
|
||||
* Kontext einer Terminplaner-Funktion (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitte 2 und 8) und
|
||||
* Speicher der Betriebs-Einstellung (termineConfig, Dokument-ID = ID des portalFeatures-Dokuments).
|
||||
*
|
||||
* ctx = { feature, project, einstellung, version }
|
||||
* feature portalFeatures-Dokument (featureKey 'termine')
|
||||
* project websiteProjects-Dokument oder null (Projekt fehlt)
|
||||
* einstellung gespeicherte Einstellung (Objekt) oder null (noch nie gespeichert / unlesbar)
|
||||
* version Zaehler fuer die optimistische Sperre (0 = noch nie gespeichert)
|
||||
* Ein ctx ist nur zum Lesen: kontextPerSiteKey gibt innerhalb von 30 Sekunden dasselbe Objekt zurueck.
|
||||
*
|
||||
* - kontextPerSiteKey (oeffentliche Schnittstelle): nur Funktionen im Zustand active und nur mit gespeicherter
|
||||
* Einstellung; Modus off => null ohne jede Abfrage. Treffer und Fehlschlaege werden 30 Sekunden je siteKey im Speicher
|
||||
* gehalten (Zustandswechsel der Funktion wirken so mit hoechstens 30 Sekunden Verzoegerung); speichereEinstellung
|
||||
* leert den Speicher. Eine Abfrage, die waehrend eines Speicherns lief, landet nie im Speicher (generation).
|
||||
* - kontextPerFeature (Portal, Gaesteseiten per Token): active und disabled (Gaeste duerfen weiter absagen und widerrufen),
|
||||
* ohne Speicher; liefert auch ohne gespeicherte Einstellung einen ctx (einstellung null, version 0).
|
||||
* Fehlt eine Collection (404 collection_not_found), wird der Fehler weitergereicht und nicht als "nicht gefunden" gelesen.
|
||||
*/
|
||||
|
||||
const FUNKTION = 'termine'
|
||||
const SITE_KEY_RE = /^[a-z2-7]{16}$/
|
||||
const CACHE_MS = 30 * 1000
|
||||
const CACHE_MAX = 10000
|
||||
|
||||
const cache = new Map()
|
||||
/** Wird mit jedem Speichern erhoeht: eine Abfrage, die davor begann, darf ihr Ergebnis nicht mehr merken. */
|
||||
let generation = 0
|
||||
|
||||
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
|
||||
|
||||
/** 404 "Dokument fehlt" (nicht: Collection oder Datenbank fehlt). */
|
||||
const dokumentFehlt = (err) => err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found'
|
||||
|
||||
async function holeOderNull(collectionId, id) {
|
||||
try {
|
||||
return await getDocument(collectionId, id)
|
||||
} catch (err) {
|
||||
if (dokumentFehlt(err)) return null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** { einstellung, version } aus einem termineConfig-Dokument; unlesbares JSON gilt als nicht gespeichert (mit Log). */
|
||||
function liesEinstellung(doc) {
|
||||
const version = Number.isInteger(doc.version) ? doc.version : 0
|
||||
try {
|
||||
const einstellung = JSON.parse(doc.einstellung)
|
||||
if (!istObjekt(einstellung)) throw new Error('kein Objekt')
|
||||
return { einstellung, version }
|
||||
} catch (err) {
|
||||
console.error(`[termine-kontext] Einstellung ${doc.$id} ist unlesbar: ${err.message}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Die gespeicherte Einstellung einer Funktion.
|
||||
* @param {string} featureId ID des portalFeatures-Dokuments
|
||||
* @returns {Promise<{ einstellung: object, version: number }|null>} null, wenn es noch keine (lesbare) gibt
|
||||
*/
|
||||
export async function ladeEinstellung(featureId) {
|
||||
pruefeText(featureId, 'featureId')
|
||||
const doc = await holeOderNull(config.collections.termineConfig, featureId)
|
||||
return doc ? liesEinstellung(doc) : null
|
||||
}
|
||||
|
||||
async function baueKontext(feature) {
|
||||
const [project, gespeichert] = await Promise.all([
|
||||
feature.projectId ? holeOderNull(config.collections.websiteProjects, feature.projectId) : null,
|
||||
ladeEinstellung(feature.$id),
|
||||
])
|
||||
return { feature, project, einstellung: gespeichert?.einstellung ?? null, version: gespeichert?.version ?? 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Kontext fuer die oeffentliche Schnittstelle: aktive Funktion 'termine' mit diesem siteKey und gespeicherter Einstellung.
|
||||
* @param {string} siteKey
|
||||
* @param {Date} [now] Zeitpunkt fuer den 30-Sekunden-Speicher
|
||||
* @returns {Promise<object|null>} null bei Modus off, ungueltigem oder unbekanntem siteKey, anderem Zustand als active
|
||||
* und fehlender Einstellung
|
||||
*/
|
||||
export async function kontextPerSiteKey(siteKey, now = new Date()) {
|
||||
if (termineAus()) return null
|
||||
if (typeof siteKey !== 'string' || !SITE_KEY_RE.test(siteKey)) return null
|
||||
const nowMs = pruefeNow(now, 'kontextPerSiteKey')
|
||||
const gemerkt = cache.get(siteKey)
|
||||
if (gemerkt && gemerkt.bis > nowMs) return gemerkt.ctx
|
||||
|
||||
const stand = generation
|
||||
const [feature] = await listDocuments(config.collections.portalFeatures, [
|
||||
Query.equal('siteKey', siteKey),
|
||||
Query.equal('featureKey', FUNKTION),
|
||||
Query.equal('state', 'active'),
|
||||
Query.limit(1),
|
||||
])
|
||||
let ctx = feature ? await baueKontext(feature) : null
|
||||
if (ctx && !ctx.einstellung) ctx = null
|
||||
if (stand === generation) merke(siteKey, ctx, nowMs)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function merke(siteKey, ctx, nowMs) {
|
||||
if (cache.size >= CACHE_MAX) {
|
||||
for (const [key, eintrag] of cache) if (eintrag.bis <= nowMs) cache.delete(key)
|
||||
if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value)
|
||||
}
|
||||
cache.set(siteKey, { bis: nowMs + CACHE_MS, ctx })
|
||||
}
|
||||
|
||||
/**
|
||||
* Kontext fuer Portal und Gaesteseiten (Token): Funktion 'termine' im Zustand active oder disabled, ohne Speicher.
|
||||
* @param {string} featureId ID des portalFeatures-Dokuments
|
||||
* @returns {Promise<object|null>} null bei Modus off, unbekannter ID, anderer Funktion oder anderem Zustand
|
||||
*/
|
||||
export async function kontextPerFeature(featureId) {
|
||||
if (termineAus()) return null
|
||||
if (typeof featureId !== 'string' || featureId === '') return null
|
||||
const feature = await holeOderNull(config.collections.portalFeatures, featureId)
|
||||
if (!feature || feature.featureKey !== FUNKTION || !['active', 'disabled'].includes(feature.state)) return null
|
||||
return baueKontext(feature)
|
||||
}
|
||||
|
||||
const veraltet = () =>
|
||||
Object.assign(new Error('Die Einstellungen wurden inzwischen geändert. Bitte neu laden.'), {
|
||||
status: 409,
|
||||
code: 'EINSTELLUNG_VERALTET',
|
||||
})
|
||||
|
||||
/**
|
||||
* Speichert die (bereits geprueften) Einstellungen. Optimistisch gesperrt: version ist die Version, auf der die
|
||||
* Aenderung beruht (0 = noch nie gespeichert); weicht sie von der gespeicherten ab, passiert nichts und es folgt
|
||||
* { status: 409, code: 'EINSTELLUNG_VERALTET' }. Lesen, Pruefen und Schreiben laufen unter der Sperre
|
||||
* termine-cfg:<featureId>. Danach ist der Speicher von kontextPerSiteKey leer.
|
||||
* @param {{ $id: string, customerId: string, projectId: string }} feature portalFeatures-Dokument
|
||||
* @param {object} einstellung
|
||||
* @param {{ version: number, by?: string, now?: Date }} optionen
|
||||
* @returns {Promise<{ einstellung: object, version: number }>} wie gespeichert, mit der neuen Version
|
||||
*/
|
||||
export async function speichereEinstellung(feature, einstellung, { version, by = '', now = new Date() } = {}) {
|
||||
pruefeText(feature?.$id, 'feature.$id')
|
||||
if (!istObjekt(einstellung)) throw new TypeError('einstellung fehlt')
|
||||
const nowMs = pruefeNow(now, 'speichereEinstellung')
|
||||
const json = JSON.stringify(einstellung)
|
||||
|
||||
return withLocks([`termine-cfg:${feature.$id}`], async () => {
|
||||
const col = config.collections.termineConfig
|
||||
const vorhanden = await holeOderNull(col, feature.$id)
|
||||
const aktuell = vorhanden && Number.isInteger(vorhanden.version) ? vorhanden.version : 0
|
||||
if (version !== aktuell) throw veraltet()
|
||||
|
||||
const felder = {
|
||||
customerId: feature.customerId,
|
||||
projectId: feature.projectId,
|
||||
einstellung: json,
|
||||
version: aktuell + 1,
|
||||
updatedAt: new Date(nowMs).toISOString(),
|
||||
updatedBy: String(by ?? '').slice(0, 100),
|
||||
}
|
||||
try {
|
||||
if (vorhanden) await updateDocument(col, feature.$id, felder)
|
||||
else await createDocument(col, felder, feature.$id)
|
||||
} finally {
|
||||
// auch bei einem Fehler: unklar, was in der Datenbank steht
|
||||
generation += 1
|
||||
cache.clear()
|
||||
}
|
||||
return { einstellung: JSON.parse(json), version: aktuell + 1 }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Adresse der Website des Betriebs (Link auf Gaesteseiten): betrieb.websiteUrl oder https:// + erster gespeicherter Host
|
||||
* des Projekts (analyseHosts); sonst null.
|
||||
*/
|
||||
export function websiteUrl(ctx) {
|
||||
const eigene = ctx?.einstellung?.betrieb?.websiteUrl
|
||||
if (typeof eigene === 'string' && eigene !== '') return eigene
|
||||
const [host] = ctx?.project ? analyseHosts(ctx.project) : []
|
||||
return host ? `https://${host}` : null
|
||||
}
|
||||
|
||||
/** Leert den Speicher von kontextPerSiteKey (Tests). */
|
||||
export function resetKontextForTests() {
|
||||
generation += 1
|
||||
cache.clear()
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
import { config } from '../../config.js'
|
||||
import { getDocument } from '../appwriteAdmin.js'
|
||||
import { preisHinweis, preisText, zahlungText } from './einstellungen.js'
|
||||
import { icsTermin } from './ics.js'
|
||||
import { websiteUrl } from './kontext.js'
|
||||
import { einreihen } from './outbox.js'
|
||||
import { widerrufsbelehrung } from './rechtstexte.js'
|
||||
import { buchungsNummer, verwaltungsToken } from './token.js'
|
||||
import { pruefeNow, zeitMs } from './util.js'
|
||||
import { datumKurz, datumLang, uhrzeit } from './zeit.js'
|
||||
|
||||
/**
|
||||
* Mails des Terminplaners (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 6): Betreffzeilen, Werte und
|
||||
* Bloecke fuer die Vorlagen G01-G08 (an Gaeste) und K20/K21 (an den Betrieb) und das Einreihen in die Warteschlange.
|
||||
* `art` ist immer die Vorlagen-ID ('G01' ... 'K21'); sie steht auch im Feld `art` des Outbox-Eintrags.
|
||||
*
|
||||
* Regeln fuer alle Mails:
|
||||
* - Genau ein Anrede-Block (sie oder du) je G-Mail, nach einstellung.anrede; K20/K21 sind immer Du-Form ohne diese Bloecke.
|
||||
* - Leere freiwillige Werte erscheinen als "–". Links auf die Website des Betriebs (Impressum, Datenschutz) fallen auf
|
||||
* websiteUrl(ctx) und zuletzt auf die Portal-Adresse zurueck (ein href darf nicht "–" sein).
|
||||
* - Person oder Platz (Block person in G02/G05) nur, wenn der Betrieb mehrere Ressourcen hat; K20/K21 zeigen sie immer.
|
||||
* - G01 und G08 enthalten keinen Text, den der Gast eingegeben hat (die Mail geht an eine ungeprueft angegebene Adresse);
|
||||
* in keinem Betreff steht ein Gastname.
|
||||
* - Verwaltungslink `${publicBaseUrl}/termin.html#m=<verwaltungsToken>` (G02, G03, G05, G07), Bestaetigungslink `#v=<token>`
|
||||
* (G01, Token von extra.verifyToken), Portal-Link der Betriebsmails `${publicBaseUrl}/dashboard.html#termine`.
|
||||
*
|
||||
* `buchung` ist das Buchungsdokument; fuer G07 und alle Mails nach einer Aenderung der Zustand NACH der Aenderung.
|
||||
* Ohne Buchung (null oder nur Gastangaben) gehen nur G01 `zu_viele`, G08 `nicht_zugeordnet` und K21 `nicht_zugeordnet`.
|
||||
*
|
||||
* extra je Vorlage (fall = Variante der Vorlage; fehlt sie dort, wo sie Pflicht ist, wirft werteFuer):
|
||||
* G01 fall 'bestaetigen' (Vorgabe) | 'zu_viele'; verifyToken (nur bei 'bestaetigen'); an (Adresse, wenn keine Buchung existiert)
|
||||
* G02 anfrageAngenommen: true, wenn der Betrieb eine Anfrage angenommen hat
|
||||
* G04 nachricht (optionale Nachricht des Betriebs)
|
||||
* G06 von 'gast' | 'betrieb' | 'system' (Pflicht); grund, nachricht (nur bei Absage durch den Betrieb); sequenz (ICS, Vorgabe: icsSequenz + 1)
|
||||
* G07 altStart (bisheriger Beginn, Pflicht)
|
||||
* G08 fall 'zugeordnet' | 'nach_frist' | 'nicht_zugeordnet' (Pflicht); eingang (Date, Pflicht); an (bei 'nicht_zugeordnet')
|
||||
* K20 fall (= block) 'anfrage' oder nichts
|
||||
* K21 fall (= block) 'abgesagt' | 'verschoben' | 'widerrufen' | 'nicht_zugeordnet' | 'nach_frist' (Pflicht); altStart (verschoben);
|
||||
* eingang (widerrufen, nach_frist, nicht_zugeordnet); bei 'nicht_zugeordnet' ist buchung { gastName, gastEmail,
|
||||
* gastTelefon, gastNachricht } aus der Erklaerung
|
||||
* K20/K21 kundeName (Anrede des Kunden; sonst Name aus dem Kundendokument, zuletzt der Betriebsname)
|
||||
* Fuer Mails ohne Buchung gibt extra.bookingId den Bezug im Outbox-Eintrag an (Vorgabe '-').
|
||||
*/
|
||||
|
||||
const LEER = '–'
|
||||
const OHNE_BUCHUNG = '-'
|
||||
const GAST_ARTEN = ['G01', 'G02', 'G03', 'G04', 'G05', 'G06', 'G07', 'G08']
|
||||
const BETRIEBS_ARTEN = ['K20', 'K21']
|
||||
const ICS_METHODE = { G02: 'REQUEST', G07: 'REQUEST', G06: 'CANCEL', K20: 'PUBLISH' }
|
||||
const VERIFY_TOKEN_RE = /^[A-Za-z0-9_-]{43}$/
|
||||
|
||||
function einfrieren(wert) {
|
||||
if (wert && typeof wert === 'object' && !Object.isFrozen(wert)) {
|
||||
Object.freeze(wert)
|
||||
Object.values(wert).forEach(einfrieren)
|
||||
}
|
||||
return wert
|
||||
}
|
||||
|
||||
/**
|
||||
* Betreffzeilen: BETREFFE[art][anrede][fall] mit {betrieb}, {leistung}, {datum} (TT.MM.JJJJ) und {zeit} (HH:MM).
|
||||
* K20/K21 stehen in der Du-Form (wie K15-K19). Kein Betreff enthaelt einen Gastnamen.
|
||||
*/
|
||||
export const BETREFFE = einfrieren({
|
||||
G01: {
|
||||
sie: { bestaetigen: 'Bitte bestätigen Sie Ihren Termin bei {betrieb}', zu_viele: 'Ihr Termin bei {betrieb} konnte nicht vorgemerkt werden' },
|
||||
du: { bestaetigen: 'Bitte bestätige deinen Termin bei {betrieb}', zu_viele: 'Dein Termin bei {betrieb} konnte nicht vorgemerkt werden' },
|
||||
},
|
||||
G02: { sie: { standard: 'Ihr Termin bei {betrieb} am {datum}' }, du: { standard: 'Dein Termin bei {betrieb} am {datum}' } },
|
||||
G03: { sie: { standard: 'Ihre Terminanfrage bei {betrieb}' }, du: { standard: 'Deine Terminanfrage bei {betrieb}' } },
|
||||
G04: { sie: { standard: 'Ihre Terminanfrage bei {betrieb}' }, du: { standard: 'Deine Terminanfrage bei {betrieb}' } },
|
||||
G05: {
|
||||
sie: { standard: 'Erinnerung: Ihr Termin am {datum} um {zeit}' },
|
||||
du: { standard: 'Erinnerung: Dein Termin am {datum} um {zeit}' },
|
||||
},
|
||||
G06: { sie: { standard: 'Ihr Termin am {datum} wurde abgesagt' }, du: { standard: 'Dein Termin am {datum} wurde abgesagt' } },
|
||||
G07: { sie: { standard: 'Ihr Termin wurde verschoben' }, du: { standard: 'Dein Termin wurde verschoben' } },
|
||||
G08: { sie: { standard: 'Eingangsbestätigung Ihres Widerrufs' }, du: { standard: 'Eingangsbestätigung deines Widerrufs' } },
|
||||
K20: {
|
||||
du: {
|
||||
buchung: 'Neue Buchung: {leistung} am {datum} um {zeit}',
|
||||
anfrage: 'Neue Terminanfrage: {leistung} am {datum} um {zeit}',
|
||||
},
|
||||
},
|
||||
K21: {
|
||||
du: {
|
||||
abgesagt: 'Termin abgesagt: {leistung} am {datum} um {zeit}',
|
||||
verschoben: 'Termin verschoben: {leistung}, jetzt am {datum} um {zeit}',
|
||||
widerrufen: 'Termin widerrufen: {leistung} am {datum} um {zeit}',
|
||||
nach_frist: 'Widerruf nach Ablauf der Frist: {leistung} am {datum} um {zeit}',
|
||||
nicht_zugeordnet: 'Widerruf ohne passende Buchung',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
function betreff(art, anrede, fall, variablen) {
|
||||
const muster = BETREFFE[art]?.[anrede]?.[fall]
|
||||
if (!muster) throw new TypeError(`Kein Betreff für ${art}/${anrede}/${fall}`)
|
||||
return muster.replace(/\{(\w+)\}/g, (_, name) => {
|
||||
if (!Object.hasOwn(variablen, name)) throw new TypeError(`Betreff ${art}: Wert für {${name}} fehlt`)
|
||||
return variablen[name]
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Bausteine
|
||||
|
||||
const hatText = (wert) => typeof wert === 'string' && wert.trim() !== ''
|
||||
/** Text oder "–". */
|
||||
const oderLeer = (wert) => (hatText(wert) ? wert.trim() : LEER)
|
||||
|
||||
function pflichtText(wert, name) {
|
||||
if (!hatText(wert)) throw new TypeError(`${name} fehlt`)
|
||||
return wert.trim()
|
||||
}
|
||||
|
||||
function pflichtDatum(wert, name) {
|
||||
const ms = zeitMs(wert)
|
||||
if (!Number.isFinite(ms)) throw new TypeError(`${name} fehlt oder ist kein Zeitpunkt`)
|
||||
return new Date(ms)
|
||||
}
|
||||
|
||||
function wahlFall(fall, erlaubt, name) {
|
||||
if (!erlaubt.includes(fall)) throw new TypeError(`${name}: fall muss ${erlaubt.join(', ')} sein`)
|
||||
return fall
|
||||
}
|
||||
|
||||
const verwaltungsLink = (buchung) => `${config.publicBaseUrl}/termin.html#m=${verwaltungsToken(pflichtText(buchung?.$id, 'buchung.$id'))}`
|
||||
const portalLink = () => `${config.publicBaseUrl}/dashboard.html#termine`
|
||||
|
||||
function bestaetigungsLink(token) {
|
||||
if (typeof token !== 'string' || !VERIFY_TOKEN_RE.test(token)) throw new TypeError('extra.verifyToken fehlt oder ist ungültig')
|
||||
return `${config.publicBaseUrl}/termin.html#v=${token}`
|
||||
}
|
||||
|
||||
/** Werte des Fusses und Kopfes aller Gaestemails. */
|
||||
function betriebsWerte(ctx) {
|
||||
const e = ctx.einstellung
|
||||
const b = e.betrieb
|
||||
const seite = websiteUrl(ctx) || config.publicBaseUrl
|
||||
return {
|
||||
farbe: e.farbe,
|
||||
betrieb_name: b.name,
|
||||
betrieb_adresse: oderLeer(b.adresse),
|
||||
betrieb_telefon: oderLeer(b.telefon),
|
||||
betrieb_email: b.email,
|
||||
impressum_url: b.impressumUrl || seite,
|
||||
datenschutz_url: b.datenschutzUrl || seite,
|
||||
}
|
||||
}
|
||||
|
||||
/** leistung, datum ("Montag, 12.10.2026"), zeit ("10:00") und die Kurzformen fuer Betreffzeilen. */
|
||||
function terminWerte(buchung) {
|
||||
const start = pflichtDatum(buchung?.startsAt, 'buchung.startsAt')
|
||||
return {
|
||||
leistung: pflichtText(buchung.leistungName, 'buchung.leistungName'),
|
||||
datum: datumLang(start),
|
||||
zeit: uhrzeit(start),
|
||||
kurz: { datum: datumKurz(start), zeit: uhrzeit(start) },
|
||||
}
|
||||
}
|
||||
|
||||
/** "45 Minuten", "1 Stunde", "1 Stunde 30 Minuten". */
|
||||
function dauerText(buchung) {
|
||||
const minuten = Math.round((pflichtDatum(buchung?.endsAt, 'buchung.endsAt') - pflichtDatum(buchung?.startsAt, 'buchung.startsAt')) / 60000)
|
||||
const stunden = Math.floor(minuten / 60)
|
||||
const rest = minuten % 60
|
||||
return [stunden ? `${stunden} ${stunden === 1 ? 'Stunde' : 'Stunden'}` : '', rest || !stunden ? `${rest} ${rest === 1 ? 'Minute' : 'Minuten'}` : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
/** Person oder Platz zeigen die Gaestemails nur, wenn der Betrieb mehrere Ressourcen hat. */
|
||||
const personWert = (e, buchung) => (hatText(buchung?.ressourceName) && (e.ressourcen || []).length > 1 ? buchung.ressourceName.trim() : '')
|
||||
|
||||
/** Bei Gast-Datum "TT.MM.JJJJ, HH:MM Uhr" (Berliner Zeit). */
|
||||
const eingangText = (eingang) => {
|
||||
const d = pflichtDatum(eingang, 'extra.eingang')
|
||||
return `${datumKurz(d)}, ${uhrzeit(d)} Uhr`
|
||||
}
|
||||
|
||||
/**
|
||||
* Welcher Hinweis zum Widerruf gehoert in G02? 'widerruf' (Verbraucherbuchung), 'kein_widerruf' (Freizeitleistung mit
|
||||
* festem Termin fuer Verbraucher, § 312g Abs. 2 Nr. 9 BGB) oder null (Unternehmer, Handbuchung).
|
||||
*/
|
||||
function widerrufFall(e, buchung) {
|
||||
if (buchung.quelle === 'manuell') return null
|
||||
if (buchung.verbraucher === true) return 'widerruf'
|
||||
if (e.recht?.freizeit === true && e.recht.gaesteSind !== 'unternehmer') return 'kein_widerruf'
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Werte je Vorlage
|
||||
|
||||
function gastG01({ ctx, anrede, buchung, extra }) {
|
||||
const b = ctx.einstellung.betrieb
|
||||
const fall = wahlFall(extra.fall ?? 'bestaetigen', ['bestaetigen', 'zu_viele'], 'G01')
|
||||
const values = { ...betriebsWerte(ctx), leistung: LEER, datum: LEER, zeit: LEER, link: LEER }
|
||||
if (fall === 'bestaetigen') {
|
||||
const t = terminWerte(buchung)
|
||||
Object.assign(values, { leistung: t.leistung, datum: t.datum, zeit: t.zeit, link: bestaetigungsLink(extra.verifyToken) })
|
||||
}
|
||||
values.betreff = betreff('G01', anrede, fall, { betrieb: b.name })
|
||||
return { values, blocks: { [anrede]: true, [fall]: true } }
|
||||
}
|
||||
|
||||
function gastG02({ ctx, anrede, buchung, extra }) {
|
||||
const e = ctx.einstellung
|
||||
const t = terminWerte(buchung)
|
||||
const preis = preisText({ preisArt: buchung.preisArt, preisCent: buchung.preisCent })
|
||||
const person = personWert(e, buchung)
|
||||
const storno = e.storno?.text
|
||||
const fall = widerrufFall(e, buchung)
|
||||
const link = verwaltungsLink(buchung)
|
||||
const values = {
|
||||
...betriebsWerte(ctx),
|
||||
gast_name: oderLeer(buchung.gastName),
|
||||
leistung: t.leistung,
|
||||
person: oderLeer(person),
|
||||
datum: t.datum,
|
||||
zeit: t.zeit,
|
||||
dauer: dauerText(buchung),
|
||||
preis: preis ? `${preis} (${preisHinweis(e)})` : LEER,
|
||||
zahlung: preis ? zahlungText(e) : LEER,
|
||||
nummer: buchungsNummer(buchung.$id),
|
||||
link,
|
||||
ort: oderLeer(e.betrieb.adresse),
|
||||
storno_text: oderLeer(storno),
|
||||
// wirft, wenn Anschrift oder Telefon fehlen: eine unvollstaendige Belehrung geht nie raus
|
||||
widerrufsbelehrung: fall === 'widerruf' ? widerrufsbelehrung(e.betrieb) : LEER,
|
||||
widerruf_link: link,
|
||||
betreff: betreff('G02', anrede, 'standard', { betrieb: e.betrieb.name, datum: t.kurz.datum }),
|
||||
}
|
||||
const blocks = { [anrede]: true }
|
||||
if (preis) blocks.preis = true
|
||||
if (person) blocks.person = true
|
||||
if (hatText(storno)) blocks.storno = true
|
||||
if (fall) blocks[fall] = true
|
||||
if (extra.anfrageAngenommen === true) blocks.anfrage_angenommen = true
|
||||
return { values, blocks }
|
||||
}
|
||||
|
||||
function gastG03({ ctx, anrede, buchung }) {
|
||||
const t = terminWerte(buchung)
|
||||
const values = {
|
||||
...betriebsWerte(ctx),
|
||||
gast_name: oderLeer(buchung.gastName),
|
||||
leistung: t.leistung,
|
||||
datum: t.datum,
|
||||
zeit: t.zeit,
|
||||
link: verwaltungsLink(buchung),
|
||||
betreff: betreff('G03', anrede, 'standard', { betrieb: ctx.einstellung.betrieb.name }),
|
||||
}
|
||||
return { values, blocks: { [anrede]: true } }
|
||||
}
|
||||
|
||||
function gastG04({ ctx, anrede, buchung, extra }) {
|
||||
const t = terminWerte(buchung)
|
||||
const values = {
|
||||
...betriebsWerte(ctx),
|
||||
gast_name: oderLeer(buchung.gastName),
|
||||
leistung: t.leistung,
|
||||
datum: t.datum,
|
||||
zeit: t.zeit,
|
||||
nachricht: oderLeer(extra.nachricht),
|
||||
betreff: betreff('G04', anrede, 'standard', { betrieb: ctx.einstellung.betrieb.name }),
|
||||
}
|
||||
return { values, blocks: { [anrede]: true, ...(hatText(extra.nachricht) ? { nachricht: true } : {}) } }
|
||||
}
|
||||
|
||||
function gastG05({ ctx, anrede, buchung }) {
|
||||
const t = terminWerte(buchung)
|
||||
const person = personWert(ctx.einstellung, buchung)
|
||||
const values = {
|
||||
...betriebsWerte(ctx),
|
||||
gast_name: oderLeer(buchung.gastName),
|
||||
leistung: t.leistung,
|
||||
person: oderLeer(person),
|
||||
datum: t.datum,
|
||||
zeit: t.zeit,
|
||||
link: verwaltungsLink(buchung),
|
||||
betreff: betreff('G05', anrede, 'standard', { datum: t.kurz.datum, zeit: t.kurz.zeit }),
|
||||
}
|
||||
return { values, blocks: { [anrede]: true, ...(person ? { person: true } : {}) } }
|
||||
}
|
||||
|
||||
function gastG06({ ctx, anrede, buchung, extra }) {
|
||||
const b = ctx.einstellung.betrieb
|
||||
const von = wahlFall(extra.von, ['gast', 'betrieb', 'system'], 'G06 extra.von')
|
||||
const t = terminWerte(buchung)
|
||||
// Grund und Nachricht kommen vom Betrieb; sagt der Gast selbst ab, gibt es beides nicht
|
||||
const grund = von === 'gast' ? '' : extra.grund
|
||||
const nachricht = von === 'gast' ? '' : extra.nachricht
|
||||
const values = {
|
||||
...betriebsWerte(ctx),
|
||||
gast_name: oderLeer(buchung.gastName),
|
||||
leistung: t.leistung,
|
||||
datum: t.datum,
|
||||
zeit: t.zeit,
|
||||
// anredeneutral, weil der Wert allein in der Zeile "Abgesagt von" steht
|
||||
absage_von: von === 'gast' ? (anrede === 'du' ? 'Du selbst' : 'Sie selbst') : b.name,
|
||||
grund: oderLeer(grund),
|
||||
nachricht: oderLeer(nachricht),
|
||||
betreff: betreff('G06', anrede, 'standard', { datum: t.kurz.datum }),
|
||||
}
|
||||
const blocks = { [anrede]: true }
|
||||
if (hatText(grund)) blocks.grund = true
|
||||
if (hatText(nachricht)) blocks.nachricht = true
|
||||
return { values, blocks }
|
||||
}
|
||||
|
||||
function gastG07({ ctx, anrede, buchung, extra }) {
|
||||
const t = terminWerte(buchung)
|
||||
const alt = pflichtDatum(extra.altStart, 'extra.altStart')
|
||||
const values = {
|
||||
...betriebsWerte(ctx),
|
||||
gast_name: oderLeer(buchung.gastName),
|
||||
leistung: t.leistung,
|
||||
alt_datum: datumLang(alt),
|
||||
alt_zeit: uhrzeit(alt),
|
||||
datum: t.datum,
|
||||
zeit: t.zeit,
|
||||
link: verwaltungsLink(buchung),
|
||||
betreff: betreff('G07', anrede, 'standard', {}),
|
||||
}
|
||||
return { values, blocks: { [anrede]: true } }
|
||||
}
|
||||
|
||||
function gastG08({ ctx, anrede, buchung, extra }) {
|
||||
const fall = wahlFall(extra.fall, ['zugeordnet', 'nach_frist', 'nicht_zugeordnet'], 'G08')
|
||||
const zugeordnet = fall !== 'nicht_zugeordnet'
|
||||
const values = {
|
||||
...betriebsWerte(ctx),
|
||||
eingang: eingangText(extra.eingang),
|
||||
// die gespeicherte Nummer der gefundenen Buchung, nie eine Eingabe des Gastes
|
||||
nummer: zugeordnet ? buchungsNummer(pflichtText(buchung?.$id, 'buchung.$id')) : LEER,
|
||||
betreff: betreff('G08', anrede, 'standard', {}),
|
||||
}
|
||||
return { values, blocks: { [anrede]: true, ...(zugeordnet ? { [fall]: true } : {}) } }
|
||||
}
|
||||
|
||||
const GAST_BAUER = {
|
||||
G01: gastG01,
|
||||
G02: gastG02,
|
||||
G03: gastG03,
|
||||
G04: gastG04,
|
||||
G05: gastG05,
|
||||
G06: gastG06,
|
||||
G07: gastG07,
|
||||
G08: gastG08,
|
||||
}
|
||||
|
||||
/** Gemeinsame Gastangaben und Termin der Betriebsmails K20/K21 (Werte, die die Vorlage immer erwartet). */
|
||||
function betriebsMailWerte(ctx, buchung, extra) {
|
||||
const e = ctx.einstellung
|
||||
return {
|
||||
kunde_name: oderLeer(extra.kundeName ?? e.betrieb.name),
|
||||
gast_name: oderLeer(buchung?.gastName),
|
||||
gast_email: oderLeer(buchung?.gastEmail),
|
||||
gast_telefon: oderLeer(buchung?.gastTelefon),
|
||||
gast_nachricht: oderLeer(buchung?.gastNachricht),
|
||||
portal_link: portalLink(),
|
||||
}
|
||||
}
|
||||
|
||||
function betriebK20({ ctx, buchung, extra }) {
|
||||
const fall = wahlFall(extra.fall ?? 'buchung', ['buchung', 'anfrage'], 'K20')
|
||||
const t = terminWerte(buchung)
|
||||
const values = {
|
||||
...betriebsMailWerte(ctx, buchung, extra),
|
||||
leistung: t.leistung,
|
||||
person: oderLeer(buchung.ressourceName),
|
||||
datum: t.datum,
|
||||
zeit: t.zeit,
|
||||
betreff: betreff('K20', 'du', fall, { leistung: t.leistung, datum: t.kurz.datum, zeit: t.kurz.zeit }),
|
||||
}
|
||||
return { values, blocks: fall === 'anfrage' ? { anfrage: true } : {} }
|
||||
}
|
||||
|
||||
function betriebK21({ ctx, buchung, extra }) {
|
||||
const fall = wahlFall(extra.fall, ['abgesagt', 'verschoben', 'widerrufen', 'nicht_zugeordnet', 'nach_frist'], 'K21')
|
||||
const values = {
|
||||
...betriebsMailWerte(ctx, buchung, extra),
|
||||
leistung: LEER,
|
||||
person: LEER,
|
||||
datum: LEER,
|
||||
zeit: LEER,
|
||||
alt_datum: LEER,
|
||||
alt_zeit: LEER,
|
||||
eingang: LEER,
|
||||
}
|
||||
let kurz = { datum: LEER, zeit: LEER }
|
||||
if (fall !== 'nicht_zugeordnet') {
|
||||
const t = terminWerte(buchung)
|
||||
Object.assign(values, { leistung: t.leistung, person: oderLeer(buchung.ressourceName), datum: t.datum, zeit: t.zeit })
|
||||
kurz = t.kurz
|
||||
}
|
||||
if (fall === 'verschoben') {
|
||||
const alt = pflichtDatum(extra.altStart, 'extra.altStart')
|
||||
Object.assign(values, { alt_datum: datumLang(alt), alt_zeit: uhrzeit(alt) })
|
||||
}
|
||||
if (['widerrufen', 'nach_frist', 'nicht_zugeordnet'].includes(fall)) values.eingang = eingangText(extra.eingang)
|
||||
values.betreff = betreff('K21', 'du', fall, { leistung: values.leistung, datum: kurz.datum, zeit: kurz.zeit })
|
||||
return { values, blocks: { [fall]: true } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Betreff, Werte und Bloecke einer Mail (Vorlage `art`), bereit fuer renderMail/einreihen. Siehe Kopfkommentar zu extra.
|
||||
* @param {string} art Vorlagen-ID ('G01' ... 'G08', 'K20', 'K21')
|
||||
* @param {{ einstellung: object, project?: object|null, feature?: object }} ctx
|
||||
* @param {object|null} buchung
|
||||
* @param {object} [extra]
|
||||
* @returns {{ values: Record<string, string>, blocks: Record<string, true> }} values.betreff ist der Betreff
|
||||
*/
|
||||
export function werteFuer(art, ctx, buchung, extra = {}) {
|
||||
const e = ctx?.einstellung
|
||||
if (!e?.betrieb) throw new TypeError('werteFuer: ctx.einstellung fehlt')
|
||||
const anrede = e.anrede === 'du' ? 'du' : 'sie'
|
||||
const argumente = { ctx, anrede, buchung, extra: extra ?? {} }
|
||||
if (GAST_BAUER[art]) return GAST_BAUER[art](argumente)
|
||||
if (art === 'K20') return betriebK20(argumente)
|
||||
if (art === 'K21') return betriebK21(argumente)
|
||||
throw new TypeError(`werteFuer: unbekannte Art ${String(art).slice(0, 20)}`)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Kalenderdateien
|
||||
|
||||
/** Kalenderdatei fuer G02/G07 (REQUEST), G06 (CANCEL) und K20 (PUBLISH, ohne Gast); sonst null. */
|
||||
function kalenderDatei(art, ctx, buchung, extra, jetzt) {
|
||||
const methode = ICS_METHODE[art]
|
||||
if (!methode) return null
|
||||
const b = ctx.einstellung.betrieb
|
||||
const nummer = buchungsNummer(buchung.$id)
|
||||
const aktuell = Number.isInteger(buchung.icsSequenz) ? buchung.icsSequenz : 0
|
||||
// CANCEL traegt eine hoehere Sequenz als die zuletzt gesendete Einladung
|
||||
const sequenz = Number.isInteger(extra.sequenz) ? extra.sequenz : aktuell + (methode === 'CANCEL' ? 1 : 0)
|
||||
return {
|
||||
methode,
|
||||
datei: icsTermin({
|
||||
uid: `${buchung.$id}@termine.webklar.com`,
|
||||
sequenz,
|
||||
methode,
|
||||
start: buchung.startsAt,
|
||||
ende: buchung.endsAt,
|
||||
titel: art === 'K20' ? `${buchung.leistungName}: ${oderLeer(buchung.gastName)}` : `${buchung.leistungName} – ${b.name}`,
|
||||
ort: b.adresse,
|
||||
beschreibung: `Buchungsnummer: ${nummer}`,
|
||||
organisator: { name: b.name, email: b.email },
|
||||
teilnehmer: methode === 'PUBLISH' ? null : { name: buchung.gastName, email: buchung.gastEmail },
|
||||
jetzt,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Einreihen
|
||||
|
||||
/**
|
||||
* Reiht eine Mail an den Gast ein (Absender: Betriebsname, Antworten an die Betriebs-E-Mail, nie BCC). Empfaenger ist
|
||||
* extra.an, sonst buchung.gastEmail. G02 und G07 tragen eine Einladung (REQUEST), G06 die Absage (CANCEL).
|
||||
* @param {string} art 'G01' ... 'G08'
|
||||
* @param {object} ctx
|
||||
* @param {object|null} buchung
|
||||
* @param {object} [extra] siehe Kopfkommentar
|
||||
* @param {Date} [now]
|
||||
* @returns {Promise<object>} der Outbox-Eintrag (Status wartet; der erste Sendeversuch laeuft im Hintergrund)
|
||||
*/
|
||||
export async function anGast(art, ctx, buchung, extra = {}, now = new Date()) {
|
||||
if (!GAST_ARTEN.includes(art)) throw new TypeError(`anGast: ${String(art).slice(0, 20)} ist keine Gastmail`)
|
||||
pruefeNow(now, 'anGast')
|
||||
extra = extra ?? {}
|
||||
const { values, blocks } = werteFuer(art, ctx, buchung, extra)
|
||||
const b = ctx.einstellung.betrieb
|
||||
const ics = kalenderDatei(art, ctx, buchung, extra, now)
|
||||
return einreihen(
|
||||
{
|
||||
featureId: ctx.feature?.$id,
|
||||
bookingId: buchung?.$id || extra.bookingId || OHNE_BUCHUNG,
|
||||
art,
|
||||
an: hatText(extra.an) ? extra.an : buchung?.gastEmail,
|
||||
fromName: b.name,
|
||||
replyTo: b.email,
|
||||
template: art,
|
||||
values,
|
||||
blocks,
|
||||
ics: ics?.datei ?? null,
|
||||
icsMethod: ics?.methode ?? 'REQUEST',
|
||||
},
|
||||
now,
|
||||
)
|
||||
}
|
||||
|
||||
/** Name des Kunden fuer die Anrede in K20/K21; ohne lesbares Kundendokument der Betriebsname. */
|
||||
async function kundenName(ctx) {
|
||||
try {
|
||||
const kunde = await getDocument(config.collections.customers, ctx.feature.customerId)
|
||||
return kunde?.name || kunde?.email || ctx.einstellung.betrieb.name
|
||||
} catch (err) {
|
||||
if (err?.status !== 404) console.warn(`[termine-mails] Kunde ${ctx.feature?.customerId} nicht lesbar: ${err.message}`)
|
||||
return ctx.einstellung.betrieb.name
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reiht eine Mail an den Betrieb ein (K20/K21, Absender "WEBklar", an betrieb.email, nie BCC). K20 traegt einen
|
||||
* Kalendereintrag (PUBLISH), ausser bei einer unbestaetigten Anfrage; K21 hat keinen.
|
||||
* @param {string} art 'K20' | 'K21'
|
||||
* @param {object} ctx
|
||||
* @param {object|null} buchung
|
||||
* @param {{ block?: string|null, extra?: object }} [optionen] block = Variante der Vorlage (K20 'anfrage'; K21 Pflicht)
|
||||
* @param {Date} [now]
|
||||
* @returns {Promise<object>} der Outbox-Eintrag
|
||||
*/
|
||||
export async function anBetrieb(art, ctx, buchung, { block = null, extra = {} } = {}, now = new Date()) {
|
||||
if (!BETRIEBS_ARTEN.includes(art)) throw new TypeError(`anBetrieb: ${String(art).slice(0, 20)} ist keine Betriebsmail`)
|
||||
pruefeNow(now, 'anBetrieb')
|
||||
extra = extra ?? {}
|
||||
const kundeName = hatText(extra.kundeName) ? extra.kundeName : await kundenName(ctx)
|
||||
const zusatz = { ...extra, kundeName, ...(block ? { fall: block } : {}) }
|
||||
const { values, blocks } = werteFuer(art, ctx, buchung, zusatz)
|
||||
const ics = art === 'K20' && block !== 'anfrage' ? kalenderDatei(art, ctx, buchung, zusatz, now) : null
|
||||
return einreihen(
|
||||
{
|
||||
featureId: ctx.feature?.$id,
|
||||
bookingId: buchung?.$id || extra.bookingId || OHNE_BUCHUNG,
|
||||
art,
|
||||
an: ctx.einstellung.betrieb.email,
|
||||
fromName: 'WEBklar',
|
||||
replyTo: null,
|
||||
template: art,
|
||||
values,
|
||||
blocks,
|
||||
ics: ics?.datei ?? null,
|
||||
icsMethod: ics?.methode ?? 'PUBLISH',
|
||||
},
|
||||
now,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,926 @@
|
||||
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) => {
|
||||
const erg = normalisiereEinstellung(eingabe(over), { now: JETZT })
|
||||
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' }) => ({
|
||||
feature: feature(),
|
||||
project: projekt,
|
||||
einstellung: einstellung(over),
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 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('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('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 https:// + erster Host des Projekts, sonst null', () => {
|
||||
assert.equal(websiteUrl(ctxMit()), 'https://salon.de/')
|
||||
const ohneUrl = { betrieb: betriebMit({ websiteUrl: null }) }
|
||||
assert.equal(websiteUrl(ctxMit(ohneUrl, { subdomain: 'salon', liveDomain: 'www.salon.de' })), 'https://salon.project.webklar.com')
|
||||
assert.equal(websiteUrl(ctxMit(ohneUrl, { liveDomain: 'https://www.salon.de/start' })), 'https://www.salon.de')
|
||||
assert.equal(websiteUrl(ctxMit(ohneUrl, { $id: PROJEKT })), null, 'Projekt ohne Hosts')
|
||||
assert.equal(websiteUrl(ctxMit(ohneUrl, null)), null, 'ohne Projekt')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 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', 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', 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, 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')
|
||||
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', 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', () => {
|
||||
for (const felder of [{ adresse: '' }, { telefon: '' }]) {
|
||||
const ctx = ctxMit({ betrieb: betriebMit(felder) })
|
||||
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 } })
|
||||
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 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)
|
||||
|
||||
const leer = werteFuer('G03', ctxMit({ betrieb: betriebMit({ telefon: '', adresse: '' }) }), 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, '–')
|
||||
|
||||
// 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']) {
|
||||
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)
|
||||
})
|
||||
Reference in new issue
Block a user