Files
Webklar-Kundenbereich/test/mailTemplates.test.js
T
ClaudeandClaude Sonnet 5.5 973135d2de Mails K22, K23 und Hinweis auf das Ende der Funktionen in K02/K03
Terminplaner TP2 E3. K22 "Terminplaner beendet" (Blöcke abo_ende, hosting,
vor_einrichtung, daten) und K23 "Terminplaner läuft wieder" im Layout von K19,
Du-Form, Texte als Entwurf für die Mail-Überarbeitung. K02 und K03 bekommen den
Block funktionen (in K03 innerhalb von abo); routes/abo.js setzt ihn nur beim
Wartungs-Abo und nie bei der Speicher-Kündigung.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 14:09:05 +00:00

980 lines
69 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 assert from 'node:assert/strict'
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import { test } from 'node:test'
import { escapeHtml, htmlToText, loadTemplate, renderMail, templateIds } from '../server/services/mailTemplates.js'
import { contractAttachmentFor, termsDocFor } from '../server/services/products.js'
const DIR = new URL('../server/mail-templates/', import.meta.url)
// G01–G08 Gästemails des Terminplaners (Task 9, neutrales Layout im Auftrag eines Betriebs), K20 und K21 Hinweise an den Betrieb;
// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b)
const ids = (prefix, count) => Array.from({ length: count }, (_, i) => `${prefix}${String(i + 1).padStart(2, '0')}`)
const EXPECTED = [...ids('G', 8), ...ids('K', 23), ...ids('T', 19), 'T20']
const GUEST = ids('G', 8)
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')
// F34: Team-Hinweise tragen [Abo], [Speicher] (Zusatzspeicher-Aufgaben), [Datenschutz] (Loeschfristen), [Zeit] (Push-Zeitmail), [Finanzen] (Bankzugang) oder [Terminplaner] (Betriebsmeldung)
assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz|Zeit|Finanzen|Terminplaner)\]/.test(tpl.subject) || id === 'T01' || id === 'T16' || id === 'T17', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz], [Zeit], [Finanzen] oder [Terminplaner] (T16 Funktion bestellt und T17 Repo zuordnen ohne Kennung)')
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')
// K und T tragen das WEBklar-Logo; G (Gästemails im Auftrag eines Betriebs) haben ein neutrales Layout ohne
if (/^[KT]/.test(id)) assert.match(mail.html, /src="https:\/\/project\.webklar\.com\/webklar-logo\.png"/)
else assert.doesNotMatch(mail.html, /webklar-logo\.png/, 'G-Vorlagen ohne WEBklar-Logo')
assert.doesNotMatch(mail.subject, /\{\{|\n/)
assert.doesNotMatch(mail.text, /<[a-z/!]|&[a-z#0-9]+;|\{\{/i, 'Textfassung ohne Tags und Entities')
}
})
}
// Terminplaner TP2 (A10a1): Betriebsmeldung ans Team; Versand ab A10a2 ueber sendAdminNotice
test('T20: Betreff [Terminplaner], Platzhalter vorfall/details/zeit, keine Blöcke', () => {
const tpl = loadTemplate('T20')
assert.equal(tpl.file, 'T20 Terminplaner Betriebsmeldung.html')
assert.equal(tpl.subject, '[Terminplaner] {{vorfall}}')
assert.deepEqual(tpl.placeholders, ['vorfall', 'details', 'zeit'])
assert.deepEqual(tpl.blocks, [])
// jeder deklarierte Platzhalter steht im Text, vorfall zusaetzlich im Betreff
for (const key of tpl.placeholders) assert.ok(tpl.body.includes(`{{${key}}}`), `{{${key}}} im Text`)
const mail = renderMail('T20', { vorfall: 'Mailkontingent erreicht', details: 'Zeile 1\nZeile <2>', zeit: '04.10.2026, 10:00' })
assert.deepEqual(mail.problems, [])
assert.equal(mail.subject, '[Terminplaner] Mailkontingent erreicht')
assert.match(mail.html, /Zeile 1<br>Zeile &lt;2&gt;/, 'details: HTML-sicher, Zeilenumbrüche bleiben')
assert.doesNotMatch(mail.html, /Zeile <2>/)
for (const line of ['Mailkontingent erreicht', 'Zeile 1\nZeile <2>', '04.10.2026, 10:00']) assert.ok(mail.text.includes(line), `Text enthält: ${line}\n---\n${mail.text}`)
})
// Terminplaner TP2 (B9a): K15 und K16 gelten für jede Funktion des Registers; der Wortlaut je Funktion kommt aus den Platzhaltern
test('K15, K16: Platzhalter und Blöcke je Funktion, keine Funktion fest im Text', () => {
const k15 = loadTemplate('K15')
assert.equal(k15.subject, 'Deine Bestellung: {{funktion}}')
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'])
const k16 = loadTemplate('K16')
assert.equal(k16.subject, '{{funktion_dein}} ist bereit')
assert.deepEqual(k16.placeholders, ['kunde_name', 'projekt', 'funktion', 'funktion_dein', 'funktion_nom', 'bereit', 'knopf', 'portal_url'])
assert.deepEqual(k16.blocks, [])
for (const tpl of [k15, k16]) assert.doesNotMatch(tpl.body, /News|Beitr|#news|#termine|Terminplaner/, `${tpl.id}: keine feste Funktion`)
const mail = renderMail('K15', { ...sampleValues(k15), rechnung_url: 'https://rechnung.example/1', portal_url: 'https://portal.example/#x' }, { rechnung: true })
assert.deepEqual(mail.problems, [])
assert.equal(mail.subject, 'Deine Bestellung: Wert-funktion')
for (const part of ['Wir richten Wert-funktion_akk jetzt für Dein Projekt ein.', 'Sobald Wert-funktion_nom auf Deiner Website eingebaut ist, schalten wir Wert-funktion_pron im Kundenportal frei', 'Wert-danach', 'Wert-preis (Wert-preis_hinweis)', 'Rechnung ansehen: https://rechnung.example/1', 'Zum Kundenportal: https://portal.example/#x']) {
assert.ok(mail.text.includes(part), `K15 enthält: ${part}\n---\n${mail.text}`)
}
assert.doesNotMatch(renderMail('K15', sampleValues(k15), { rechnung: false }).text, /Rechnung ansehen/)
const bereit = renderMail('K16', sampleValues(k16), {})
assert.deepEqual(bereit.problems, [])
assert.equal(bereit.subject, 'Wert-funktion_dein ist bereit')
for (const part of ['✓ Wert-funktion', 'Wert-funktion_nom ist auf Deiner Website eingebaut und freigeschaltet. Wert-bereit', 'Wert-knopf: Wert-portal_url']) {
assert.ok(bereit.text.includes(part), `K16 enthält: ${part}\n---\n${bereit.text}`)
}
})
// Terminplaner TP2 (B9b): T16 gilt je Funktion (neue Platzhalter und Blöcke), T19 ist neu (Erstattung fällig, Team-Hinweis)
test('T16, T19: Betreff, Platzhalter und Blöcke stehen in der Vorlage', () => {
const t16 = loadTemplate('T16')
assert.equal(t16.subject, 'Neue Funktion bestellt: {{funktion}}')
assert.deepEqual(t16.blocks, ['zahlung_fehlgeschlagen', 'ungeklaert', 'kein_abo', 'bereits_aktiv', 'anleitung', 'gesundheit'])
const t19 = loadTemplate('T19')
assert.equal(t19.file, 'T19 Erstattung faellig.html')
assert.match(t19.subject, /^\[Abo\] Erstattung fällig: \{\{funktion\}\}-Einrichtung$/)
assert.deepEqual(t19.blocks, ['zahlung_nach_ende', 'ticket_offen', 'rechnung'])
for (const tpl of [t16, t19]) {
for (const key of tpl.placeholders) assert.ok(tpl.body.includes(`{{${key}}}`) || tpl.subject.includes(`{{${key}}}`), `${tpl.id}: {{${key}}} steht in der Vorlage`)
for (const block of tpl.blocks) {
assert.equal((tpl.body.match(new RegExp(`<!-- WENN ${block} -->`, 'g')) || []).length, 1, `${tpl.id}: Block ${block} genau einmal`)
}
}
})
// Terminplaner TP2 (E3): K22 „Terminplaner beendet“ je Grund eine Variante, K23 „läuft wieder“; Texte sind Entwurf für die Mail-Überarbeitung
const K22_WERTE = { kunde_name: 'Erika', projekt: 'Praxis Muster', enddatum: '03.10.2026', loeschdatum: '02.11.2026', preis: '149 €', portal_url: 'https://portal.example/#termine' }
const k22 = (blocks) => renderMail('K22', K22_WERTE, blocks)
test('K22, K23: Betreff, Platzhalter und Blöcke stehen in der Vorlage', () => {
const tpl22 = loadTemplate('K22')
assert.equal(tpl22.file, 'K22 Terminplaner beendet.html')
assert.equal(tpl22.subject, 'Dein Terminplaner wurde beendet – {{projekt}}')
assert.deepEqual(tpl22.placeholders, ['kunde_name', 'projekt', 'enddatum', 'loeschdatum', 'preis', 'portal_url'])
assert.deepEqual(tpl22.blocks, ['abo_ende', 'hosting', 'vor_einrichtung', 'daten'])
const tpl23 = loadTemplate('K23')
assert.equal(tpl23.file, 'K23 Terminplaner laeuft wieder.html')
assert.equal(tpl23.subject, 'Dein Terminplaner läuft wieder – {{projekt}}')
assert.deepEqual(tpl23.placeholders, ['kunde_name', 'projekt', 'portal_url'])
assert.deepEqual(tpl23.blocks, [])
assert.equal(k22({}).subject, 'Dein Terminplaner wurde beendet – Praxis Muster')
})
test('K22 abo_ende: nennt das beendete Wartungs-Abo, keine Erstattung', () => {
const mail = k22({ abo_ende: true })
assert.deepEqual(mail.problems, [])
assert.match(mail.text, /Wartungs-Abo/)
assert.match(mail.text, /03\.10\.2026/, 'Enddatum')
assert.doesNotMatch(mail.text, /erstatten/)
assert.doesNotMatch(mail.text, /Hosting-Abo/, 'nicht die Hosting-Variante')
assert.doesNotMatch(mail.text, /02\.11\.2026|Was jetzt gilt/, 'der Kasten kommt nur mit dem Block daten')
})
test('K22 hosting: das Hosting-Abo reicht für den Terminplaner nicht', () => {
const mail = k22({ hosting: true })
assert.deepEqual(mail.problems, [])
assert.match(mail.text, /Hosting-Abo/)
assert.match(mail.text, /Wartungs-Abo/, 'sagt, was der Terminplaner braucht')
assert.doesNotMatch(mail.text, /erstatten/)
assert.doesNotMatch(mail.text, /02\.11\.2026|Was jetzt gilt/)
})
test('K22 vor_einrichtung: Einrichtung wird voll erstattet, nennt den Preis, kein Löschkasten', () => {
const mail = k22({ vor_einrichtung: true })
assert.deepEqual(mail.problems, [])
assert.match(mail.text, /149 €/)
assert.match(mail.text, /erstatten/)
assert.match(mail.text, /vor der Einrichtung/)
assert.doesNotMatch(mail.text, /02\.11\.2026|Was jetzt gilt|Rückkehr|löschen/, 'es gibt noch keine Termindaten, also keinen Löschkasten')
})
test('K22 daten: Kasten „Was jetzt gilt“ mit Löschdatum, Gästen und Rückkehr', () => {
const mail = k22({ daten: true })
assert.deepEqual(mail.problems, [])
const t = mail.text
assert.match(t, /Was jetzt gilt/)
assert.match(t, /Bis 02\.11\.2026 kannst Du Deine Termine im Kundenportal ansehen und als CSV exportieren/)
assert.match(t, /Gäste können ihre Termine weiter absagen/)
assert.match(t, /Bestehende Termine bleiben eingetragen, und die Erinnerungen an Deine Gäste laufen bis zur Löschung weiter/)
assert.match(t, /Bitte informiere Deine Gäste selbst/)
assert.match(t, /Ab 02\.11\.2026 löschen wir die Termindaten/)
assert.match(t, /30 Tage/, 'Löschfrist 30 Tage nach dem Abschalten')
assert.match(t, /Rückkehr: Läuft bis dahin wieder ein Wartungs-Abo, schalten wir den Terminplaner automatisch und ohne Gebühr wieder ein/)
assert.match(t, /Nach der Löschung braucht es einen neuen Fragebogen und die Einrichtung für 149 € noch einmal/)
assert.doesNotMatch(t, /erstatten/)
})
test('K22: Anrede, Projekt und Knopf ins Kundenportal stehen in jeder Variante', () => {
const mail = k22({ abo_ende: true, daten: true })
assert.deepEqual(mail.problems, [])
assert.match(mail.text, /Hallo Erika,/)
assert.match(mail.text, /Praxis Muster/)
assert.match(mail.text, /Zum Kundenportal: https:\/\/portal\.example\/#termine/)
assert.match(mail.html, /href="https:\/\/portal\.example\/#termine"/)
})
test('K23: nennt das Projekt und führt ins Kundenportal', () => {
const mail = renderMail('K23', { kunde_name: 'Erika', projekt: 'Praxis Muster', portal_url: 'https://portal.example/#termine' })
assert.deepEqual(mail.problems, [])
assert.equal(mail.subject, 'Dein Terminplaner läuft wieder – Praxis Muster')
assert.match(mail.text, /Hallo Erika,/)
assert.match(mail.text, /Praxis Muster/)
assert.match(mail.text, /Wartungs-Abo/)
assert.match(mail.text, /Zum Kundenportal: https:\/\/portal\.example\/#termine/)
assert.match(mail.html, /<a href="https:\/\/portal\.example\/#termine"/)
})
// Terminplaner TP2 (E3): K02/K03 sagen, dass die Funktionen mit dem Wartungs-Abo enden (Sie-Form wie der Rest der Vorlagen)
test('K02, K03: Block funktionen nennt Terminplaner und Löschfrist, nur mit Block (in K03 nur innerhalb von abo)', () => {
const k02 = { name: 'K', produkt: 'Wartungs-Abo', eingang_zeitpunkt: '01.10.2026, 10:00', vertragsende: '01.10.2027', vertragsende_paragraph: '§ 11' }
assert.ok(loadTemplate('K02').blocks.includes('funktionen'))
const an = renderMail('K02', k02, { wartung: true, funktionen: true })
assert.deepEqual(an.problems, [])
assert.match(an.text, /Die Website-Analyse und der Terminplaner gehören zum Wartungs-Abo und enden mit ihm\. Die Termindaten löschen wir 30 Tage nach dem Abschalten; vorher können Sie Ihre Termine im Kundenportal ansehen und exportieren\./)
assert.doesNotMatch(renderMail('K02', k02, { wartung: true }).text, /Terminplaner/)
assert.doesNotMatch(renderMail('K02', { ...k02, produkt: 'Hosting-Abo' }, {}).text, /Terminplaner|Wartungs-Abo/, 'Hosting-Abo: kein Hinweis')
const k03 = { ...sampleValues(loadTemplate('K03')), vertragsende_paragraph: '§ 11' }
assert.ok(loadTemplate('K03').blocks.includes('funktionen'))
const abo = renderMail('K03', k03, { abo: true, wartung: true, funktionen: true })
assert.deepEqual(abo.problems, [])
assert.match(abo.text, /Die Website-Analyse und der Terminplaner gehören zum Wartungs-Abo und enden mit ihm\. Die Termindaten löschen wir 30 Tage nach dem Abschalten; vorher können Sie Ihre Termine im Kundenportal ansehen und exportieren\./)
assert.doesNotMatch(renderMail('K03', k03, { abo: true, wartung: true }).text, /Terminplaner/)
// Speicher-Kündigung (abo aus): der Block steht innerhalb von abo und fällt mit ihm weg
assert.doesNotMatch(renderMail('K03', k03, { abo: false, funktionen: true }).text, /Terminplaner/)
})
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 &lt;b&gt;&amp;&quot;&#39; hat im Kundenportal geschrieben/)
assert.match(mail.html, /Zeile 1<br>Zeile &lt;2&gt;/)
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&amp;b')
})
test('optionale Blöcke: nur eingeschaltete erscheinen', () => {
const values = { name: 'Max', produkt: 'Wartungs-Abo', preis: '50,00 € / Monat', 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', () => {
// F34: Produkt und Preis kommen aus dem Katalog, der Produktblock (hier wartung) bringt die Liste „Enthalten“
const mail = renderMail(
'K01',
{ name: 'Erika Musterfrau', produkt: 'Wartungs-Abo', preis: '50,00 € / Monat', 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, wartung: true }
)
assert.deepEqual(mail.problems, [])
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) einschließlich des Auftragsverarbeitungsvertrags 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 &rarr;</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) => !/^[GKT]\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)
}
})
// Task 8 (Fix 1): K13 (Kunde) und T13 (Team) fuer den Zusatzspeicher, Aufbau wie K12/T12; Endfassung der Texte in Aufgabe 12
test('K13, T13: Betreff, Platzhalter und Blöcke des Zusatzspeichers', () => {
assert.equal(loadTemplate('K13').subject, 'Ihr E-Mail-Zusatzspeicher – Bestätigung')
assert.deepEqual(loadTemplate('K13').placeholders, ['name', 'pakete', 'gesamt_gb', 'monatspreis_zusatz', 'wirksam_ab'])
// Fix 2 (3): plus = Einrichtung mit anteiliger Berechnung, plus_bezahlt = Plus bis zur schon bezahlten Menge,
// bestellung = Unterlagen und Widerruf (bei beiden), minus = Verringerung zum Periodenende
assert.deepEqual(loadTemplate('K13').blocks, ['plus', 'plus_bezahlt', 'bestellung', 'minus'])
assert.equal(loadTemplate('T13').subject, '[Speicher] {{aktion}}: {{email}}')
for (const p of ['aktion', 'email', 'name', 'stripe_abo_id', 'postfaecher_hinweis', 'neue_groesse_gb', 'pakete', 'faellig']) {
assert.ok(loadTemplate('T13').placeholders.includes(p), p)
}
assert.deepEqual([...loadTemplate('T13').blocks].sort(), ['einrichten', 'nachweis_fehlt', 'periodenende', 'teilweise'])
const values = sampleValues(loadTemplate('K13'))
const plus = renderMail('K13', values, { plus: true, bestellung: true })
assert.deepEqual(plus.problems, [])
assert.match(plus.text, /gemäß § 19 UStG ohne Umsatzsteuer/)
assert.match(plus.text, /wird eingerichtet/)
assert.match(plus.text, /14 Tagen widerrufen/)
assert.match(plus.text, /Vertragsunterlagen im Anhang/)
assert.doesNotMatch(plus.text, /ohne anteilige Erstattung|keine zusätzlichen Kosten/)
const paid = renderMail('K13', values, { plus_bezahlt: true, bestellung: true })
assert.ok(paid.text.includes('Die Pakete sind für den laufenden Monat schon bezahlt; es entstehen keine zusätzlichen Kosten. Ab dem nächsten Abrechnungsmonat gelten Wert-pakete Pakete.'))
assert.match(paid.text, /14 Tagen widerrufen/)
assert.match(paid.text, /Vertragsunterlagen im Anhang/)
assert.doesNotMatch(paid.text, /wird eingerichtet|anteilig/)
const minus = renderMail('K13', values, { minus: true })
assert.match(minus.text, /ohne anteilige Erstattung/)
assert.match(minus.text, /keine neuen E-Mails/)
assert.doesNotMatch(minus.text, /wird eingerichtet|14 Tagen widerrufen|im Anhang|schon bezahlt/)
const t = loadTemplate('T13')
const teilweise = renderMail('T13', { ...sampleValues(t), pakete: 5 }, { einrichten: true, teilweise: true })
assert.deepEqual(teilweise.problems, [])
assert.match(teilweise.text, /Schritt 2 fehlgeschlagen/)
assert.match(teilweise.text, /von Hand auf 5 setzen/)
assert.match(renderMail('T13', sampleValues(t), { nachweis_fehlt: true }).text, /Nachweis nicht gespeichert/)
assert.match(renderMail('T13', sampleValues(t), { periodenende: true }).text, /Periodenende|zum Wert-faellig/)
assert.doesNotMatch(renderMail('T13', sampleValues(t), {}).text, /Schritt 2 fehlgeschlagen|Nachweis nicht gespeichert/)
})
// Task 10: Kuendigung und Widerruf mit Auswahl - K03/T08 nennen den Vertrag und die gekuendigten Pakete, K04/T09 die
// Bestellung; die Handgriffe je Bestellung und "wir pruefen" ohne offene Frist. Endfassung der Texte in Aufgabe 12
test('K03, K04, T08, T09: Vertrag bzw. Bestellung, Pakete und die Blöcke der Auswahl', () => {
for (const p of ['vertrag', 'pakete', 'pakete_neu']) assert.ok(loadTemplate('K03').placeholders.includes(p), `K03 ${p}`)
for (const b of ['zusatzspeicher', 'pruefen', 'abo']) assert.ok(loadTemplate('K03').blocks.includes(b), `K03 ${b}`)
assert.ok(loadTemplate('K04').placeholders.includes('bestellung'))
assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'pruefen'])
for (const p of ['vertrag', 'pakete', 'fehler']) assert.ok(loadTemplate('T08').placeholders.includes(p), `T08 ${p}`)
for (const b of ['zusatzspeicher', 'kein_speicher', 'wechsel_ausstehend', 'speicher_fehlgeschlagen', 'nachweis_fehlt']) assert.ok(loadTemplate('T08').blocks.includes(b), `T08 ${b}`)
for (const p of ['bestellung', 'ereignisse']) assert.ok(loadTemplate('T09').placeholders.includes(p), `T09 ${p}`)
for (const b of ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'keine_frist', 'nachweis_fehlt']) assert.ok(loadTemplate('T09').blocks.includes(b), `T09 ${b}`)
const k03 = { ...sampleValues(loadTemplate('K03')), vertrag: 'E-Mail-Zusatzspeicher zum WEBklar Hosting-Abo', pakete: 2, pakete_neu: 1, vertragsende: '31.10.2026' }
const speicher = renderMail('K03', k03, { zusatzspeicher: true })
assert.deepEqual(speicher.problems, [])
assert.match(speicher.text, /Vertrag: E-Mail-Zusatzspeicher zum WEBklar Hosting-Abo/)
assert.match(speicher.text, /Ihr Zusatzspeicher verringert sich zum\n31\.10\.2026/)
assert.match(speicher.text, /um 2 Paket\(e\) gekündigt; danach bleiben 1 Paket\(e\)/)
assert.doesNotMatch(speicher.text, /Wartungs-Abo\n|Stundenkontingent|Stundenguthaben|Wir prüfen Ihre Kündigung/)
assert.match(renderMail('K03', k03, { pruefen: true }).text, /bestätigen Ihnen den Zeitpunkt, zu dem sie wirkt, gesondert per E-Mail/)
// Task 12 (Fix 1, 6): das Stundenkontingent gibt es nur im Wartungs-Abo (Block wartung innerhalb von abo); es ist
// kein Geldguthaben (v4 § 4 Abs. 1), darum „nicht genutzte Arbeitszeit“ statt „Stundenguthaben“
assert.match(renderMail('K03', k03, { abo: true, wartung: true }).text, /Nicht genutzte Arbeitszeit aus dem Stundenkontingent verfällt mit Vertragsende\./)
assert.doesNotMatch(renderMail('K03', k03, { abo: true }).text, /Stundenkontingent|Stundenguthaben/)
assert.doesNotMatch(renderMail('K03', k03, { abo: true, wartung: true }).text, /Stundenguthaben/)
assert.match(renderMail('K03', k03, { abo: true }).text, /Domain-AuthCode/)
const k04 = { ...sampleValues(loadTemplate('K04')), bestellung: 'Wechsel ins Wartungs-Abo' }
assert.match(renderMail('K04', k04, { wechsel: true }).text, /Widerruf der Bestellung „Wechsel ins Wartungs-Abo“/)
// Task 12: Wortlaut der Hinweise zum Widerruf (Fassung 4)
assert.match(renderMail('K04', k04, { wechsel: true }).text, /zu den bisherigen Bedingungen weiter/)
assert.match(renderMail('K04', k04, { zusatzspeicher: true }).text, /Menge vor der Bestellung/)
assert.match(renderMail('K04', k04, { abschluss: true }).text, /keine weiteren Abbuchungen/)
assert.match(renderMail('K04', k04, { pruefen: true }).text, /prüfen, ob für diese Bestellung eine Widerrufsfrist läuft/)
assert.doesNotMatch(renderMail('K04', k04, {}).text, /Wie es weitergeht|WEBklar Wartungs-Abo“/)
const t08 = sampleValues(loadTemplate('T08'))
assert.match(renderMail('T08', t08, { wechsel_ausstehend: true }).text, /Wechsel ausstehend – Speicher nach Abschluss des Wechsels von Hand reduzieren/)
assert.match(renderMail('T08', t08, { speicher_fehlgeschlagen: true }).text, /NICHT automatisch verringert/)
assert.match(renderMail('T08', t08, { kein_speicher: true }).text, /kein Zusatzspeicher gebucht/)
assert.match(renderMail('T08', t08, { nachweis_fehlt: true }).text, /Nachweis nicht gespeichert/)
const t09 = { ...sampleValues(loadTemplate('T09')), ereignisse: 'speicher-1: 0 → 2 Pakete\nspeicher-2: 1 → 3 Pakete' }
const speicherT09 = renderMail('T09', t09, { abo_gefunden: true, zusatzspeicher: true })
assert.match(speicherT09.text, /Menge zurück/)
assert.match(speicherT09.text, /speicher-1: 0 → 2 Pakete\nspeicher-2: 1 → 3 Pakete/)
assert.doesNotMatch(speicherT09.text, /Abo in Stripe sofort beenden/)
assert.match(renderMail('T09', t09, { abo_gefunden: true, abschluss: true }).text, /Abo in Stripe sofort beenden/)
assert.match(renderMail('T09', t09, { abo_gefunden: true, keine_frist: true }).text, /keine offene Frist gefunden/)
assert.match(renderMail('T09', t09, { abo_gefunden: true, wechsel: true }).text, /Preis zurück auf das Hosting-Abo/)
// Fix 1: Paketzahl in jedem Speicherpfad (K03/T08), Pruefung der Kuendigungsart, T09-Hinweise
assert.match(renderMail('K03', { ...k03, pakete_angabe: 'alle gebuchten Pakete' }, { speicher_angaben: true, pruefen: true }).text, /Pakete: alle gebuchten Pakete/)
assert.match(renderMail('K03', { ...k03, kuendigungsart: 'außerordentliche Kündigung' }, { speicher_kind_pruefung: true }).text, /Ihre außerordentliche Kündigung wird geprüft/)
const t08a = { ...t08, pakete_angabe: '2 Paket(e)', kuendigungsart: 'Außerordentliche Kündigung' }
assert.match(renderMail('T08', t08a, { speicher_angaben: true, wechsel_ausstehend: true }).text, /Pakete: 2 Paket\(e\)/)
assert.match(renderMail('T08', t08a, { wechsel_ausstehend: true }).text, /gekündigt: 2 Paket\(e\)/)
assert.match(renderMail('T08', t08a, { speicher_kind_pruefung: true }).text, /Außerordentliche Kündigung des Zusatzspeichers/)
const t09b = { ...t09, fristende: 'keine Frist gefunden', pruefhinweis: 'Ereignisse nicht lesbar – bitte von Hand prüfen' }
assert.match(renderMail('T09', t09b, { abo_gefunden: true }).text, /Widerrufsfrist bis: keine Frist gefunden/)
assert.match(renderMail('T09', t09b, { abo_gefunden: true, nicht_pruefbar: true }).text, /Ereignisse nicht lesbar – bitte von Hand prüfen/)
assert.match(renderMail('T09', t09b, { abo_gefunden: true, wechsel: true, wechsel_offen: true }).text, /Wechselrechnung noch offen – stornieren/)
assert.match(renderMail('T09', t09b, { abo_gefunden: true, abschluss_verspaetet: true }).text, /als ordentliche Kündigung behandeln/)
assert.match(renderMail('T09', t09b, { abo_gefunden: true, bereits_widerrufen: true }).text, /schon widerrufen/)
})
// ---------------------------------------------------------------------------
// Task 12: Mails je Produkt (Hosting-Abo, Wartungs-Abo), Vertragsunterlagen der zugestimmten Fassung,
// Paragraph zum Vertragsende je Fassung, Folgen des Widerrufs wie in den Hinweisen der Fassung 4, UptimeRobot
// ---------------------------------------------------------------------------
test('Neue Vorlagen vorhanden', () => {
for (const id of ['K12', 'K13', 'K14', 'T12', 'T13']) assert.ok(templateIds().includes(id), id)
})
test('K01 Hosting: 1 Postfach, keine Arbeitsstunde; Wartung: beliebig viele Postfächer', () => {
const base = { name: 'K', produkt: 'Hosting-Abo', preis: '20,00 € / Monat', vertragsschluss: '01.10.2026', leistungsbeginn: '01.10.2026',
mindestlaufzeit_bis: '01.10.2027', bedingungen_version: '2026-10-05-v4', zustimmung_zeitpunkt: '01.10.2026, 10:00', angebot_text: '' }
const h = renderMail('K01', base, { hosting: true, sofortbeginn: true })
assert.equal(h.subject, 'Ihr WEBklar Hosting-Abo – Vertragsbestätigung')
assert.ok(h.text.includes('1 E-Mail-Postfach') && !h.text.includes('Stunde Arbeitszeit'), h.text)
assert.ok(h.text.includes('20,00 € / Monat') && h.text.includes('gemäß § 19 UStG ohne Umsatzsteuer'))
assert.ok(h.text.includes('Ihr Hosting-Abo ist bestätigt.') && h.text.includes('Abschluss Ihres Hosting-Abos'))
assert.doesNotMatch(h.text, /Stundenkontingent/)
assert.deepEqual(h.problems, [])
const w = renderMail('K01', { ...base, produkt: 'Wartungs-Abo', preis: '50,00 € / Monat' }, { wartung: true, sofortbeginn: true })
assert.ok(w.text.includes('beliebig viele Postfächer'), w.text)
assert.ok(w.text.includes('1 Stunde Arbeitszeit je bezahltem Monat') && w.text.includes('Stundenkontingent'))
assert.doesNotMatch(w.text, /1 E-Mail-Postfach\n/)
assert.deepEqual(w.problems, [])
})
test('T02 und T07 erinnern an UptimeRobot', () => {
// F38: echte Platzhalter, keine Unstimmigkeiten
const t02 = renderMail('T02', { produkt: 'Hosting-Abo', email: 'k@example.invalid', kunde_name: 'K', appwrite_id: 'c1', stripe_abo_id: 'sub_1', angebot_text: '' }, {})
assert.deepEqual(t02.problems, [])
assert.ok(t02.subject.startsWith('[Abo] Neues Hosting-Abo') && t02.text.includes('Website als Monitor in UptimeRobot anlegen'), t02.text)
assert.equal(loadTemplate('T02').file, 'T02 Neues Abo.html')
assert.match(renderMail('T02', { produkt: 'Hosting-Abo', email: 'k@example.invalid', kunde_name: 'K', appwrite_id: 'c1', stripe_abo_id: 'sub_1', angebot_text: '' }, { nachweis_fehlt: true }).text, /Nachweis nicht gespeichert/)
const t07 = renderMail('T07', { produkt: 'Hosting-Abo', email: 'k@example.invalid', stripe_abo_id: 'sub_1', status: 'canceled', vertragsende_paragraph: '§ 11' }, {})
assert.deepEqual(t07.problems, [])
assert.ok(t07.text.includes('Monitor in UptimeRobot entfernen'), t07.text)
})
test('Keine Vorlage nennt fest „Wartungs-Abo“ im Betreff außer K12, K14 und T12', () => {
for (const id of templateIds()) if (!['K12', 'K14', 'T12'].includes(id)) assert.ok(!loadTemplate(id).subject.includes('Wartungs-Abo'), id)
})
test('K01-Anhang: Fassung 3 für v3-Abos, Fassung 4 sonst', () => {
// contractAttachmentFor(termsVersion) liefert { filename, path } und wird von sendContractConfirmation benutzt
assert.equal(contractAttachmentFor('2026-09-25-v3').filename, 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf')
assert.equal(contractAttachmentFor('2026-10-05-v4').filename, 'WEBklar-Abo-Vertragsunterlagen.pdf')
assert.ok(existsSync(contractAttachmentFor('2026-10-05-v4').path))
assert.ok(existsSync(contractAttachmentFor('2026-09-25-v3').path))
})
// Rechtsrunde: In Fassung 4 ist § 9 der Wechsel, das Vertragsende steht in § 11 (F08)
test('K02, K03, T05, T07: Paragraph zum Vertragsende aus der Fassung des Kunden', () => {
assert.equal(termsDocFor('2026-09-25-v3').endParagraph, '§ 9')
assert.equal(termsDocFor(null).endParagraph, '§ 9')
assert.equal(termsDocFor('2026-10-05-v4').endParagraph, '§ 11')
for (const id of ['K02', 'K03', 'T05', 'T07']) {
const tpl = loadTemplate(id)
assert.ok(tpl.placeholders.includes('vertragsende_paragraph'), id)
const v4 = renderMail(id, { ...sampleValues(tpl), vertragsende_paragraph: '§ 11' }, allBlocks(tpl, true))
assert.deepEqual(v4.problems, [], id)
assert.doesNotMatch(v4.text, /§ 9 der Vertragsbedingungen/, id)
assert.match(v4.text, /§ 11 der Vertragsbedingungen/, id)
assert.match(renderMail(id, { ...sampleValues(tpl), vertragsende_paragraph: '§ 9' }, allBlocks(tpl, true)).text, /§ 9 der Vertragsbedingungen/, id)
}
})
test('K02–K06, T02, T05–T09: Produkt aus dem Katalog statt fest Wartungs-Abo', () => {
for (const id of ['K02', 'K03', 'K04', 'K05', 'K06', 'T02', 'T05', 'T06', 'T07', 'T08', 'T09']) {
assert.ok(loadTemplate(id).placeholders.includes('produkt'), id)
}
const values = { name: 'K', produkt: 'Hosting-Abo', eingang_zeitpunkt: '01.10.2026, 10:00', vertragsende: '01.10.2027', vertragsende_paragraph: '§ 11' }
const k02 = renderMail('K02', values, {})
assert.deepEqual(k02.problems, [])
assert.equal(k02.subject, 'Bestätigung Ihrer Kündigung – WEBklar Hosting-Abo')
assert.match(k02.text, /Kündigung des WEBklar Hosting-Abos vom 01\.10\.2026, 10:00 Uhr/)
assert.doesNotMatch(k02.text, /Stundenkontingent|Abo verwalten|Wartungs-Abo/)
const k02w = renderMail('K02', { ...values, produkt: 'Wartungs-Abo' }, { wartung: true })
assert.match(k02w.text, /Nicht genutzte Zeit aus dem Stundenkontingent entfällt mit Vertragsende\./)
assert.equal(renderMail('K03', { ...sampleValues(loadTemplate('K03')), produkt: 'Wartungs-Abo' }, {}).subject, 'Eingangsbestätigung Ihrer Kündigung – WEBklar Wartungs-Abo')
assert.equal(renderMail('K04', { ...sampleValues(loadTemplate('K04')), produkt: 'Hosting-Abo' }, {}).subject, 'Eingangsbestätigung Ihres Widerrufs – WEBklar Hosting-Abo')
for (const id of ['T05', 'T06', 'T07', 'T08', 'T09']) {
const tpl = loadTemplate(id)
assert.match(renderMail(id, { ...sampleValues(tpl), produkt: 'Hosting-Abo' }, allBlocks(tpl, true)).text, /^Abo: Hosting-Abo$/m, id)
}
})
// F36: die Folgen des Widerrufs je Bestellung wie in den Hinweisen zum Widerruf der Fassung 4
test('K04: Folgen des Widerrufs je Bestellung (Abschluss, Wechsel, Zusatzspeicher, wir prüfen)', () => {
const values = { name: 'K', produkt: 'Hosting-Abo', bestellung: 'Abschluss Ihres Abos', eingang_zeitpunkt: '01.10.2026, 10:00', email: 'k@example.invalid', kundennummer: '' }
const abschluss = renderMail('K04', values, { abschluss: true })
assert.deepEqual(abschluss.problems, [])
assert.match(abschluss.text, /keine weiteren Abbuchungen/)
assert.match(abschluss.text, /Wertersatz/)
assert.match(abschluss.text, /Zusatzspeicher[^.]*erstatten wir vollständig; Wertersatz verlangen wir dafür nicht/)
const wechsel = renderMail('K04', { ...values, bestellung: 'Wechsel ins Wartungs-Abo' }, { wechsel: true }).text
assert.match(wechsel, /Hosting-Abo läuft zu den bisherigen Bedingungen weiter, als hätte es den Wechsel nicht gegeben, einschließlich seiner ursprünglichen Mindestlaufzeit/)
assert.match(wechsel, /Mehrbetrag/)
assert.match(wechsel, /abzüglich des Wertersatzes/)
assert.match(wechsel, /§ 357a Abs\. 2 BGB/)
const speicher = renderMail('K04', { ...values, bestellung: 'E-Mail-Zusatzspeicher' }, { zusatzspeicher: true }).text
assert.match(speicher, /Menge vor der Bestellung/)
assert.match(speicher, /erstatten wir vollständig/)
assert.match(speicher, /Wertersatz verlangen wir nicht/)
assert.doesNotMatch(speicher, /abzüglich/)
assert.match(renderMail('K04', values, { pruefen: true }).text, /prüfen, ob für diese Bestellung eine Widerrufsfrist läuft/)
})
test('K12: Monatspreis mit gebuchtem Zusatzspeicher (Block), Widerruf des Wechsels wie in Fassung 4', () => {
const values = sampleValues(loadTemplate('K12'))
const mit = renderMail('K12', values, { zusatzspeicher: true })
assert.deepEqual(mit.problems, [])
assert.match(mit.text, /einschließlich Ihres gebuchten E-Mail-Zusatzspeichers/)
const ohne = renderMail('K12', values, {})
assert.doesNotMatch(ohne.text, /einschließlich Ihres gebuchten/)
assert.match(ohne.text, /Widerrufen Sie den Wechsel, läuft Ihr Hosting-Abo zu den bisherigen Bedingungen weiter/)
assert.match(ohne.text, /Mehrbetrag/)
// K13: Widerruf des Zusatzspeichers ohne Wertersatz
const k13 = renderMail('K13', sampleValues(loadTemplate('K13')), { plus: true, bestellung: true })
assert.match(k13.text, /erstatten alles, was Sie dafür gezahlt haben\. Wertersatz verlangen wir nicht\./)
})
// T09: die Handgriffe je Bestellung passen zu den Hinweisen zum Widerruf (Zusatzspeicher ohne Wertersatz)
test('T09: Handgriffe je Bestellung ohne Wertersatz beim Zusatzspeicher', () => {
const t09 = { ...sampleValues(loadTemplate('T09')), produkt: 'Hosting-Abo' }
const speicher = renderMail('T09', t09, { abo_gefunden: true, zusatzspeicher: true }).text
assert.match(speicher, /vollständig erstatten/)
assert.doesNotMatch(speicher, /Wertersatz für die Tage abziehen/)
const wechsel = renderMail('T09', t09, { abo_gefunden: true, wechsel: true }).text
assert.match(wechsel, /Mehrbetrag/)
assert.match(wechsel, /Wertersatz/)
const abschluss = renderMail('T09', t09, { abo_gefunden: true, abschluss: true }).text
assert.match(abschluss, /Zusatzspeicher[^.]*vollständig/)
})
// Task 12, Fix round 1: K01 je Fassung (v3-Zeile „1 E-Mail-Postfach · 1 Stunde Arbeitszeit pro Monat“ im Block wartung_v3),
// Zustimmung einschließlich AVV; K12 „Heute fällig“ nennt den Zusatzspeicher nur im Block; T07 nennt die Arbeitszeit nur bei Wartung
test('K01: Block wartung_v3 mit der Zeile der Fassung 3, wartung mit der Fassung 4, Zustimmung einschließlich AVV', () => {
const base = { name: 'K', produkt: 'Wartungs-Abo', preis: '50,00 € / Monat', 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' }
assert.ok(loadTemplate('K01').blocks.includes('wartung_v3'), 'Block wartung_v3 deklariert')
const v3 = renderMail('K01', base, { wartung_v3: true, sofortbeginn: true })
assert.deepEqual(v3.problems, [])
assert.match(v3.text, /• 1 E-Mail-Postfach/)
assert.match(v3.text, /• 1 Stunde Arbeitszeit pro Monat/)
assert.match(v3.text, /Dort sehen Sie jederzeit Ihr aktuelles Stundenkontingent\./)
assert.doesNotMatch(v3.text, /beliebig viele|je bezahltem Monat|Zusatzspeicher/)
assert.match(v3.text, /Ihre Zustimmung zu den Vertragsbedingungen \(Version 2026-09-25-v3\) einschließlich des Auftragsverarbeitungsvertrags haben wir am 01\.10\.2026, 10:07 Uhr erhalten\./)
const v4 = renderMail('K01', { ...base, bedingungen_version: '2026-10-05-v4' }, { wartung: true, sofortbeginn: true })
assert.match(v4.text, /• beliebig viele Postfächer/)
assert.match(v4.text, /1 Stunde Arbeitszeit je bezahltem Monat/)
assert.doesNotMatch(v4.text, /1 E-Mail-Postfach|pro Monat/)
const h = renderMail('K01', { ...base, produkt: 'Hosting-Abo', preis: '20,00 € / Monat' }, { hosting: true, sofortbeginn: true })
assert.doesNotMatch(h.text, /Stundenkontingent|Stunde Arbeitszeit/)
})
test('K12: „Heute fällig“ nennt den Zusatzspeicher nur im Block zusatzspeicher', () => {
const values = sampleValues(loadTemplate('K12'))
assert.match(renderMail('K12', values, { zusatzspeicher: true }).text, /Monatsgebühr des Wartungs-Abos mit gebuchtem Zusatzspeicher, abzüglich der Gutschrift/)
const ohne = renderMail('K12', values, {}).text
assert.match(ohne, /Monatsgebühr des Wartungs-Abos, abzüglich der Gutschrift/)
assert.doesNotMatch(ohne, /mit gebuchtem Zusatzspeicher/)
})
test('T07: Hinweis auf nicht genutzte Arbeitszeit nur im Block wartung', () => {
const values = { produkt: 'Wartungs-Abo', email: 'k@example.invalid', stripe_abo_id: 'sub_1', status: 'canceled', vertragsende_paragraph: '§ 11' }
assert.ok(loadTemplate('T07').blocks.includes('wartung'), 'Block wartung deklariert')
const w = renderMail('T07', values, { wartung: true })
assert.deepEqual(w.problems, [])
assert.match(w.text, /Nicht genutzte Arbeitszeit aus dem Stundenkontingent verfällt mit dem Vertragsende\. Gebuchter Zusatzspeicher endet mit dem Abo\./)
const h = renderMail('T07', { ...values, produkt: 'Hosting-Abo' }, {})
assert.deepEqual(h.problems, [])
assert.match(h.text, /Gebuchter Zusatzspeicher endet mit dem Abo\./)
assert.doesNotMatch(h.text, /Arbeitszeit|Stundenkontingent|Restguthaben/)
})
// Etappe 4: das Admin-Panel ist abgeschaltet, Team-Mails fuehren ins Ticketsystem
const TEAM_FOOTER = ['T01', 'T02', 'T03', 'T04', 'T05', 'T06', 'T07', 'T08', 'T09', 'T10', 'T11', 'T12', 'T13', 'T14', 'T16', 'T19', 'T20']
test('keine Vorlage verlinkt admin.html oder nennt den Admin-Bereich', () => {
for (const id of EXPECTED) {
const source = readFileSync(new URL(loadTemplate(id).file, DIR), 'utf8')
assert.ok(!source.includes('admin.html'), `${id}: admin.html`)
assert.ok(!source.includes('Admin-Bereich'), `${id}: Admin-Bereich`)
}
})
test('Fusszeile der Team-Mails: Ticketsystem', () => {
for (const id of TEAM_FOOTER) {
const tpl = loadTemplate(id)
const mail = renderMail(id, sampleValues(tpl), allBlocks(tpl, true))
assert.match(mail.html, /Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https:\/\/ticket\.webklar\.com\/customers"[^>]*>Ticketsystem<\/a>/, id)
}
})
test('T01: Knopf antwortet im Ticketsystem beim Kunden (team_link)', () => {
const mail = renderMail('T01', { kunde_name: 'Berta', nachricht: 'Hallo', team_link: 'https://ticket.webklar.com/customers/chat?kunde=c1' })
assert.deepEqual(mail.problems, [])
assert.match(mail.html, /<a href="https:\/\/ticket\.webklar\.com\/customers\/chat\?kunde=c1"[^>]*>Im Ticketsystem antworten &rarr;<\/a>/)
})
// ---------------------------------------------------------------------------
// Terminplaner (Task 9): G01–G08 an Gäste, K20 und K21 an den Betrieb
// ---------------------------------------------------------------------------
const sourceOf = (id) => readFileSync(new URL(loadTemplate(id).file, DIR), 'utf8')
const FOOTER_SENTENCE = 'Diese E-Mail wurde im Auftrag von Wert-betrieb_name über den Terminplaner von WEBklar versendet.'
// Sie-Formen und Du-Formen (der Rechtstext der Widerrufsbelehrung ist ein Platzhalterwert und kommt hier nicht vor)
const SIE_FORM = /(^|[^\p{L}])(Sie|Ihr\p{L}*|Ihnen)(?![\p{L}-])/u
const DU_FORM = /(^|[^\p{L}])(du|dein\p{L}*|dir|dich)(?![\p{L}-])/iu
const anrede = (tpl, sie, extra = {}) => ({ ...allBlocks(tpl, true), ...extra, sie, du: !sie })
test('G-Vorlagen: Betreff ist {{betreff}}, Blöcke sie und du, Platzhalter für Farbe und Betriebsfuß', () => {
for (const id of GUEST) {
const tpl = loadTemplate(id)
assert.equal(tpl.subject, '{{betreff}}', id)
for (const block of ['sie', 'du']) assert.ok(tpl.blocks.includes(block), `${id}: Block ${block}`)
for (const p of ['betreff', 'farbe', 'betrieb_name', 'betrieb_adresse', 'betrieb_telefon', 'betrieb_email', 'impressum_url', 'datenschutz_url']) {
assert.ok(tpl.placeholders.includes(p), `${id}: ${p}`)
}
}
})
test('G-Vorlagen: neutrales Layout, Kopf in der Betriebsfarbe, Fuß mit Betrieb, Links und Satz über den Terminplaner', () => {
for (const id of GUEST) {
const tpl = loadTemplate(id)
const source = sourceOf(id)
assert.match(source, /<td[^>]*style="[^"]*background:\{\{farbe\}\}/, `${id}: Kopfbalken in {{farbe}}`)
assert.doesNotMatch(source, /<img|webklar-logo|project\.webklar\.com/i, `${id}: kein Bild, kein WEBklar-Logo, keine Portal-Adresse`)
assert.doesNotMatch(source, /https?:\/\/(?!www\.w3\.org)|google|tracking|pixel/i, `${id}: keine festen Adressen, keine Werbung, kein Tracking`)
for (const on of [true, false]) {
const mail = renderMail(id, sampleValues(tpl), { ...allBlocks(tpl, on), sie: true, du: false })
assert.deepEqual(mail.problems, [], id)
assert.ok(mail.text.includes(FOOTER_SENTENCE), `${id}: Fußsatz\n${mail.text}`)
for (const wert of ['Wert-betrieb_adresse', 'Wert-betrieb_telefon', 'Wert-betrieb_email']) assert.ok(mail.text.includes(wert), `${id}: ${wert}`)
assert.match(mail.html, /href="Wert-impressum_url"[^>]*>Impressum</, id)
assert.match(mail.html, /href="Wert-datenschutz_url"[^>]*>Datenschutz</, id)
assert.match(mail.html, /background:Wert-farbe/, id)
}
}
})
test('G-Vorlagen: jeder Anredetext steht in sie und du, Du-Mail ohne Sie-Formen und Sie-Mail ohne Du-Formen', () => {
for (const id of GUEST) {
const tpl = loadTemplate(id)
const du = renderMail(id, sampleValues(tpl), anrede(tpl, false))
const sie = renderMail(id, sampleValues(tpl), anrede(tpl, true))
assert.deepEqual([du.problems, sie.problems], [[], []], id)
assert.doesNotMatch(du.text, SIE_FORM, `${id}: Du-Fassung enthält Sie-Form\n${du.text}`)
assert.doesNotMatch(du.text, / Sie | Ihr /, id)
assert.doesNotMatch(sie.text, DU_FORM, `${id}: Sie-Fassung enthält Du-Form\n${sie.text}`)
assert.match(sie.text, SIE_FORM, `${id}: Sie-Fassung spricht den Gast an`)
assert.match(du.text, DU_FORM, `${id}: Du-Fassung spricht den Gast an`)
assert.notEqual(du.text, sie.text, id)
// ohne gesetzte Anrede erscheint kein Anredetext (der Server setzt genau einen der beiden Blöcke)
const neither = renderMail(id, sampleValues(tpl), { ...allBlocks(tpl, true), sie: false, du: false })
assert.doesNotMatch(neither.text, SIE_FORM, id)
assert.doesNotMatch(neither.text, DU_FORM, id)
}
})
test('G02: Du-Mail enthält im Text kein „ Sie “ und kein „Ihr “', () => {
const tpl = loadTemplate('G02')
const mail = renderMail('G02', sampleValues(tpl), { ...allBlocks(tpl, true), sie: false, du: true })
assert.deepEqual(mail.problems, [])
assert.doesNotMatch(mail.text, / Sie |Ihr /)
assert.doesNotMatch(mail.html, / Sie | Ihr /)
})
test('G01 und G08 enthalten keinen vom Gast eingegebenen Text (kein Platzhalter gast_name)', () => {
for (const id of ['G01', 'G08']) {
assert.ok(!loadTemplate(id).placeholders.includes('gast_name'), `${id}: Platzhalter`)
assert.ok(!sourceOf(id).includes('gast_name'), `${id}: Quelltext`)
}
// ... die anderen Gästemails nennen den Gast beim Namen
for (const id of ['G02', 'G03', 'G04', 'G05', 'G06', 'G07']) assert.ok(loadTemplate(id).placeholders.includes('gast_name'), id)
})
test('G-Vorlagen: Platzhalter und Blöcke wie im Plan', () => {
const common = ['betreff', 'farbe', 'betrieb_name', 'betrieb_adresse', 'betrieb_telefon', 'betrieb_email', 'impressum_url', 'datenschutz_url']
const expected = {
G01: { p: ['leistung', 'datum', 'zeit', 'link'], b: ['bestaetigen', 'zu_viele'] },
G02: { p: ['gast_name', 'leistung', 'person', 'datum', 'zeit', 'dauer', 'preis', 'zahlung', 'nummer', 'link', 'ort', 'storno_text', 'widerrufsbelehrung', 'widerruf_link'], b: ['preis', 'person', 'storno', 'widerruf', 'kein_widerruf', 'anfrage_angenommen'] },
G03: { p: ['gast_name', 'leistung', 'datum', 'zeit', 'link'], b: [] },
G04: { p: ['gast_name', 'leistung', 'datum', 'zeit', 'nachricht'], b: ['nachricht'] },
G05: { p: ['gast_name', 'leistung', 'person', 'datum', 'zeit', 'link'], b: ['person'] },
G06: { p: ['gast_name', 'leistung', 'datum', 'zeit', 'absage_von', 'grund', 'nachricht'], b: ['grund', 'nachricht'] },
G07: { p: ['gast_name', 'leistung', 'alt_datum', 'alt_zeit', 'datum', 'zeit', 'link'], b: [] },
G08: { p: ['eingang', 'nummer'], b: ['zugeordnet', 'nach_frist'] },
}
for (const [id, { p, b }] of Object.entries(expected)) {
const tpl = loadTemplate(id)
assert.deepEqual([...tpl.placeholders].sort(), [...common, ...p].sort(), `${id}: Platzhalter`)
assert.deepEqual([...tpl.blocks].sort(), ['du', 'sie', ...b].sort(), `${id}: Blöcke`)
}
})
test('G01: Block bestaetigen mit Angaben und Knopf, Block zu_viele nur mit eigenem Text und ohne Knopf', () => {
const tpl = loadTemplate('G01')
const values = { ...sampleValues(tpl), betrieb_name: 'Salon Muster', betrieb_telefon: '06301 1234', leistung: 'Herrenschnitt', datum: 'Montag, 12.10.2026', zeit: '10:00', link: 'https://project.webklar.com/termin.html#v=abc' }
const sie = renderMail('G01', values, { sie: true, bestaetigen: true })
assert.deepEqual(sie.problems, [])
assert.match(sie.text, /Leistung: Herrenschnitt/)
assert.match(sie.text, /Datum: Montag, 12\.10\.2026/)
assert.match(sie.text, /Uhrzeit: 10:00 Uhr/)
assert.match(sie.text, /Termin bestätigen: https:\/\/project\.webklar\.com\/termin\.html#v=abc/)
assert.match(sie.text, /Das waren Sie nicht\? Dann ignorieren Sie diese E-Mail\./)
assert.doesNotMatch(sie.text, /höchstmögliche Zahl|rufen Sie uns an/)
assert.match(renderMail('G01', values, { du: true, bestaetigen: true }).text, /Das warst du nicht\? Dann ignoriere diese E-Mail\./)
// zu_viele: nichts reserviert, also weder Terminangaben noch Knopf noch Link
const viele = renderMail('G01', values, { sie: true, zu_viele: true })
assert.deepEqual(viele.problems, [])
assert.match(viele.text, /bereits die höchstmögliche Zahl offener Termine/)
assert.match(viele.text, /sagen Sie einen Ihrer offenen Termine ab/)
assert.match(viele.text, /rufen Sie uns an: 06301 1234\./)
assert.doesNotMatch(viele.text, /Herrenschnitt|12\.10\.2026|Uhrzeit|Termin bestätigen|termin\.html|ignorieren Sie den Knopf|Knopf/)
assert.doesNotMatch(viele.html, /termin\.html|Herrenschnitt/)
assert.match(renderMail('G01', values, { du: true, zu_viele: true }).text, /ruf uns an: 06301 1234\./)
// ohne gesetzten Block bleibt nur der gemeinsame Rahmen
const leer = renderMail('G01', values, { sie: true })
assert.doesNotMatch(leer.text, /Herrenschnitt|höchstmögliche Zahl|Termin bestätigen/)
})
test('G02: Zusammenfassung, Verwaltungslink, Preis, Person, Storno und Widerruf nur mit ihren Blöcken', () => {
const tpl = loadTemplate('G02')
const belehrung = 'Widerrufsbelehrung\n\nWiderrufsrecht: 14 Tage <b>ohne</b> Gründe.\nZweite Zeile & mehr'
const values = {
...sampleValues(tpl),
gast_name: 'Erika Muster', leistung: 'Herrenschnitt', person: 'Anna', datum: 'Montag, 12.10.2026', zeit: '10:00', dauer: '30 Minuten',
preis: '28,00 € inkl. USt', zahlung: 'Bezahlung vor Ort', nummer: 'AB12CD34', link: 'https://project.webklar.com/termin.html#m=tok', ort: 'Hauptstr. 1, 67722 Winnweiler',
storno_text: 'Absage bis 24 Stunden vorher kostenlos.', widerrufsbelehrung: belehrung, widerruf_link: 'https://project.webklar.com/widerrufen.html#m=tok',
}
const basis = renderMail('G02', values, { sie: true })
assert.deepEqual(basis.problems, [])
for (const zeile of ['Guten Tag Erika Muster,', 'Leistung: Herrenschnitt', 'Datum: Montag, 12.10.2026', 'Uhrzeit: 10:00 Uhr', 'Dauer: 30 Minuten', 'Ort: Hauptstr. 1, 67722 Winnweiler', 'Buchungsnummer: AB12CD34']) {
assert.ok(basis.text.split('\n').includes(zeile), `Zeile fehlt: ${zeile}\n${basis.text}`)
}
assert.match(basis.text, /Termin verwalten: https:\/\/project\.webklar\.com\/termin\.html#m=tok/)
assert.match(basis.text, /Ihr Termin ist bestätigt/)
assert.doesNotMatch(basis.text, /28,00|Bezahlung vor Ort|Anna|Absage bis 24 Stunden|Widerruf|angenommen/)
const voll = renderMail('G02', values, { sie: true, preis: true, person: true, storno: true, widerruf: true, anfrage_angenommen: true })
assert.deepEqual(voll.problems, [])
assert.match(voll.text, /Preis: 28,00 € inkl\. USt/)
assert.match(voll.text, /Zahlung: Bezahlung vor Ort/)
assert.match(voll.text, /Anna/)
assert.match(voll.text, /Absage bis 24 Stunden vorher kostenlos\./)
assert.match(voll.text, /Wert-betrieb_name hat Ihre Terminanfrage angenommen/)
assert.match(voll.text, /Vertrag widerrufen: https:\/\/project\.webklar\.com\/widerrufen\.html#m=tok/)
// Ruling R55/Abschlussrunde: der Knopf geht nur bis zum Beginn des Termins, danach Formular auf der Website oder E-Mail
const WEG = 'Bis zum Beginn des Termins geht das über den Knopf „Vertrag widerrufen“; danach über das Formular „Vertrag widerrufen“ auf der Website von Wert-betrieb_name oder per E-Mail an Wert-betrieb_email.'
assert.ok(voll.text.includes(WEG), voll.text)
assert.doesNotMatch(voll.text, /jederzeit den Knopf/)
// Belehrung wörtlich, Zeilenumbrüche bleiben, Sonderzeichen sind maskiert
assert.match(voll.html, /Widerrufsrecht: 14 Tage &lt;b&gt;ohne&lt;\/b&gt; Gründe\.<br>Zweite Zeile &amp; mehr/)
assert.ok(voll.text.includes('Widerrufsbelehrung\n\nWiderrufsrecht: 14 Tage <b>ohne</b> Gründe.\nZweite Zeile & mehr'), voll.text)
assert.doesNotMatch(voll.text, /kein Widerrufsrecht/, 'widerruf und kein_widerruf schließen sich aus')
// der Wortlaut der Belehrung steht in einem abgesetzten Kasten
assert.match(voll.html, /class="box"[^>]*><tr><td[^>]*>Widerrufsbelehrung<br><br>Widerrufsrecht/)
// Freizeitausnahme: Hinweis statt Widerrufsrecht
const kein = renderMail('G02', values, { sie: true, kein_widerruf: true })
assert.deepEqual(kein.problems, [])
assert.ok(kein.text.includes('Für diese Buchung besteht kein Widerrufsrecht (§ 312g Abs. 2 Nr. 9 BGB). Absagen richten sich nach den Stornobedingungen.'), kein.text)
assert.doesNotMatch(kein.text, /Vertrag widerrufen|Widerrufsbelehrung|Muster-Widerrufsformular/)
const keinDu = renderMail('G02', values, { du: true, kein_widerruf: true })
assert.ok(keinDu.text.includes('Für diese Buchung besteht kein Widerrufsrecht (§ 312g Abs. 2 Nr. 9 BGB). Absagen richten sich nach den Stornobedingungen.'), keinDu.text)
assert.doesNotMatch(keinDu.text, SIE_FORM)
assert.doesNotMatch(basis.text, /kein Widerrufsrecht/)
const du = renderMail('G02', values, { du: true, anfrage_angenommen: true, widerruf: true })
assert.match(du.text, /Dein Termin ist bestätigt/)
assert.match(du.text, /hat deine Terminanfrage angenommen/)
assert.ok(du.text.includes(WEG), du.text)
assert.doesNotMatch(du.text, /jederzeit/)
assert.doesNotMatch(du.text, SIE_FORM)
})
test('G03, G04, G05, G06, G07: Inhalt je Mail', () => {
const g03 = renderMail('G03', { ...sampleValues(loadTemplate('G03')), gast_name: 'Erika', betrieb_name: 'Salon Muster', link: 'https://x.example/m' }, { sie: true })
assert.match(g03.text, /Salon Muster prüft sie und meldet sich bei Ihnen/)
assert.match(g03.text, /Anfrage absagen: https:\/\/x\.example\/m/)
const t04 = loadTemplate('G04')
const g04 = renderMail('G04', { ...sampleValues(t04), nachricht: 'Am Montag sind wir leider im Urlaub.' }, { sie: true })
assert.doesNotMatch(g04.text, /Urlaub/)
assert.match(g04.text, /leider kann Wert-betrieb_name Ihre Terminanfrage nicht annehmen/)
assert.match(renderMail('G04', { ...sampleValues(t04), nachricht: 'Am Montag sind wir leider im Urlaub.' }, { sie: true, nachricht: true }).text, /Am Montag sind wir leider im Urlaub\./)
const t05 = loadTemplate('G05')
const g05 = renderMail('G05', { ...sampleValues(t05), person: 'Anna', link: 'https://x.example/m' }, { du: true, person: true })
assert.match(g05.text, /Anna/)
assert.match(g05.text, /Termin verwalten: https:\/\/x\.example\/m/)
assert.match(g05.text, /Erinnerung/)
assert.doesNotMatch(renderMail('G05', { ...sampleValues(t05), person: 'Anna' }, { du: true }).text, /Anna/)
const t06 = loadTemplate('G06')
const werte06 = { ...sampleValues(t06), absage_von: 'dem Betrieb', grund: 'Krankheit im Team', nachricht: 'Wir melden uns wegen eines neuen Termins.' }
const g06 = renderMail('G06', werte06, { sie: true })
assert.match(g06.text, /Abgesagt von: dem Betrieb/)
assert.doesNotMatch(g06.text, /Krankheit|Grund:|neuen Termins/)
const g06grund = renderMail('G06', werte06, { sie: true, grund: true })
assert.match(g06grund.text, /Grund: Krankheit im Team/)
assert.doesNotMatch(g06grund.text, /neuen Termins/)
const g06nachricht = renderMail('G06', werte06, { sie: true, nachricht: true })
assert.match(g06nachricht.text, /Nachricht von Wert-betrieb_name\s+Wir melden uns wegen eines neuen Termins\./)
assert.doesNotMatch(g06nachricht.text, /Krankheit/)
const beide = renderMail('G06', werte06, { sie: true, grund: true, nachricht: true })
assert.deepEqual(beide.problems, [])
assert.match(beide.text, /Krankheit im Team/)
assert.match(beide.text, /neuen Termins/)
const t07 = loadTemplate('G07')
const g07 = renderMail('G07', { ...sampleValues(t07), alt_datum: '12.10.2026', alt_zeit: '10:00', datum: '13.10.2026', zeit: '11:30' }, { du: true })
assert.match(g07.text, /Bisher: 12\.10\.2026, 10:00 Uhr/)
assert.match(g07.text, /Neu: 13\.10\.2026, 11:30 Uhr/)
})
test('G08: Eingangsbestätigung mit Datum und Uhrzeit, Blöcke zugeordnet und nach_frist, nichts vom Gast', () => {
const tpl = loadTemplate('G08')
const values = { ...sampleValues(tpl), betrieb_name: 'Salon Muster', eingang: '02.10.2026, 10:07 Uhr', nummer: 'AB12CD34' }
const ohne = renderMail('G08', values, { sie: true })
assert.deepEqual(ohne.problems, [])
assert.match(ohne.text, /Eingegangen am: 02\.10\.2026, 10:07 Uhr/)
assert.match(ohne.text, /Widerruf/)
assert.doesNotMatch(ohne.text, /AB12CD34|bereits abgelaufen/)
const zugeordnet = renderMail('G08', values, { sie: true, zugeordnet: true })
assert.match(zugeordnet.text, /Buchung AB12CD34/)
assert.match(zugeordnet.text, /Der Termin ist abgesagt\./)
assert.doesNotMatch(zugeordnet.text, /bereits abgelaufen/)
assert.doesNotMatch(zugeordnet.text, /keiner Buchung zuordnen/, 'zugeordnet widerspricht sich nicht selbst')
const spaet = renderMail('G08', values, { sie: true, nach_frist: true })
assert.match(spaet.text, /Nach unseren Unterlagen war die Widerrufsfrist bei Eingang Ihrer Erklärung bereits abgelaufen\./)
assert.doesNotMatch(spaet.text, /Der Termin ist abgesagt|AB12CD34|keiner Buchung zuordnen/)
// der Satz für alle Fälle ist in jedem Zustand wahr, in beiden Anreden
for (const blocks of [{}, { zugeordnet: true }, { nach_frist: true }]) {
assert.match(renderMail('G08', values, { sie: true, ...blocks }).text, /Salon Muster prüft Ihre Erklärung und meldet sich bei Ihnen, falls noch etwas zu klären ist\./)
assert.match(renderMail('G08', values, { du: true, ...blocks }).text, /Salon Muster prüft deine Erklärung und meldet sich bei dir, falls noch etwas zu klären ist\./)
}
assert.match(renderMail('G08', values, { du: true, nach_frist: true }).text, /Nach unseren Unterlagen war die Widerrufsfrist bei Eingang deiner Erklärung bereits abgelaufen\./)
// Hinweis für Dritte: wer nichts erklärt hat, kann die Mail ignorieren
assert.match(ohne.text, /Waren Sie das nicht, können Sie diese Nachricht ignorieren\./)
})
// Terminplaner TP2 (E3): K22 und K23 stehen in Du-Form (K02/K03 bleiben Sie-Form wie ihr Rest)
test('K22, K23: Du-Form fest, kein Sie und kein Ihr, auch mit allen Blöcken', () => {
for (const id of ['K22', 'K23']) {
const tpl = loadTemplate(id)
assert.ok(!tpl.blocks.includes('sie') && !tpl.blocks.includes('du'), `${id}: Du-Form fest, keine Anredeblöcke`)
const mail = renderMail(id, sampleValues(tpl), allBlocks(tpl, true))
assert.doesNotMatch(mail.text, SIE_FORM, `${id}: Du-Form\n${mail.text}`)
assert.match(mail.text, /Hallo Wert-kunde_name,/, id)
assert.match(mail.text, DU_FORM, `${id}: spricht den Kunden mit Du an`)
}
})
test('K20 und K21: WEBklar-Layout in Du-Form, Gastdaten maskiert, Blöcke je Fall', () => {
for (const id of ['K20', 'K21']) {
const tpl = loadTemplate(id)
assert.equal(tpl.subject, '{{betreff}}', id)
for (const p of ['betreff', 'kunde_name', 'leistung', 'person', 'datum', 'zeit', 'gast_name', 'gast_email', 'gast_telefon', 'gast_nachricht', 'portal_link']) assert.ok(tpl.placeholders.includes(p), `${id}: ${p}`)
assert.ok(!tpl.blocks.includes('sie') && !tpl.blocks.includes('du'), `${id}: Du-Form fest, keine Anredeblöcke`)
const mail = renderMail(id, sampleValues(tpl), allBlocks(tpl, true))
assert.doesNotMatch(mail.text, SIE_FORM, `${id}: Du-Form\n${mail.text}`)
assert.match(mail.text, /Hallo Wert-kunde_name,/, id)
assert.match(mail.html, /<a href="Wert-portal_link"/, id)
assert.doesNotMatch(sourceOf(id), /google|tracking|pixel/i, id)
}
assert.deepEqual([...loadTemplate('K20').blocks], ['anfrage'])
assert.deepEqual([...loadTemplate('K21').blocks].sort(), ['abgesagt', 'begonnen', 'nach_frist', 'nicht_zugeordnet', 'verschoben', 'widerrufen'])
for (const p of ['alt_datum', 'alt_zeit', 'eingang']) assert.ok(loadTemplate('K21').placeholders.includes(p), `K21: ${p}`)
const t20 = loadTemplate('K20')
const werte20 = { ...sampleValues(t20), kunde_name: 'Berta', leistung: 'Herrenschnitt', datum: '12.10.2026', zeit: '10:00', gast_name: 'Max <b>Muster</b>', gast_email: 'max@example.com', gast_telefon: '0170 1', gast_nachricht: 'Bitte\nFenster', portal_link: 'https://project.webklar.com/dashboard.html' }
const buchung = renderMail('K20', werte20, {})
assert.deepEqual(buchung.problems, [])
assert.match(buchung.html, /Max &lt;b&gt;Muster&lt;\/b&gt;/)
assert.match(buchung.html, /Bitte<br>Fenster/)
assert.match(buchung.text, /E-Mail: max@example\.com/)
assert.match(buchung.text, /Im Portal ansehen: https:\/\/project\.webklar\.com\/dashboard\.html/)
assert.doesNotMatch(buchung.text, /noch nicht bestätigt/)
assert.match(renderMail('K20', werte20, { anfrage: true }).text, /Diese Anfrage ist noch nicht bestätigt/)
const t21 = loadTemplate('K21')
const werte21 = { ...sampleValues(t21), alt_datum: '11.10.2026', alt_zeit: '09:00', datum: '12.10.2026', zeit: '10:00', eingang: '02.10.2026, 10:07 Uhr' }
const fall = (block) => renderMail('K21', werte21, { [block]: true })
assert.match(fall('abgesagt').text, /hat den Termin abgesagt/)
assert.match(fall('verschoben').text, /Bisher: 11\.10\.2026, 09:00 Uhr/)
assert.match(fall('verschoben').text, /Neu: 12\.10\.2026, 10:00 Uhr/)
assert.match(fall('widerrufen').text, /Eingegangen am: 02\.10\.2026, 10:07 Uhr/)
assert.match(fall('widerrufen').text, /widerrufen/)
assert.match(fall('nach_frist').text, /Nach unseren Unterlagen war die Widerrufsfrist bei Eingang bereits abgelaufen/)
assert.match(fall('begonnen').text, /Ein Widerruf ist eingegangen, nachdem der Termin bereits begonnen hatte\. Bitte prüfe, ob das Widerrufsrecht schon erloschen ist \(vollständige Leistung nach Zustimmung zum vorzeitigen Beginn\) oder ob Wertersatz in Frage kommt\./)
assert.match(fall('begonnen').text, /Eingegangen am: 02\.10\.2026, 10:07 Uhr/)
assert.match(fall('nicht_zugeordnet').text, /keiner Buchung zuordnen/)
// je Fall erscheint nur der eigene Abschnitt
const SATZ = { abgesagt: 'hat den Termin abgesagt', verschoben: 'hat den Termin verschoben', widerrufen: 'hat den Vertrag widerrufen', nach_frist: 'Nach unseren Unterlagen war die Widerrufsfrist bei Eingang bereits abgelaufen', begonnen: 'nachdem der Termin bereits begonnen hatte', nicht_zugeordnet: 'keiner Buchung zuordnen' }
for (const block of Object.keys(SATZ)) {
const mail = fall(block)
assert.deepEqual(mail.problems, [], block)
for (const [anderer, satz] of Object.entries(SATZ)) assert.equal(mail.text.includes(satz), anderer === block, `${block}: ${satz}`)
}
})