699 lines
35 KiB
JavaScript
699 lines
35 KiB
JavaScript
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('<img src=x onerror=alert(1)>'))
|
||
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('&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, [])
|
||
})
|