K15 (Bestellung eingegangen) und K16 (Funktion bereit) tragen den Wortlaut der Funktion als Platzhalter (funktion, funktion_gen/akk/nom/pron/dein, preis_hinweis, danach, bereit, knopf, portal_url) statt fest News-Pinnwand. siteFeatures baut die Werte mit bestellMail/bereitMail aus dem Register-Eintrag (mails.*, setupEstimate, returnHash) und dem Katalogpreis je feature.product. K15 geht ohne Anhaenge raus. Die News-Mails bleiben Zeichen fuer Zeichen gleich (Test featureMails friert Betreff und Text ein). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
303 lines
13 KiB
JavaScript
303 lines
13 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 und T19 in dieser Datei. websiteProjects, workorders und customers legt das Setup-Skript nicht an: ohne Schema.
|
||
const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures') } })
|
||
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] }
|
||
}
|
||
|
||
// ------------------------------------------------------------------------- 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 € (brutto, einmalig; 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 € (brutto, einmalig; 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'])
|
||
})
|
||
|
||
// ------------------------------------------------------------------------------------- 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)
|
||
})
|
||
|
||
// ----------------------------------------------------------------------------- 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',
|
||
])
|
||
assert.deepEqual(k15.blocks, ['rechnung'])
|
||
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('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`)
|
||
assert.deepEqual(k15.blocks, { rechnung: true })
|
||
assert.deepEqual(sf.bestellMail(feature, { ...ctx, invoiceUrl: '' }).blocks, { rechnung: false })
|
||
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 ---
|
||
test('K15 ohne Anhänge: die Termine-Bestellung ruft keinen Registerhaken', async () => {
|
||
const termine = config.features.termine
|
||
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.ok(!mail.attachments || mail.attachments.length === 0, 'K15 trägt keinen Anhang')
|
||
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, [])
|
||
})
|