Files
Webklar-Kundenbereich/test/featureMails.test.js
T

699 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
// Mails K15 (Bestellung eingegangen) und K16 (Funktion bereit) je Funktion (Task B9a). Der News-Wortlaut ist als Literal
// eingefroren (vor dem Umbau der Vorlagen aus den damaligen Mails uebernommen): "News-Mails bleiben woertlich".
// B9b ergaenzt T16 (Team-Hinweis je Funktion) und T19 (Erstattung faellig) in dieser Datei. websiteProjects, workorders und customers legt das Setup-Skript nicht an: ohne Schema.
const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures'), termineConfig: schemaKeys('termineConfig') } })
const st = await startFakeStripe()
after(() => Promise.all([aw.close(), st.close()]))
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const reg = await import('../server/services/featureRegistry.js')
const sf = await import('../server/services/siteFeatures.js')
const { loadTemplate, renderMail } = await import('../server/services/mailTemplates.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { mailSink } = await import('./support/mailSink.js')
const mails = mailSink()
setMailTransportForTests(mails)
after(() => setMailTransportForTests(null))
// Vorlagenprobleme (unbekannte oder fehlende Platzhalter) meldet der Mailer nur als Warnung: hier mitschreiben
const warnungen = []
const origWarn = console.warn
console.warn = (...args) => {
if (String(args[0]).startsWith('[mailer] Vorlage')) warnungen.push(args.join(' '))
else origWarn(...args)
}
after(() => {
console.warn = origWarn
})
aw.seed('customers', { $id: 'c1', name: 'Kunde Eins', location: 'Berlin', email: 'kunde1@example.test' })
st.invoices.set('in_1', { id: 'in_1', object: 'invoice', hosted_invoice_url: 'https://invoice.stripe.test/i/1' })
const BASE = 'https://project.webklar.com'
const FOOTER = [
'Viele Grüße',
'Dein WEBklar-Team',
'',
`Kundenportal (${BASE}/dashboard.html)`,
'',
'WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler',
'Telefon 0176 23726355 · support@webklar.com',
'Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein',
'',
]
let zaehler = 0
/** Projekt (Kunde c1) anlegen und die Funktion bestellen: liefert { projectId, sessionId }. */
async function bestelle(key, projectName) {
zaehler += 1
const projectId = `bm${zaehler}`
const sessionId = `cs_bm${zaehler}`
aw.seed('websiteProjects', { $id: projectId, customerId: 'c1', projectName })
await sf.markOrdered({ customerId: 'c1', projectId, key, sessionId, consent: {} })
return { projectId, sessionId }
}
const paidEvent = (sessionId, projectId, key, extra = {}) => ({
id: `evt_${sessionId}`,
object: 'event',
type: 'checkout.session.completed',
data: {
object: { id: sessionId, object: 'checkout.session', mode: 'payment', payment_status: 'paid', metadata: { customerId: 'c1', projectId, featureKey: key }, ...extra },
},
})
/** Bestellung bezahlen (Stripe-Ereignis) und die K15-Mail an den Kunden liefern. */
async function bezahle(key, projectName, extra = {}) {
const { projectId, sessionId } = await bestelle(key, projectName)
const vor = mails.sent.length
await sf.handleFeatureCheckoutEvent(paidEvent(sessionId, projectId, key, extra))
const k15 = mails.sent.slice(vor).filter((m) => m.subject.startsWith('Deine Bestellung'))
assert.equal(k15.length, 1, `genau eine K15-Mail: ${mails.sent.slice(vor).map((m) => m.subject)}`)
return { projectId, sessionId, mail: k15[0] }
}
const failedEvent = (sessionId, projectId, key) => ({
id: `evt_f_${sessionId}`,
object: 'event',
type: 'checkout.session.async_payment_failed',
data: {
object: { id: sessionId, object: 'checkout.session', mode: 'payment', payment_status: 'unpaid', metadata: { customerId: 'c1', projectId, featureKey: key } },
},
})
/** Die T16-Mails (Team-Hinweis "Neue Funktion bestellt") ab einer Position von mails.sent. */
const t16Seit = (vor) => mails.sent.slice(vor).filter((m) => m.subject.startsWith('Neue Funktion bestellt'))
/** Bestellung bezahlen: die eine T16-Mail an das Team, dazu Projekt, Ticketnummer und Site-Key aus dem Dokument. */
async function t16Bezahlt(key, projectName) {
const { projectId, sessionId } = await bestelle(key, projectName)
const vor = mails.sent.length
await sf.handleFeatureCheckoutEvent(paidEvent(sessionId, projectId, key))
const t16 = t16Seit(vor)
assert.equal(t16.length, 1, `genau eine T16-Mail: ${mails.sent.slice(vor).map((m) => m.subject)}`)
const doc = await sf.getFeatureDoc('c1', projectId, key)
const woid = aw.docs('workorders').get(doc.ticketId).woid
return { projectId, sessionId, doc, woid, mail: t16[0] }
}
/** Zahlung fehlgeschlagen (SEPA): bestellen, dann async_payment_failed. */
async function t16Fehlgeschlagen(key, projectName) {
const { projectId, sessionId } = await bestelle(key, projectName)
const vor = mails.sent.length
await sf.handleFeatureCheckoutEvent(failedEvent(sessionId, projectId, key))
const t16 = t16Seit(vor)
assert.equal(t16.length, 1, `genau eine T16-Mail: ${mails.sent.slice(vor).map((m) => m.subject)}`)
return { projectId, mail: t16[0] }
}
/** Bezahlte Sitzung ohne Bestellung zum Projekt (Schlüssel der Funktion wie in den Metadaten, auch fremd oder leer): T16 mit Block "ungeklaert". */
async function t16Ungeklaert(sessionId, projectId, key) {
const vor = mails.sent.length
await sf.handleFeatureCheckoutEvent(paidEvent(sessionId, projectId, key))
const t16 = t16Seit(vor)
assert.equal(t16.length, 1, `genau eine T16-Mail: ${mails.sent.slice(vor).map((m) => m.subject)}`)
return t16[0]
}
const T16_FUSS = 'Automatische Benachrichtigung aus dem WEBklar Kundenportal · Ticketsystem (https://ticket.webklar.com/customers)'
// ------------------------------------------------------------------------- News bleibt woertlich ---
test('News-Mails bleiben wörtlich: K15 mit Rechnung', async () => {
const { mail } = await bezahle('news', 'Bäckerei', { invoice: 'in_1' })
assert.equal(mail.subject, 'Deine Bestellung: News-Pinnwand')
assert.equal(
mail.text,
[
'WEBklar',
'',
'Bestätigung',
'',
'✓ Bestellung eingegangen',
'',
'Deine Bestellung: News-Pinnwand',
'',
'Hallo Kunde Eins,',
'vielen Dank für Deine Bestellung und Deine Zahlung. Wir richten die News-Pinnwand jetzt für Dein Projekt ein.',
'',
'Funktion: News-Pinnwand – Einrichtung',
'Projekt: Bäckerei',
'Einmalig bezahlt: 30,00 € (einmalig, ohne Umsatzsteuer gemäß § 19 UStG; die Nutzung ist kostenlos, es läuft kein Abo)',
'Einrichtung: meist innerhalb von 1–2 Wochen',
'',
'Wie geht es weiter?',
'Sobald die News-Pinnwand auf Deiner Website eingebaut ist, schalten wir sie im Kundenportal frei und Du bekommst eine weitere E-Mail. Danach kannst Du Beiträge im Portal schreiben.',
'',
'Rechnung ansehen: https://invoice.stripe.test/i/1',
'',
`Zum Kundenportal: ${BASE}/dashboard.html#news`,
'',
...FOOTER,
].join('\n')
)
assert.deepEqual(mail.to.map((t) => t.address), ['kunde1@example.test'])
})
test('News-Mails bleiben wörtlich: K15 ohne Rechnung', async () => {
const { mail } = await bezahle('news', 'Café')
assert.equal(mail.subject, 'Deine Bestellung: News-Pinnwand')
assert.equal(
mail.text,
[
'WEBklar',
'',
'Bestätigung',
'',
'✓ Bestellung eingegangen',
'',
'Deine Bestellung: News-Pinnwand',
'',
'Hallo Kunde Eins,',
'vielen Dank für Deine Bestellung und Deine Zahlung. Wir richten die News-Pinnwand jetzt für Dein Projekt ein.',
'',
'Funktion: News-Pinnwand – Einrichtung',
'Projekt: Café',
'Einmalig bezahlt: 30,00 € (einmalig, ohne Umsatzsteuer gemäß § 19 UStG; die Nutzung ist kostenlos, es läuft kein Abo)',
'Einrichtung: meist innerhalb von 1–2 Wochen',
'',
'Wie geht es weiter?',
'Sobald die News-Pinnwand auf Deiner Website eingebaut ist, schalten wir sie im Kundenportal frei und Du bekommst eine weitere E-Mail. Danach kannst Du Beiträge im Portal schreiben.',
'',
`Zum Kundenportal: ${BASE}/dashboard.html#news`,
'',
...FOOTER,
].join('\n')
)
assert.doesNotMatch(mail.text, /Rechnung ansehen/)
})
test('News-Mails bleiben wörtlich: K16', async () => {
const { projectId } = await bezahle('news', 'Bistro')
const doc = await sf.getFeatureDoc('c1', projectId, 'news')
aw.seed('workorders', { ...aw.docs('workorders').get(doc.ticketId), status: 'Closed' })
const vor = mails.sent.length
const done = await sf.runActivation({ customerId: 'c1' })
assert.deepEqual(done.map((d) => d.projectId), [projectId])
const gesendet = mails.sent.slice(vor)
assert.equal(gesendet.length, 1)
const [mail] = gesendet
assert.equal(mail.subject, 'Deine News-Pinnwand ist bereit')
assert.equal(
mail.text,
[
'WEBklar',
'',
'Bereit',
'',
'✓ News-Pinnwand',
'',
'Deine News-Pinnwand ist bereit',
'',
'Hallo Kunde Eins,',
'die News-Pinnwand ist auf Deiner Website eingebaut und freigeschaltet. Du kannst ab sofort Beiträge schreiben, sie erscheinen direkt bei Dir auf der Seite.',
'',
'Funktion: News-Pinnwand',
'Projekt: Bistro',
'',
`Beitrag schreiben: ${BASE}/dashboard.html#news`,
'',
...FOOTER,
].join('\n')
)
assert.deepEqual(mail.to.map((t) => t.address), ['kunde1@example.test'])
})
test('News-Mails bleiben wörtlich: T16', async () => {
const { mail, woid, doc } = await t16Bezahlt('news', 'Bäckerei')
assert.equal(mail.subject, 'Neue Funktion bestellt: News-Pinnwand')
assert.equal(
mail.text,
[
'WEBklar',
'',
'Intern · Kundenportal',
'',
'Funktionen',
'',
'Neue Funktion bestellt: News-Pinnwand',
'',
'Kunde: Kunde Eins',
'E-Mail: kunde1@example.test',
'Projekt: Bäckerei',
`Ticket: ${woid}`,
`Site-Key: ${doc.siteKey}`,
'',
T16_FUSS,
'',
].join('\n')
)
assert.deepEqual(mail.to.map((t) => t.address), ['kenso@webklar.com', 'andrej@webklar.com'])
})
test('News-Mails bleiben wörtlich: T16 Zahlung fehlgeschlagen', async () => {
const { mail } = await t16Fehlgeschlagen('news', 'Bäckerei')
assert.equal(mail.subject, 'Neue Funktion bestellt: News-Pinnwand')
assert.equal(
mail.text,
[
'WEBklar',
'',
'Intern · Kundenportal',
'',
'Funktionen',
'',
'Neue Funktion bestellt: News-Pinnwand',
'',
'Kunde: Kunde Eins',
'E-Mail: kunde1@example.test',
'Projekt: Bäckerei',
'Ticket:',
'Site-Key:',
'',
'Zahlung fehlgeschlagen',
'Die Zahlung ist nicht eingegangen (zum Beispiel SEPA-Lastschrift abgelehnt). Es wurde nichts eingerichtet und kein Ticket angelegt; die Bestellung steht im Portal wieder auf „nicht bestellt“.',
'',
T16_FUSS,
'',
].join('\n')
)
})
test('News-Mails bleiben wörtlich: T16 bezahlt, aber nicht zuzuordnen', async () => {
const mail = await t16Ungeklaert('cs_unklar_news', 'pX_news', 'news')
assert.equal(mail.subject, 'Neue Funktion bestellt: News-Pinnwand')
assert.equal(
mail.text,
[
'WEBklar',
'',
'Intern · Kundenportal',
'',
'Funktionen',
'',
'Neue Funktion bestellt: News-Pinnwand',
'',
'Kunde: Kunde Eins',
'E-Mail: kunde1@example.test',
'Projekt: pX_news',
'Ticket:',
'Site-Key:',
'',
'Bezahlt, aber nicht zuzuordnen',
'Der Kunde hat bezahlt, die Zahlung passt aber zu keiner offenen Bestellung. Es wurde nichts eingerichtet und kein Ticket angelegt. Bitte von Hand prüfen: cs_unklar_news (Kunde c1, Projekt pX_news)',
'',
T16_FUSS,
'',
].join('\n')
)
})
// ------------------------------------------------------------------------------------- Termine ---
test('Termine-Mails: K15 mit den Werten des Terminplaners', async () => {
const termine = config.features.termine.mails
const { mail } = await bezahle('termine', 'Salon Haarlinie', { invoice: 'in_1' })
assert.equal(mail.subject, 'Deine Bestellung: Terminplaner')
assert.doesNotMatch(mail.text, /News|Beitr/)
assert.doesNotMatch(mail.html, /News|Beitr/)
assert.match(mail.text, /Wir richten den Terminplaner jetzt für Dein Projekt ein\./)
assert.match(mail.text, /Funktion: Terminplaner – Einrichtung/)
assert.match(mail.text, /Projekt: Salon Haarlinie/)
assert.ok(mail.text.includes(`Einmalig bezahlt: 149,00 € (${termine.preisHinweis})`), mail.text)
assert.ok(
mail.text.includes(`Sobald der Terminplaner auf Deiner Website eingebaut ist, schalten wir ihn im Kundenportal frei und Du bekommst eine weitere E-Mail. ${termine.danach}`),
mail.text
)
assert.match(mail.text, /Rechnung ansehen: https:\/\/invoice\.stripe\.test\/i\/1/)
assert.ok(mail.text.includes(`Zum Kundenportal: ${BASE}/dashboard.html#termine`), mail.text)
assert.match(mail.html, new RegExp(`href="${BASE.replace(/\./g, '\\.')}/dashboard\\.html#termine"`))
assert.deepEqual(mail.to.map((t) => t.address), ['kunde1@example.test'])
})
test('Termine-Mails: K16 mit den Werten des Terminplaners', () => {
const termine = config.features.termine.mails
const nachricht = sf.bereitMail(reg.featureConfig('termine'), { kundeName: 'Kunde Eins', projekt: 'Salon Haarlinie' })
assert.equal(nachricht.template, 'K16')
const mail = renderMail(nachricht.template, nachricht.values, nachricht.blocks)
assert.deepEqual(mail.problems, [])
assert.equal(mail.subject, 'Dein Terminplaner ist bereit')
assert.doesNotMatch(mail.text, /News|Beitr/)
assert.doesNotMatch(mail.html, /News|Beitr/)
assert.match(mail.text, /Hallo Kunde Eins,\nder Terminplaner ist auf Deiner Website eingebaut und freigeschaltet\./)
assert.ok(mail.text.includes(termine.bereit), mail.text)
assert.match(mail.text, /Funktion: Terminplaner\nProjekt: Salon Haarlinie/)
assert.ok(mail.text.includes(`Termine öffnen: ${BASE}/dashboard.html#termine`), mail.text)
})
// -------------------------------------------------------------------- T16 je Funktion (Team-Hinweis) ---
test('Termine-T16: Betreff, Text und Knopf nennen den Terminplaner', async () => {
// c1 hat ein laufendes Wartungs-Abo: ohne es meldet T16 den Block kein_abo (Task B10b, test/featurePaid.test.js)
aw.seed('subscriptions', { $id: 'sub_w_mails', stripeSubscriptionId: 'sub_w_mails', customerId: 'c1', productKey: 'wartung', status: 'active' })
const { mail, projectId, woid, doc } = await t16Bezahlt('termine', 'Salon Haarlinie')
assert.equal(mail.subject, 'Neue Funktion bestellt: Terminplaner')
for (const teil of [mail.subject, mail.text, mail.html]) assert.doesNotMatch(teil, /News|Beitr/)
assert.match(mail.text, /Funktionen\n\nNeue Funktion bestellt: Terminplaner\n\nKunde: Kunde Eins\nE-Mail: kunde1@example\.test\nProjekt: Salon Haarlinie\n/)
assert.ok(mail.text.includes(`Ticket: ${woid}\nSite-Key: ${doc.siteKey}\n`), mail.text)
// der Knopf führt in das WOMS-Kundenpanel "Funktionen" mit geöffneter Anleitung dieses Projekts (anleitungsLink aus B8)
const link = `${config.womsBaseUrl}/customers/c1?tab=funktionen&anleitung=${projectId}`
assert.equal(link, reg.anleitungsLink('c1', projectId))
assert.ok(mail.text.includes(`Im Ticketsystem öffnen: ${link}`), mail.text)
assert.ok(mail.html.includes(`href="${link.replaceAll('&', '&')}"`), mail.html)
// die Warnblöcke bleiben bei einem ordentlich bezahlten Kauf aus (kein_abo nur ohne laufendes Abo, bereits_aktiv nur bei einer Zahlung auf ein vorhandenes Dokument: featurePaid.test.js; gesundheit nur bei Treffern der Stichwortprüfung: T16 Gesundheit unten)
for (const warnung of ['Zahlung fehlgeschlagen', 'Bezahlt, aber nicht zuzuordnen', 'Kein laufendes Wartungs-Abo', 'Gesundheitsbereich prüfen', 'Bezahlt, aber schon vorhanden']) {
assert.ok(!mail.text.includes(warnung), `kein Block "${warnung}"`)
}
})
test('Termine-T16: Zahlung fehlgeschlagen und nicht zuzuordnen nennen den Terminplaner, ohne Knopf zur Anleitung', async () => {
const { mail: fehl } = await t16Fehlgeschlagen('termine', 'Salon Haarlinie')
assert.equal(fehl.subject, 'Neue Funktion bestellt: Terminplaner')
assert.match(fehl.text, /Neue Funktion bestellt: Terminplaner\n\nKunde: Kunde Eins/)
assert.match(fehl.text, /Zahlung fehlgeschlagen\nDie Zahlung ist nicht eingegangen/)
assert.doesNotMatch(fehl.text, /News|Beitr|Im Ticketsystem öffnen/, 'es wurde nichts bestellt: keine Anleitung')
const unklar = await t16Ungeklaert('cs_unklar_termine', 'pX_termine', 'termine')
assert.equal(unklar.subject, 'Neue Funktion bestellt: Terminplaner')
assert.match(unklar.text, /Bezahlt, aber nicht zuzuordnen\nDer Kunde hat bezahlt.*cs_unklar_termine \(Kunde c1, Projekt pX_termine\)\n/)
assert.doesNotMatch(unklar.text, /News|Beitr|Im Ticketsystem öffnen/)
})
test('T16 ohne bekannten Funktionsschlüssel: "Funktion", sonst leer', async () => {
for (const [sessionId, projectId, key] of [
['cs_ohne_schluessel', 'pY1', undefined],
['cs_fremder_schluessel', 'pY2', 'gibtesnicht'],
]) {
const mail = await t16Ungeklaert(sessionId, projectId, key)
assert.equal(mail.subject, 'Neue Funktion bestellt: Funktion', sessionId)
assert.match(mail.text, /Neue Funktion bestellt: Funktion\n\nKunde: Kunde Eins/)
assert.match(mail.text, /Ticket:\nSite-Key:\n/, 'Ticket und Site-Key leer')
assert.match(mail.text, new RegExp(`Bezahlt, aber nicht zuzuordnen\\nDer Kunde hat bezahlt.*${sessionId} \\(Kunde c1, Projekt ${projectId}\\)\\n`))
assert.doesNotMatch(mail.text, /News|Beitr|Im Ticketsystem öffnen/, sessionId)
}
})
test('T16 Blöcke kein_abo und bereits_aktiv: Wortlaut und Werte', () => {
const werte = { kunde_name: 'Kunde Eins', email: 'kunde1@example.test', projekt: 'Salon', funktion: 'Terminplaner', woid: '10042', sitekey: 'abc', sitzung: 'cs_test_1', zustand: 'aktiv', anleitung_url: '', gesundheit_text: '' }
const aus = { zahlung_fehlgeschlagen: false, ungeklaert: false, kein_abo: false, bereits_aktiv: false, anleitung: false, gesundheit: false }
const keinAbo = renderMail('T16', werte, { ...aus, kein_abo: true })
assert.deepEqual(keinAbo.problems, [])
assert.match(keinAbo.text, /Kein laufendes Wartungs-Abo\nDie Zahlung ist eingegangen und das Ticket 10042 ist angelegt, aber der Kunde hat kein laufendes Wartungs-Abo\. Die Freischaltung bleibt gesperrt\. Endet das Abo vor der Fertigstellung, wird die Einrichtung voll erstattet \(Mail „Erstattung fällig“\)\.\n/)
assert.doesNotMatch(keinAbo.text, /schon aktiv|nicht zuzuordnen/)
const aktiv = renderMail('T16', werte, { ...aus, bereits_aktiv: true })
assert.deepEqual(aktiv.problems, [])
assert.match(
aktiv.text,
/Bezahlt, aber schon vorhanden\nZahlung eingegangen, aber die Funktion war schon aktiv\. Es wurde kein Ticket angelegt und nichts eingerichtet\. Bitte prüfen: Einbau nachholen oder erstatten\. Sitzung: cs_test_1\n/
)
assert.doesNotMatch(aktiv.text, /Kein laufendes Wartungs-Abo/)
// ohne Block steht nichts davon in der Mail
const keiner = renderMail('T16', werte, aus)
assert.doesNotMatch(keiner.text, /Wartungs-Abo|schon aktiv|Im Ticketsystem öffnen|Gesundheitsbereich/)
})
test('T16 gesundheit: Block aus ohne Warntext, Block an mit dem Text', () => {
const werte = { kunde_name: 'Kunde Eins', email: 'kunde1@example.test', projekt: 'Salon', funktion: 'Terminplaner', woid: '10042', sitekey: 'abc', sitzung: '', zustand: '', anleitung_url: '', gesundheit_text: 'Branche Physiotherapie: Gesundheitsdaten möglich' }
const aus = renderMail('T16', werte, { gesundheit: false })
assert.deepEqual(aus.problems, [])
assert.doesNotMatch(aus.text, /Gesundheitsbereich prüfen|Physiotherapie/)
assert.doesNotMatch(aus.html, /Physiotherapie/)
const an = renderMail('T16', werte, { gesundheit: true })
assert.deepEqual(an.problems, [])
assert.ok(an.text.includes('Gesundheitsbereich prüfen: Branche Physiotherapie: Gesundheitsdaten möglich'), an.text)
})
// ------------------------------------------------------- T16 Block gesundheit (Termine, Task D4) ---
const { ausVorlage } = await import('../server/services/termine/vorlagen.js')
const GESUNDHEIT_ZEILE = 'Gesundheitsbereich prüfen: '
/** Einstellung eines Salons mit den Namen und Beschreibungen der Leistungen, die der Test braucht. */
function salon(leistungen = []) {
const e = ausVorlage('salon', { name: 'Salon Haarlinie', email: 'info@haarlinie.example', telefon: '06301 1234', adresse: 'Hauptstraße 1, 67722 Winnweiler', bundesland: 'RP' }, new Date('2026-10-05T10:00:00Z'))
leistungen.forEach(([name, beschreibung], i) => Object.assign(e.leistungen[i], { name, beschreibung: beschreibung ?? '' }))
return e
}
/** Termine bestellen (mit einer Einstellung im termineConfig-Dokument), bezahlen und die T16-Mail liefern. */
async function t16MitEinstellung(einstellung, { fehlerBeimLesen = false } = {}) {
if (!aw.docs('subscriptions').has('sub_w_mails')) {
aw.seed('subscriptions', { $id: 'sub_w_mails', stripeSubscriptionId: 'sub_w_mails', customerId: 'c1', productKey: 'wartung', status: 'active' })
}
const { projectId, sessionId } = await bestelle('termine', 'Salon Haarlinie')
const doc = await sf.getFeatureDoc('c1', projectId, 'termine')
if (einstellung) {
aw.seed('termineConfig', { $id: doc.$id, customerId: 'c1', projectId, einstellung: JSON.stringify(einstellung), version: 1, updatedAt: '2026-10-03T08:15:00.000Z', updatedBy: '' })
}
// der erste Lesezugriff gehoert dem Ticket (Kurzfassung), der zweite dem Hinweis fuer T16
if (fehlerBeimLesen) aw.failNext({ collection: 'termineConfig', method: 'GET', status: 500, after: 1 })
const vor = mails.sent.length
await sf.handleFeatureCheckoutEvent(paidEvent(sessionId, projectId, 'termine'))
const t16 = t16Seit(vor)
assert.equal(t16.length, 1, `genau eine T16-Mail: ${mails.sent.slice(vor).map((m) => m.subject)}`)
const gesundheit = t16[0].text.split('\n').find((z) => z.startsWith(GESUNDHEIT_ZEILE))
return { mail: t16[0], doc: await sf.getFeatureDoc('c1', projectId, 'termine'), gesundheit: gesundheit ? gesundheit.slice(GESUNDHEIT_ZEILE.length) : null }
}
test('T16 Termine mit Leistung Physiotherapie: Warnblock "Gesundheitsbereich prüfen" mit dem Fundort, Ticket und Mail gehen raus', async () => {
const { mail, doc, gesundheit } = await t16MitEinstellung(salon([['Physiotherapie']]))
assert.equal(gesundheit, 'Leistungsname enthält „Physiotherapie“')
assert.ok(mail.html.includes('Gesundheitsbereich prüfen: Leistungsname enthält „Physiotherapie“'), mail.html)
assert.ok(doc.ticketId && aw.docs('workorders').get(doc.ticketId).details.includes('Gesundheitsbereich prüfen:'), 'das Ticket nennt es ebenfalls')
// die uebrigen Bloecke sind unveraendert: Ticket, Knopf zur Anleitung, kein kein_abo
assert.ok(mail.text.includes('Im Ticketsystem öffnen:'))
assert.ok(!mail.text.includes('Kein laufendes Wartungs-Abo'))
})
test('T16 Termine ohne Treffer oder ohne Einstellungen: kein Gesundheitsblock', async () => {
const ohneTreffer = await t16MitEinstellung(salon([['Herrenschnitt', 'Schneiden und Föhnen']]))
assert.equal(ohneTreffer.gesundheit, null)
assert.ok(!ohneTreffer.mail.text.includes('Gesundheitsbereich'))
const ohneConfig = await t16MitEinstellung(null)
assert.equal(ohneConfig.gesundheit, null)
assert.ok(!ohneConfig.mail.text.includes('Gesundheitsbereich') && !ohneConfig.mail.html.includes('Gesundheitsbereich'))
})
test('T16 Termine: scheitert das Lesen der Einstellung, geht T16 trotzdem ohne Block raus (nie Geld ohne Spur)', async () => {
const protokoll = []
const alt = console.error
console.error = (...args) => protokoll.push(args.join(' '))
try {
const { mail, doc, gesundheit } = await t16MitEinstellung(salon([['Physiotherapie']]), { fehlerBeimLesen: true })
assert.equal(gesundheit, null)
assert.ok(mail.text.includes('Neue Funktion bestellt: Terminplaner') && mail.text.includes('Im Ticketsystem öffnen:'))
assert.ok(doc.ticketId, 'das Ticket entstand')
assert.equal(protokoll.filter((z) => /Gesundheitshinweis/.test(z)).length, 1, protokoll.join('\n'))
assert.ok(!protokoll.join('\n').includes('Physio'), 'das Log nennt keine Daten des Betriebs')
} finally {
console.error = alt
}
})
test('T16 Termine: Namen des Kunden werden als Text eingesetzt (HTML maskiert, nichts Halbes), bei vielen Treffern hoechstens 500 Zeichen', async () => {
const boese = salon([
[`<img src=x onerror=alert(1)> \uD83D${'x'.repeat(5)}`, 'Physiotherapie'],
['Zeile1\n## Zeile2', 'Therapie'],
])
const eins = await t16MitEinstellung(boese)
assert.ok(eins.gesundheit.includes('<img src=x onerror=alert(1)>'), eins.gesundheit)
assert.ok(!eins.mail.html.includes('<img src=x'), 'im HTML maskiert')
assert.ok(eins.mail.html.includes('&lt;img src=x onerror=alert(1)&gt;'))
assert.ok(eins.mail.text.isWellFormed() && eins.mail.html.isWellFormed())
assert.ok(!eins.gesundheit.includes('\n'))
// 10 Treffer: gekuerzt auf ganze Meldungen und "und N weitere", hoechstens 500 Zeichen
const viele = salon(Array.from({ length: 4 }, (_, i) => [`${String(i)} ${'N'.repeat(36)}`, 'Physiotherapie']))
viele.leistungen.push(...Array.from({ length: 7 }, (_, i) => ({ ...viele.leistungen[0], id: `s_v${i}`, name: `${String(i + 4)} ${'M'.repeat(36)}`, beschreibung: 'Physiotherapie' })))
const zehn = await t16MitEinstellung(viele)
assert.ok(zehn.gesundheit.length <= 500, String(zehn.gesundheit.length))
assert.match(zehn.gesundheit, / … und \d+ weitere$/)
assert.ok(zehn.gesundheit.startsWith('Beschreibung der Leistung „0 NNN'), zehn.gesundheit)
})
test('T16 Termine, Zahlung fehlgeschlagen: kein Gesundheitsblock (es wurde nichts bestellt), auch mit Treffer in den Einstellungen', async () => {
const { projectId, sessionId } = await bestelle('termine', 'Salon Haarlinie')
const doc = await sf.getFeatureDoc('c1', projectId, 'termine')
aw.seed('termineConfig', { $id: doc.$id, customerId: 'c1', projectId, einstellung: JSON.stringify(salon([['Physiotherapie']])), version: 1, updatedAt: '2026-10-03T08:15:00.000Z', updatedBy: '' })
const vor = mails.sent.length
await sf.handleFeatureCheckoutEvent(failedEvent(sessionId, projectId, 'termine'))
const t16 = t16Seit(vor)
assert.equal(t16.length, 1)
assert.match(t16[0].text, /Zahlung fehlgeschlagen/)
assert.ok(!t16[0].text.includes('Gesundheitsbereich') && !t16[0].html.includes('Gesundheitsbereich'))
})
test('T16 News: nie ein Gesundheitsblock, auch wenn unter der ID der News-Funktion ein Einstellungsdokument mit Treffer laege', async () => {
const { projectId, sessionId } = await bestelle('news', 'Bäckerei Physiotherapie')
const doc = await sf.getFeatureDoc('c1', projectId, 'news')
aw.seed('termineConfig', { $id: doc.$id, customerId: 'c1', projectId, einstellung: JSON.stringify(salon([['Physiotherapie']])), version: 1, updatedAt: '2026-10-03T08:15:00.000Z', updatedBy: '' })
const vor = mails.sent.length
await sf.handleFeatureCheckoutEvent(paidEvent(sessionId, projectId, 'news'))
const t16 = t16Seit(vor)
assert.equal(t16.length, 1)
assert.ok(!t16[0].text.includes('Gesundheitsbereich') && !t16[0].html.includes('Gesundheitsbereich'))
})
test('T16 anleitung: Knopf nur mit Block, Link trägt &anleitung=<Projekt-ID>', () => {
const link = reg.anleitungsLink('c1', 'p42')
assert.ok(link.endsWith('&anleitung=p42'))
const werte = { kunde_name: 'Kunde Eins', email: 'kunde1@example.test', projekt: 'Salon', funktion: 'Terminplaner', woid: '10042', sitekey: 'abc', sitzung: '', zustand: '', anleitung_url: link, gesundheit_text: '' }
const an = renderMail('T16', werte, { anleitung: true })
assert.deepEqual(an.problems, [])
assert.ok(an.text.includes(`Im Ticketsystem öffnen: ${link}`), an.text)
assert.ok(an.html.includes('&amp;anleitung=p42"'), an.html)
const aus = renderMail('T16', werte, { anleitung: false })
assert.doesNotMatch(aus.text, /Im Ticketsystem öffnen/)
assert.ok(!aus.text.includes('anleitung=p42'))
})
// ------------------------------------------------------------------------ T19 Erstattung fällig ---
test('T19 rendert beide Fälle', () => {
const werte = {
kunde_name: 'Kunde Eins',
email: 'kunde1@example.test',
projekt: 'Salon Haarlinie',
funktion: 'Terminplaner',
betrag: '149,00 €',
sitzung: 'cs_test_erstattung',
woid: '10042',
rechnung_url: 'https://invoice.stripe.test/i/1',
}
const gemeinsam = (mail) => {
assert.deepEqual(mail.problems, [])
assert.equal(mail.subject, '[Abo] Erstattung fällig: Terminplaner-Einrichtung')
for (const teil of ['Kunde: Kunde Eins', 'E-Mail: kunde1@example.test', 'Projekt: Salon Haarlinie', 'Betrag: 149,00 €', 'Sitzung: cs_test_erstattung']) {
assert.ok(mail.text.includes(teil), `T19 enthält: ${teil}\n---\n${mail.text}`)
}
// volle Erstattung von Hand, keine automatische Rückzahlung
assert.match(mail.text, /von Hand in Stripe/)
assert.match(mail.text, /Eine automatische Rückzahlung gibt es nicht\./)
assert.match(mail.html, /Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https:\/\/ticket\.webklar\.com\/customers"[^>]*>Ticketsystem<\/a>/)
assert.doesNotMatch(mail.text, /News|Beitr/)
}
const nachEnde = renderMail('T19', werte, { zahlung_nach_ende: true, ticket_offen: false, rechnung: false })
gemeinsam(nachEnde)
assert.ok(nachEnde.text.includes('Die Zahlung ist erst nach dem Abo-Ende eingegangen, es wurde kein Ticket angelegt.'), nachEnde.text)
assert.doesNotMatch(nachEnde.text, /noch offen|10042|Rechnung ansehen/)
const ticketOffen = renderMail('T19', werte, { zahlung_nach_ende: false, ticket_offen: true, rechnung: false })
gemeinsam(ticketOffen)
assert.ok(ticketOffen.text.includes('Das Ticket 10042 ist noch offen.'), ticketOffen.text)
assert.match(ticketOffen.text, /Ticket mit einem Vermerk schließen/)
assert.match(ticketOffen.text, /schaltet dann nicht frei/)
assert.doesNotMatch(ticketOffen.text, /erst nach dem Abo-Ende|Rechnung ansehen/)
const mitRechnung = renderMail('T19', werte, { zahlung_nach_ende: true, ticket_offen: false, rechnung: true })
gemeinsam(mitRechnung)
assert.ok(mitRechnung.text.includes('Rechnung ansehen: https://invoice.stripe.test/i/1'), mitRechnung.text)
assert.match(mitRechnung.html, /<a href="https:\/\/invoice\.stripe\.test\/i\/1"[^>]*>Rechnung ansehen/)
})
// ----------------------------------------------------------------------------- Platzhalter, Blöcke ---
test('Platzhalter-/Blocklisten exakt: K15 und K16', () => {
const k15 = loadTemplate('K15')
assert.deepEqual(k15.placeholders, [
'kunde_name',
'projekt',
'funktion',
'funktion_gen',
'funktion_akk',
'funktion_nom',
'funktion_pron',
'preis',
'preis_hinweis',
'zeit',
'danach',
'portal_url',
'rechnung_url',
'fassung',
'lb_url',
'avv_url',
'widerruf_url',
'zustimmungen_text',
])
assert.deepEqual(k15.blocks, ['rechnung', 'behalten', 'neu', 'anlagen', 'anhang', 'zustimmungen'])
assert.equal(k15.subject, 'Deine Bestellung: {{funktion}}')
const k16 = loadTemplate('K16')
assert.deepEqual(k16.placeholders, ['kunde_name', 'projekt', 'funktion', 'funktion_dein', 'funktion_nom', 'bereit', 'knopf', 'portal_url'])
assert.deepEqual(k16.blocks, [])
assert.equal(k16.subject, '{{funktion_dein}} ist bereit')
})
test('Platzhalter-/Blocklisten exakt: T16 und T19', () => {
const t16 = loadTemplate('T16')
assert.equal(t16.subject, 'Neue Funktion bestellt: {{funktion}}')
assert.deepEqual(t16.placeholders, ['kunde_name', 'email', 'projekt', 'funktion', 'woid', 'sitekey', 'sitzung', 'zustand', 'anleitung_url', 'gesundheit_text'])
assert.deepEqual(t16.blocks, ['zahlung_fehlgeschlagen', 'ungeklaert', 'kein_abo', 'bereits_aktiv', 'anleitung', 'gesundheit', 'abo', 'team', 'anhang_fehlt'])
const t19 = loadTemplate('T19')
assert.equal(t19.file, 'T19 Erstattung faellig.html')
assert.equal(t19.subject, '[Abo] Erstattung fällig: {{funktion}}-Einrichtung')
assert.deepEqual(t19.placeholders, ['kunde_name', 'email', 'projekt', 'funktion', 'betrag', 'sitzung', 'woid', 'rechnung_url'])
assert.deepEqual(t19.blocks, ['zahlung_nach_ende', 'ticket_offen', 'rechnung'])
for (const tpl of [t16, t19]) assert.doesNotMatch(tpl.body, /News|Beitr|Terminplaner/, `${tpl.id}: keine feste Funktion`)
})
test('Die Mail-Werte je Funktion decken genau die Platzhalter der Vorlagen ab', () => {
const ctx = { kundeName: 'Kunde Eins', projekt: 'Salon', invoiceUrl: 'https://invoice.stripe.test/i/1' }
for (const key of reg.featureKeys()) {
const feature = reg.featureConfig(key)
const k15 = sf.bestellMail(feature, ctx)
const k16 = sf.bereitMail(feature, ctx)
assert.equal(k15.template, 'K15')
assert.equal(k16.template, 'K16')
assert.deepEqual(Object.keys(k15.values).sort(), [...loadTemplate('K15').placeholders].sort(), `${key}: K15`)
assert.deepEqual(Object.keys(k16.values).sort(), [...loadTemplate('K16').placeholders].sort(), `${key}: K16`)
// ohne Rechtsunterlagen im Aufruf (F8: onFeaturePaid übergibt sie für den Terminplaner) bleiben anlagen, anhang und zustimmungen aus
assert.deepEqual(k15.blocks, { rechnung: true, anlagen: false, anhang: false, zustimmungen: false, behalten: false, neu: true })
assert.deepEqual(sf.bestellMail(feature, { ...ctx, invoiceUrl: '' }).blocks, { rechnung: false, anlagen: false, anhang: false, zustimmungen: false, behalten: false, neu: true })
for (const nachricht of [k15, k16]) {
const mail = renderMail(nachricht.template, nachricht.values, nachricht.blocks)
assert.deepEqual(mail.problems, [], `${key}: ${nachricht.template}`)
}
// Preis aus dem Katalog der Funktion (feature.product), nicht aus News
assert.equal(k15.values.preis, `${(config[feature.product].amountCents / 100).toFixed(2).replace('.', ',')} €`)
assert.equal(k15.values.portal_url, `${config.publicBaseUrl}/dashboard.html#${feature.returnHash}`)
assert.equal(k16.values.portal_url, k15.values.portal_url)
}
})
// ------------------------------------------------------------------------------------ Anhänge ---
// Die Vertragsunterlagen des Terminplaners hängen seit F8 an K15 (legalDocs.featureLegalAttachments, Tests in funktionenRecht.test.js).
// Diese Probe hält fest, dass das nicht über einen Registerhaken läuft: kein mails.anhaenge, und die Haken des Eintrags werden bei der
// Zahlung nicht gerufen.
test('K15 mit Anhängen aus legalDocs: die Termine-Bestellung ruft keinen Registerhaken, mails.anhaenge gibt es nicht', async () => {
const termine = config.features.termine
assert.equal(Object.hasOwn(termine.mails, 'anhaenge'), false)
const aufrufe = []
const alt = {}
const spion = (name) => (...args) => {
aufrufe.push(name)
return name === 'mails.anhaenge' ? [] : true
}
for (const name of ['verfuegbar', 'bestellpruefung', 'unterlagenBereit', 'belehrung', 'freischaltbar']) {
alt[name] = termine[name]
termine[name] = spion(name)
}
termine.mails.anhaenge = spion('mails.anhaenge')
try {
const { mail } = await bezahle('termine', 'Anhang-Test')
assert.equal(mail.attachments.length, 3, 'K15 trägt die drei PDFs der Vertragsunterlagen')
assert.deepEqual(aufrufe, [])
} finally {
for (const name of Object.keys(alt)) {
if (alt[name] === undefined) delete termine[name]
else termine[name] = alt[name]
}
delete termine.mails.anhaenge
}
})
test('keine Vorlagenwarnungen in allen Abläufen dieser Datei', () => {
assert.deepEqual(warnungen, [])
})