203 lines
12 KiB
JavaScript
203 lines
12 KiB
JavaScript
import assert from 'node:assert/strict'
|
||
import { readFileSync, readdirSync } from 'node:fs'
|
||
import { test } from 'node:test'
|
||
import { escapeHtml, htmlToText, loadTemplate, renderMail, templateIds } from '../server/services/mailTemplates.js'
|
||
|
||
const DIR = new URL('../server/mail-templates/', import.meta.url)
|
||
// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9; K13/T13 und die Endfassung der Texte folgen in Task 12
|
||
const EXPECTED = [
|
||
...Array.from({ length: 11 }, (_, i) => `K${String(i + 1).padStart(2, '0')}`),
|
||
'K12',
|
||
'K14',
|
||
...Array.from({ length: 11 }, (_, i) => `T${String(i + 1).padStart(2, '0')}`),
|
||
'T12',
|
||
]
|
||
|
||
const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`]))
|
||
const allBlocks = (tpl, on) => Object.fromEntries(tpl.blocks.map((b) => [b, on]))
|
||
|
||
test('alle Vorlagen sind da', () => {
|
||
assert.deepEqual(templateIds(), EXPECTED)
|
||
})
|
||
|
||
for (const id of EXPECTED) {
|
||
test(`${id}: Kopfkommentar passt zur Vorlage`, () => {
|
||
const tpl = loadTemplate(id)
|
||
const source = readFileSync(new URL(tpl.file, DIR), 'utf8')
|
||
const title = source.match(/<title>([\s\S]*?)<\/title>/)[1]
|
||
assert.equal(title, tpl.subject, 'Betreff im Kopf und <title> sind gleich')
|
||
assert.equal(id.startsWith('T'), tpl.subject.startsWith('[Abo]') || id === 'T01', 'Team-Hinweise ab T02 mit [Abo]')
|
||
|
||
const used = new Set([...tpl.body.matchAll(/\{\{(\w+)\}\}/g), ...tpl.subject.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]))
|
||
assert.deepEqual([...used].sort(), [...tpl.placeholders].sort(), 'deklarierte = verwendete Platzhalter')
|
||
|
||
const opened = [...tpl.body.matchAll(/<!-- WENN (\w+) -->/g)].map((m) => m[1])
|
||
const closed = [...tpl.body.matchAll(/<!-- ENDE (\w+) -->/g)].map((m) => m[1])
|
||
assert.deepEqual([...new Set(opened)].sort(), [...tpl.blocks].sort(), 'deklarierte = verwendete Blöcke')
|
||
assert.deepEqual(opened.slice().sort(), closed.slice().sort(), 'jeder Block wird geschlossen')
|
||
})
|
||
|
||
test(`${id}: rendert ohne Reste, mit und ohne optionale Blöcke`, () => {
|
||
const tpl = loadTemplate(id)
|
||
for (const on of [true, false]) {
|
||
const mail = renderMail(id, sampleValues(tpl), allBlocks(tpl, on))
|
||
assert.deepEqual(mail.problems, [])
|
||
assert.doesNotMatch(mail.html, /\{\{|WENN|ENDE|Platzhalter:/)
|
||
assert.match(mail.html, /<!--\[if mso\]>/, 'bedingter Outlook-Kommentar bleibt')
|
||
assert.equal((mail.html.match(/<!--/g) || []).length, 1, 'nur der Outlook-Kommentar bleibt')
|
||
assert.match(mail.html, /src="https:\/\/project\.webklar\.com\/webklar-logo\.png"/)
|
||
assert.doesNotMatch(mail.subject, /\{\{|\n/)
|
||
assert.doesNotMatch(mail.text, /<[a-z/!]|&[a-z#0-9]+;|\{\{/i, 'Textfassung ohne Tags und Entities')
|
||
}
|
||
})
|
||
}
|
||
|
||
test('Werte werden HTML-sicher eingesetzt, Zeilenumbrüche werden <br>', () => {
|
||
const mail = renderMail('T01', { kunde_name: 'Max <b>&"\'', nachricht: 'Zeile 1\nZeile <2>' })
|
||
assert.match(mail.html, /Max <b>&"' hat im Kundenportal geschrieben/)
|
||
assert.match(mail.html, /Zeile 1<br>Zeile <2>/)
|
||
assert.doesNotMatch(mail.html, /<b>&/)
|
||
assert.equal(mail.subject, 'Neue Portalnachricht von Max <b>&"\'')
|
||
assert.match(mail.text, /Zeile 1\nZeile <2>/)
|
||
assert.equal(escapeHtml('a&b'), 'a&b')
|
||
})
|
||
|
||
test('optionale Blöcke: nur eingeschaltete erscheinen', () => {
|
||
const values = { name: 'Max', angebot_text: 'Erste 2 Monate je 25,00 €', vertragsschluss: '01.10.2026', leistungsbeginn: '15.10.2026', mindestlaufzeit_bis: '15.10.2027', bedingungen_version: 'v3', zustimmung_zeitpunkt: '01.10.2026, 10:00' }
|
||
const early = renderMail('K01', values, { angebot: false, sofortbeginn: true, beginn_nach_widerrufsfrist: false })
|
||
assert.doesNotMatch(early.html, /Ihr Angebot:/)
|
||
assert.match(early.html, /Sie haben ausdrücklich verlangt/)
|
||
assert.doesNotMatch(early.html, /erst nach Ablauf der Widerrufsfrist/)
|
||
const late = renderMail('K01', values, { angebot: true, sofortbeginn: false, beginn_nach_widerrufsfrist: true })
|
||
assert.match(late.html, /Ihr Angebot:<\/strong> Erste 2 Monate je 25,00 € \(wie vereinbart\)/)
|
||
assert.doesNotMatch(late.html, /Sie haben ausdrücklich verlangt/)
|
||
assert.match(late.html, /Leistungsbeginn und erste Abbuchung: 15\.10\.2026\./)
|
||
})
|
||
|
||
test('verschachtelte Blöcke (T09) werden aufgelöst', () => {
|
||
const values = { email: 'a@example.com', name: 'A', kundennummer: '', stripe_abo_id: 'sub_1', abo_beginn: '01.10.2026', fristende: '15.10.2026', rechtzeitig: 'RECHTZEITIG' }
|
||
const ok = renderMail('T09', values, { abo_gefunden: true, auf_kuendigung_gesetzt: true })
|
||
assert.match(ok.html, /sub_1 <span[^>]*>\(auf Kündigung gesetzt\)<\/span>/)
|
||
assert.doesNotMatch(ok.html, /KEIN aktives Abo|NICHT automatisch/)
|
||
const failed = renderMail('T09', values, { abo_gefunden: true, auf_kuendigung_gesetzt: false, kuendigung_fehlgeschlagen: true })
|
||
assert.doesNotMatch(failed.html, /auf Kündigung gesetzt\)/)
|
||
assert.match(failed.html, /Abo sub_1 konnte NICHT automatisch auf Kündigung gesetzt werden/)
|
||
const none = renderMail('T09', values, { kein_abo_gefunden: true })
|
||
assert.doesNotMatch(none.html, /Stripe-Abo|Abo-Beginn|Widerrufsfrist bis/)
|
||
assert.match(none.html, /KEIN aktives Abo zu dieser E-Mail gefunden/)
|
||
assert.doesNotMatch(none.html, /WENN|ENDE/)
|
||
})
|
||
|
||
test('Anrede ohne Namen, fehlende und unbekannte Werte werden gemeldet', () => {
|
||
const mail = renderMail('K10', { name: '', antrag_datum: '01.10.2026', antrag_nr: 'AB12CD34', begruendung: 'x', extra: 1 }, { gibtsnicht: true })
|
||
assert.match(mail.html, /Guten Tag,<br>/)
|
||
assert.match(mail.text, /^Guten Tag,$/m)
|
||
assert.deepEqual(mail.problems.sort(), ['unbekannter Block "gibtsnicht"', 'unbekannter Platzhalter {{extra}}'])
|
||
const missing = renderMail('K10', { name: 'A', antrag_datum: '01.10.2026', antrag_nr: 'X' })
|
||
assert.deepEqual(missing.problems, ['kein Wert für {{begruendung}}'])
|
||
})
|
||
|
||
test('Textfassung K01: alle Vertragsangaben lesbar', () => {
|
||
const mail = renderMail(
|
||
'K01',
|
||
{ name: 'Erika Musterfrau', angebot_text: '', vertragsschluss: '01.10.2026', leistungsbeginn: '01.10.2026', mindestlaufzeit_bis: '01.10.2027', bedingungen_version: '2026-09-25-v3', zustimmung_zeitpunkt: '01.10.2026, 10:07' },
|
||
{ sofortbeginn: true }
|
||
)
|
||
const t = mail.text
|
||
for (const line of [
|
||
'Guten Tag Erika Musterfrau,',
|
||
'50,00 € / Monat',
|
||
'gemäß § 19 UStG ohne Umsatzsteuer',
|
||
'Vertragsschluss: 01.10.2026',
|
||
'Leistungsbeginn: 01.10.2026',
|
||
'Laufzeit: Mindestlaufzeit 12 Monate bis 01.10.2027. Kündigung zu diesem Termin bis einen Monat vorher. Danach läuft das Abo unbefristet und ist jederzeit zum Ende des Abrechnungsmonats kündbar.',
|
||
'• 1 Domain',
|
||
'Zum Kundenportal: https://project.webklar.com/dashboard.html',
|
||
'📎 Widerrufsbelehrung mit Muster-Widerrufsformular',
|
||
'Ihre Zustimmung zu den Vertragsbedingungen (Version 2026-09-25-v3) haben wir am 01.10.2026, 10:07 Uhr erhalten.',
|
||
'Vertrag widerrufen (https://project.webklar.com/widerrufen.html)',
|
||
'Vertrag kündigen (https://project.webklar.com/kuendigen.html)',
|
||
'Telefon 0176 23726355 · support@webklar.com',
|
||
'Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein',
|
||
]) {
|
||
assert.ok(t.split('\n').includes(line), `Zeile fehlt: ${line}\n---\n${t}`)
|
||
}
|
||
assert.doesNotMatch(t, /Ihr Wartungs-Abo ist abgeschlossen\. Alle Vertragsunterlagen/, 'Vorschautext nicht im Text')
|
||
})
|
||
|
||
test('htmlToText: Links, Buttons und Leerzeilen', () => {
|
||
const t = htmlToText('<body><table><tr><td class="px">A <a href="mailto:x@y.de">x@y.de</a> B</td></tr><tr><td class="px"><a href="https://e.de/p" style="display:block">Los →</a></td></tr></table></body>')
|
||
assert.equal(t, 'A x@y.de B\n\nLos: https://e.de/p\n')
|
||
})
|
||
|
||
test('Vorlagen-Ordner enthält nur Vorlagen', () => {
|
||
assert.deepEqual(readdirSync(DIR).filter((f) => !/^[KT]\d{2} .*\.html$/.test(f)), [])
|
||
})
|
||
|
||
test('K09: Gutschrift wird mit dem Abo verrechnet, auch nach einem Wechsel', () => {
|
||
const tpl = loadTemplate('K09')
|
||
const mail = renderMail('K09', sampleValues(tpl), allBlocks(tpl, true))
|
||
assert.match(mail.text, /mit den nächsten Monatsgebühren Ihres Abos\. Auf Ihren Rechnungen erscheint sie als angerechnetes Guthaben\./)
|
||
assert.match(mail.text, /Eine Auszahlung ist nicht möglich\. Wechseln Sie in ein anderes Abo, verrechnen wir die Gutschrift weiter; endet Ihr Abo ohne einen solchen Wechsel, entfällt ein noch nicht verrechneter Rest\./)
|
||
assert.doesNotMatch(mail.html, /Wartungs-Abo|Vertragsende/)
|
||
})
|
||
|
||
// Task 7 (Fix 1): konnte das Vertragsereignis "abschluss" dauerhaft nicht gespeichert werden (4xx bei
|
||
// Appwrite), erfaehrt das Team das im T02-Hinweis statt in einem stillen Webhook-Fehler
|
||
test('T02: Block nachweis_fehlt weist auf den fehlenden Nachweis hin', () => {
|
||
const tpl = loadTemplate('T02')
|
||
const values = sampleValues(tpl)
|
||
const withBlock = renderMail('T02', values, { nachweis_fehlt: true })
|
||
assert.deepEqual(withBlock.problems, [])
|
||
assert.match(withBlock.text, /Nachweis nicht gespeichert/)
|
||
assert.match(withBlock.text, /contractEvents/)
|
||
assert.doesNotMatch(renderMail('T02', values, {}).text, /Nachweis nicht gespeichert/)
|
||
})
|
||
|
||
// Task 9 (Rechtsrunde 28.09.2026): Wechsel ins Wartungs-Abo wird erst mit der Zahlung wirksam
|
||
test('K12, K14, T12: Betreff, Platzhalter und Blöcke des Wechsels', () => {
|
||
assert.equal(loadTemplate('K12').subject, 'Ihr Wechsel ins WEBklar Wartungs-Abo – Bestätigung')
|
||
assert.deepEqual(loadTemplate('K12').placeholders, ['name', 'wechseltag', 'betrag_heute', 'monatspreis', 'mindestlaufzeit_bis', 'bedingungen_version', 'zustimmung_zeitpunkt'])
|
||
assert.equal(loadTemplate('K14').subject, 'Ihr Wechsel ins WEBklar Wartungs-Abo ist nicht zustande gekommen')
|
||
assert.deepEqual(loadTemplate('K14').placeholders, ['name', 'grund'])
|
||
assert.equal(loadTemplate('T12').subject, '[Abo] Wechsel ins Wartungs-Abo: {{email}}')
|
||
// F37: feste Platzhalter fuer T12
|
||
assert.deepEqual(loadTemplate('T12').placeholders, ['email', 'name', 'stripe_abo_id', 'wechseltag', 'betrag_heute', 'monatspreis'])
|
||
for (const block of ['gescheitert', 'lastschrift_gescheitert']) assert.ok(loadTemplate('T12').blocks.includes(block), block)
|
||
})
|
||
|
||
test('K12: Block zahlung_offen wörtlich, Block lastschrift mit dem Satz aus § 9 Abs. 3', () => {
|
||
const values = sampleValues(loadTemplate('K12'))
|
||
const offen = renderMail('K12', values, { zahlung_offen: true })
|
||
assert.ok(offen.text.includes('Der Wechsel wird wirksam, sobald die Zahlung bestätigt ist. Andernfalls läuft Ihr Hosting-Abo mit Preis und Mindestlaufzeit wie bisher weiter.'))
|
||
assert.doesNotMatch(offen.text, /SEPA-Lastschrift/)
|
||
const sepa = renderMail('K12', values, { lastschrift: true })
|
||
assert.match(sepa.text, /Wird die Lastschrift nicht eingelöst, gilt der Wechsel als nicht erfolgt/)
|
||
assert.match(sepa.text, /Ihr Abbuchungstag bleibt der Wechseltag/)
|
||
assert.doesNotMatch(sepa.text, /sobald die Zahlung bestätigt ist/)
|
||
const plain = renderMail('K12', values, {})
|
||
assert.doesNotMatch(plain.text, /sobald die Zahlung bestätigt ist|Wird die Lastschrift nicht eingelöst/)
|
||
// Vertragsbestaetigung: Preis mit § 19 UStG, Mindestlaufzeit, Unterlagen, Zustimmung zum Sofortbeginn
|
||
for (const text of ['gemäß § 19 UStG ohne Umsatzsteuer', 'Mindestlaufzeit 12 Monate bis Wert-mindestlaufzeit_bis.', 'Widerrufsbelehrung mit Muster-Widerrufsformular', 'Sie haben ausdrücklich verlangt, dass wir vor Ablauf der Widerrufsfrist mit dem Wartungs-Abo beginnen']) {
|
||
assert.ok(plain.text.includes(text), text)
|
||
}
|
||
})
|
||
|
||
test('K14: Hosting-Abo läuft wie vor dem Wechsel weiter; SEPA: Abbuchungstag bleibt', () => {
|
||
const values = { name: 'Kunde', grund: 'Die SEPA-Lastschrift für die Wechselrechnung wurde nicht eingelöst.' }
|
||
const card = renderMail('K14', values, {})
|
||
assert.deepEqual(card.problems, [])
|
||
assert.ok(card.text.includes('Ihr Hosting-Abo läuft mit Preis und Mindestlaufzeit wie vor dem Wechsel weiter.'))
|
||
assert.ok(card.text.includes(values.grund))
|
||
assert.doesNotMatch(card.text, /Abbuchungstag/)
|
||
assert.match(renderMail('K14', values, { lastschrift: true }).text, /Ihr Abbuchungstag bleibt der Tag des Wechsels\./)
|
||
})
|
||
|
||
// Nach der Pruefung der Rechtsrunden-Texte: Stripe kann die Frist auch kuerzer setzen
|
||
test('Wechsel-Mails nennen keine feste Frist von 23 Stunden', () => {
|
||
for (const id of ['K12', 'K14', 'T12']) {
|
||
const tpl = loadTemplate(id)
|
||
assert.doesNotMatch(renderMail(id, sampleValues(tpl), allBlocks(tpl, true)).text, /23 Stunden/, id)
|
||
}
|
||
})
|