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

1358 lines
95 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); T21 Neue Webshop-Anfrage (Webshop B1, Team-Hinweis); K24 Webshop-Anfrage entschieden (Webshop B2, Kunde); G09–G14 Käufermails des Webshops (Bestellbestätigung, versendet, abholbereit, Eingang Widerruf, Erstattung, Zahlung fehlgeschlagen; Webshop E4, eigener Fuß statt Terminplaner-Fuß, Sie-Form) und K25, K26 an den Händler (neue Bestellung, Widerruf; Webshop E4); K27–K30 Öffnungszeiten live (eingeschaltet, Feiertage prüfen, Abo beendet, abgeschaltet; Öffnungszeiten live Task 13); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9); K33–K37 und T23 Buchungskalender (K36 und K37 Neue Anfrage und Neue Buchung an den Vermieter, T23 Team-Hinweis; zuvor K31, K32, T22, die den Aufträgen gehören); K31, K32 und T22 Auftragsbestätigung (Aufträge und Rechnungen, Strang L5; K31 und K32 in Sie-Form, T22 mit [Aufträge]); K42, K43 Zusage-Mails ohne Preis (Kundenansicht P6: Webshop- und Buchungskalender-Anfrage, Ersatz für K24 und K34 im Abo-Modell); K38–K41 und T24 Funktionen im Abo-Modell (Kundenansicht P6: K38 wird eingerichtet, K39 beendet, K40 und K41 Öffnungszeiten live Abo-Ende und abgeschaltet ohne Preis, T24 Team-Hinweis; Du-Form, Test in aboFunktionenVorlagen.test.js)
const ids = (prefix, count) => Array.from({ length: count }, (_, i) => `${prefix}${String(i + 1).padStart(2, '0')}`)
const EXPECTED = [...ids('G', 22), ...ids('K', 43), ...ids('T', 24)]
// GUEST = Gästemails des Terminplaners (gemeinsamer Fuß, Anrede-Blöcke); SHOP_GUEST = Käufermails des Webshops (G09–G14, siehe unten)
const GUEST = ids('G', 8)
const SHOP_GUEST = ['G09', 'G10', 'G11', 'G12', 'G13', 'G14', 'G15']
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), [Terminplaner] (Betriebsmeldung) oder [Webshop] (neue Anfrage)
assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz|Zeit|Finanzen|Terminplaner|Webshop|Aufträge|Buchungskalender)\]/.test(tpl.subject) || id === 'T01' || id === 'T16' || id === 'T17', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz], [Zeit], [Finanzen], [Terminplaner], [Webshop], [Aufträge] oder [Buchungskalender] (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}`)
})
// Webshop B1: Team-Hinweis auf eine neue Anfrage (Versand ueber sendAdminNotice, services/shop/anfrage.js)
test('T21: Betreff [Webshop], Platzhalter der Anfrage, keine Blöcke, Knopf zum Kunden im Ticketsystem', () => {
const tpl = loadTemplate('T21')
assert.equal(tpl.file, 'T21 Neue Webshop-Anfrage.html')
assert.equal(tpl.subject, '[Webshop] Neue Anfrage: {{kunde_name}}')
assert.deepEqual(tpl.placeholders, ['kunde_name', 'email', 'projekt', 'warenart', 'beschreibung', 'artikelzahl', 'lieferung', 'nachricht', 'woms_url'])
assert.deepEqual(tpl.blocks, [])
const mail = renderMail('T21', { ...sampleValues(tpl), beschreibung: 'Zeile 1\nZeile <2>', woms_url: 'https://ticket.webklar.com/customers/c1?tab=funktionen' })
assert.deepEqual(mail.problems, [])
assert.equal(mail.subject, '[Webshop] Neue Anfrage: Wert-kunde_name')
assert.match(mail.html, /Zeile 1<br>Zeile &lt;2&gt;/)
assert.match(mail.html, /<a href="https:\/\/ticket\.webklar\.com\/customers\/c1\?tab=funktionen"/)
})
// 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}}')
// F8: fassung, lb_url, avv_url, widerruf_url, zustimmungen_text und die Blöcke anlagen (mit anhang) und zustimmungen für Funktionen mit Rechtsdokumenten
assert.deepEqual(k15.placeholders, ['kunde_name', 'projekt', 'funktion', 'funktion_gen', 'funktion_akk', 'funktion_nom', 'funktion_pron', 'preis', 'preis_hinweis', 'zeit', 'danach', 'portal_url', 'rechnung_url', 'fassung', 'lb_url', 'avv_url', 'widerruf_url', 'zustimmungen_text'])
assert.deepEqual(k15.blocks, ['rechnung', 'behalten', 'neu', 'anlagen', 'anhang', 'zustimmungen'])
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, neu: 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/)
// Vertragsunterlagen (F8): anlagen mit den drei versionierten Adressen, der PDF-Satz nur mit anhang, die Zustimmungen mehrzeilig
const werte = { ...sampleValues(k15), fassung: '2026-10-05', lb_url: 'https://p.example/rechtliches/lb-2026-10-05.html', avv_url: 'https://p.example/rechtliches/avv-2026-10-05.html', widerruf_url: 'https://p.example/rechtliches/w-2026-10-05.html', zustimmungen_text: 'Du hast zugestimmt:\n– Erstens\n– Zweitens' }
const aus = renderMail('K15', werte, { rechnung: false, anlagen: false, anhang: false, zustimmungen: false })
assert.doesNotMatch(aus.text, /Vertragsunterlagen|Zustimmungen|Fassung 2026|hängen als PDF/)
const ohneAnhang = renderMail('K15', werte, { anlagen: true, anhang: false, zustimmungen: false })
assert.deepEqual(ohneAnhang.problems, [])
for (const part of ['Deine Vertragsunterlagen', 'Fassung 2026-10-05: Leistungsbeschreibung (https://p.example/rechtliches/lb-2026-10-05.html)', 'Auftragsverarbeitungsvertrag (https://p.example/rechtliches/avv-2026-10-05.html)', 'Widerrufsbelehrung mit Muster-Widerrufsformular (https://p.example/rechtliches/w-2026-10-05.html)']) {
assert.ok(ohneAnhang.text.includes(part), `K15 anlagen enthält: ${part}\n---\n${ohneAnhang.text}`)
}
assert.doesNotMatch(ohneAnhang.text, /hängen als PDF|Deine Zustimmungen/)
const mitAnhang = renderMail('K15', werte, { anlagen: true, anhang: true, zustimmungen: true })
assert.match(mitAnhang.text, /Alle drei hängen als PDF an dieser E-Mail\. Bitte bewahre sie auf\./)
assert.ok(mitAnhang.text.includes('Deine Zustimmungen\nDu hast zugestimmt:\n– Erstens\n– Zweitens'), mitAnhang.text)
// anhang steht nur innerhalb von anlagen
assert.doesNotMatch(renderMail('K15', werte, { anlagen: false, anhang: true }).text, /hängen als PDF/)
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', 'abo', 'team', 'anhang_fehlt'])
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: '', produkt: 'Terminplaner', preis: '149 €', 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', 'projekt', 'projekt_unternehmen'])
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, T12 und K29 (Abo beendet, Öffnungszeiten live)', () => {
for (const id of templateIds()) if (!['K12', 'K14', 'T12', 'K29'].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/)
})
// F9: Widerruf je Funktion - T09 kennt preis, die Blöcke bleiben unverändert; K04/T09 nennen Produkt und Folgen der Funktion
test('F9: T09 deklariert preis, Blöcke von K04 und T09 wie festgelegt (dazu je der Block projekt aus Strang L5)', () => {
assert.ok(loadTemplate('T09').placeholders.includes('preis'))
assert.ok(loadTemplate('T09').placeholders.includes('produkt'))
assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'pruefen', 'projekt', 'projekt_unternehmen'])
assert.deepEqual(loadTemplate('T09').blocks, [
'kundennummer', 'abo_gefunden', 'auf_kuendigung_gesetzt', 'kein_abo_gefunden', 'kuendigung_fehlgeschlagen', 'abschluss', 'wechsel',
'zusatzspeicher', 'funktion', 'keine_frist', 'nachweis_fehlt', 'wechsel_offen', 'abschluss_verspaetet', 'nicht_pruefbar', 'bereits_widerrufen', 'projekt',
])
})
test('F9: K04 und T09 beim Widerruf einer Funktion (begonnene Einrichtung, Wertersatz, Preis, Handgriffe)', () => {
const k04 = { name: 'K', produkt: 'Terminplaner', bestellung: 'Terminplaner (Einrichtung)', eingang_zeitpunkt: '01.10.2026, 10:00', email: 'k@example.invalid', kundennummer: '' }
const kunde = renderMail('K04', k04, { funktion: true })
assert.deepEqual(kunde.problems, [])
assert.equal(kunde.subject, 'Eingangsbestätigung Ihres Widerrufs – WEBklar Terminplaner')
assert.match(kunde.text, /Widerruf der Bestellung „Terminplaner \(Einrichtung\)“/)
assert.match(kunde.text, /Einrichtung auf Ihren ausdrücklichen Wunsch schon vor Ablauf der Widerrufsfrist begonnen/)
assert.match(kunde.text, /Wertersatz[^.]*\(§ 357a Abs\. 2 BGB\)/)
assert.doesNotMatch(kunde.text, /abgeschlossen/)
assert.match(kunde.text, /Sie erhalten dazu eine gesonderte Abrechnung/)
const t09 = { ...sampleValues(loadTemplate('T09')), produkt: 'Terminplaner', preis: '149 €', ereignisse: 'Studio Anker: in Einrichtung' }
const team = renderMail('T09', t09, { funktion: true })
assert.deepEqual(team.problems, [])
assert.match(team.text, /Widerruf einer Funktion \(Terminplaner, 149 € einmalig\)/)
assert.match(team.text, /NICHTS automatisch geändert/)
assert.match(team.text, /innerhalb von 14 Tagen erstatten/)
assert.match(team.text, /abzüglich Wertersatz für den begonnenen Einbau \(Aufwand im Arbeitsblatt, ⚖ Q5\)/)
assert.match(team.text, /Reiter „Funktionen“/)
assert.match(team.text, /Beim Terminplaner bleibt das Wartungs-Abo unberührt/)
assert.match(team.text, /Studio Anker: in Einrichtung/)
})
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', 'T21', 'T22', 'T24']
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); Öffnungszeiten live: K27–K30 ebenso
test('K22, K23, K27–K30: Du-Form fest, kein Sie und kein Ihr, auch mit allen Blöcken', () => {
for (const id of ['K22', 'K23', 'K27', 'K28', 'K29', 'K30']) {
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}`)
}
})
// Webshop B2: K24 meldet dem Kunden die Entscheidung des Teams über seine Webshop-Anfrage (Block angenommen oder abgelehnt)
const K24_WERTE = { kunde_name: 'Erika', projekt: 'Töpferei', preis: '299 €', ust_hinweis: 'ohne Umsatzsteuer gemäß § 19 UStG', begruendung: 'Wir verkaufen keine <Waffen>.\nBitte melde Dich.', portal_url: 'https://portal.example/dashboard.html#webshop?projekt=p1' }
test('K24: Betreff, Platzhalter und Blöcke stehen in der Vorlage', () => {
const tpl = loadTemplate('K24')
assert.equal(tpl.file, 'K24 Webshop-Anfrage.html')
assert.equal(tpl.subject, 'Deine Webshop-Anfrage für {{projekt}}')
assert.deepEqual(tpl.placeholders, ['kunde_name', 'projekt', 'preis', 'ust_hinweis', 'begruendung', 'portal_url'])
assert.deepEqual(tpl.blocks, ['angenommen', 'abgelehnt'])
})
test('K24 angenommen: Preis mit Umsatzsteuerhinweis, Knopf ins Portal, keine Begründung', () => {
const mail = renderMail('K24', K24_WERTE, { angenommen: true, abgelehnt: false })
assert.deepEqual(mail.problems, [])
assert.equal(mail.subject, 'Deine Webshop-Anfrage für Töpferei')
assert.match(mail.text, /Hallo Erika/)
assert.match(mail.text, /angenommen/)
assert.match(mail.text, /299 €/)
assert.match(mail.text, /§ 19 UStG/)
assert.doesNotMatch(mail.text, /Waffen|Begründung|leider/)
assert.match(mail.html, /href="https:\/\/portal\.example\/dashboard\.html#webshop\?projekt=p1"/)
})
test('K24 abgelehnt: nennt die Begründung (maskiert, Zeilenumbruch), keinen Preis', () => {
const mail = renderMail('K24', K24_WERTE, { angenommen: false, abgelehnt: true })
assert.deepEqual(mail.problems, [])
assert.match(mail.text, /leider/)
assert.match(mail.text, /Wir verkaufen keine <Waffen>\./)
assert.match(mail.html, /Wir verkaufen keine &lt;Waffen&gt;\.<br>Bitte melde Dich\./)
assert.doesNotMatch(mail.text, /299 €|UStG|angenommen/)
})
// ---------------------------------------------------------------------------
// Webshop E4: Käufermails G09–G14 (Sie-Form, Betreff in der Vorlage, Fuß mit Anbieter und Widerrufsfunktion)
// und K25, K26 an den Händler (Du-Form)
// ---------------------------------------------------------------------------
const SHOP_GEMEIN = ['farbe', 'betrieb_name', 'anbieter_zeile', 'impressum_url', 'datenschutz_url', 'widerruf_url']
test('G09–G15: Platzhalter, Blöcke und Betreff wie im Plan (G-Vorlagen des Webshops haben keine Anrede-Blöcke)', () => {
const erwartet = {
G09: {
s: 'Ihre Bestellung {{bestellnummer}} bei {{betrieb_name}}',
p: ['bestellnummer', 'datum', 'zahlungsdatum', 'positionen', 'zwischensumme', 'versand', 'gesamt', 'steuer_hinweis', 'lieferart_text', 'lieferadresse', 'abholort', 'lieferzeit', 'zahlungsart', 'ausschluss_text', 'agb', 'widerrufsbelehrung', 'widerrufsformular', 'gewaehrleistung'],
b: ['versand', 'abholung', 'ausschluss'],
},
G10: { s: 'Ihre Bestellung {{bestellnummer}} wurde versendet', p: ['bestellnummer', 'sendungsnummer', 'versanddienst'], b: ['sendung'] },
G11: { s: 'Ihre Bestellung {{bestellnummer}} ist abholbereit', p: ['bestellnummer', 'abholort', 'hinweis'], b: [] },
G12: { s: 'Eingangsbestätigung Ihres Widerrufs bei {{betrieb_name}}', p: ['name', 'bestellnummer', 'eingang', 'nachricht'], b: ['nachricht'] },
G13: { s: 'Erstattung zu Ihrer Bestellung {{bestellnummer}}', p: ['bestellnummer', 'betrag', 'grund'], b: ['grund'] },
G14: { s: 'Ihre Bestellung {{bestellnummer}} wurde nicht abgeschlossen', p: ['bestellnummer'], b: [] },
G15: { s: 'Wir haben Ihre Bestellung {{bestellnummer}} erhalten', p: ['bestellnummer', 'datum', 'positionen', 'gesamt'], b: [] },
}
assert.deepEqual(Object.keys(erwartet), SHOP_GUEST)
for (const [id, { s, p, b }] of Object.entries(erwartet)) {
const tpl = loadTemplate(id)
assert.equal(tpl.subject, s, id)
assert.deepEqual([...tpl.placeholders].sort(), [...p, ...SHOP_GEMEIN].sort(), `${id}: Platzhalter`)
assert.deepEqual([...tpl.blocks].sort(), [...b].sort(), `${id}: Blöcke`)
}
})
test('G09–G15: neutrales Layout in der Shop-Farbe, kein Logo, Fuß mit Anbieter, Impressum, Datenschutz und Widerrufsfunktion', () => {
for (const id of SHOP_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, kein Tracking`)
for (const on of [true, false]) {
const mail = renderMail(id, sampleValues(tpl), allBlocks(tpl, on))
assert.deepEqual(mail.problems, [], id)
assert.ok(mail.text.includes('Wert-anbieter_zeile'), `${id}: Anbieter im Fuß`)
assert.ok(mail.text.includes('Diese E-Mail wurde im Auftrag von Wert-betrieb_name über den Webshop von WEBklar versendet.'), `${id}: Fußsatz`)
assert.match(mail.html, /href="Wert-impressum_url"[^>]*>Impressum</, id)
assert.match(mail.html, /href="Wert-datenschutz_url"[^>]*>Datenschutz</, id)
assert.match(mail.html, /href="Wert-widerruf_url"[^>]*>Vertrag widerrufen</, id)
assert.match(mail.html, /background:Wert-farbe/, id)
assert.doesNotMatch(mail.text, DU_FORM, `${id}: Sie-Form, nicht Du\n${mail.text}`)
}
}
})
const G_WERTE = { bestellnummer: '26-K7QM2X', betrieb_name: 'Töpferei <Muster>', farbe: '#112233', anbieter_zeile: 'Töpferei Muster, Hauptstr. 1, 12345 Musterstadt, info@example.com', impressum_url: 'https://shop.example/impressum', datenschutz_url: 'https://shop.example/datenschutz', widerruf_url: 'https://project.webklar.com/shop/sk/widerruf' }
const g = (id, werte, blocks = {}) => renderMail(id, { ...G_WERTE, ...werte }, blocks)
const G09_WERTE = {
datum: '05.10.2026',
zahlungsdatum: '07.10.2026',
positionen: '2 × Tasse (blau) – 24,00 €\n1 × Teller – 12,50 €',
zwischensumme: '36,50 €',
versand: '4,90 €',
gesamt: '41,40 €',
steuer_hinweis: 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.',
lieferart_text: 'Versand nach Deutschland',
lieferadresse: 'Erika Beispiel\nMusterweg 3\n12345 Musterstadt',
abholort: 'Werkstatt, Hauptstr. 1',
lieferzeit: '2–4 Werktage',
zahlungsart: 'Kreditkarte',
ausschluss_text: 'Teller: Vom Widerruf ausgeschlossen: nach Kundenwunsch angefertigt',
agb: 'AGB-Text Zeile 1\nAGB-Text Zeile 2',
widerrufsbelehrung: 'Widerrufsbelehrung Zeile 1\nZeile 2',
widerrufsformular: 'An Töpferei Muster\nHiermit widerrufe ich',
gewaehrleistung: 'GESETZLICHE GEWÄHRLEISTUNG\n\nMindestens zwei Jahre',
}
test('G09 Versand: Betreff mit Bestellnummer und Shop, Positionen, Summen, Lieferadresse, alle Rechtstexte, Links', () => {
const mail = g('G09', G09_WERTE, { versand: true, abholung: false, ausschluss: true })
assert.deepEqual(mail.problems, [])
assert.equal(mail.subject, 'Ihre Bestellung 26-K7QM2X bei Töpferei <Muster>')
assert.match(mail.html, /Töpferei &lt;Muster&gt;/, 'Shopname maskiert')
assert.match(mail.html, /2 × Tasse \(blau\) – 24,00 €<br>1 × Teller – 12,50 €/)
assert.match(mail.html, /Erika Beispiel<br>Musterweg 3<br>12345 Musterstadt/)
for (const teil of ['26-K7QM2X', '05.10.2026', '36,50 €', '4,90 €', '41,40 €', '§ 19 UStG', 'Versand nach Deutschland', '2–4 Werktage', 'Kreditkarte', 'Vom Widerruf ausgeschlossen: nach Kundenwunsch angefertigt', 'AGB-Text Zeile 2', 'Widerrufsbelehrung Zeile 1', 'Hiermit widerrufe ich', 'GESETZLICHE GEWÄHRLEISTUNG', 'Hinweis zur gesetzlichen Gewährleistung', 'Muster-Widerrufsformular', 'Allgemeine Geschäftsbedingungen']) {
assert.ok(mail.text.includes(teil), teil)
}
// I-1 b: ausdrückliche Annahme, Bestelldatum und Zahlungsdatum getrennt
assert.match(mail.text, /Mit dieser E-Mail nehmen wir Ihre Bestellung an\. Der Kaufvertrag ist damit geschlossen\./)
assert.match(mail.text, /Bestelldatum: 05\.10\.2026/)
assert.match(mail.text, /Zahlungsdatum: 07\.10\.2026/)
assert.match(mail.text, /Lieferadresse: Erika Beispiel/)
assert.doesNotMatch(mail.text, /Abholort|Werkstatt, Hauptstr/)
assert.match(mail.html, /href="https:\/\/project\.webklar\.com\/shop\/sk\/widerruf"/)
})
test('G09 Abholung ohne Ausschluss: Abholort statt Lieferadresse, kein Widerrufs-Ausschluss', () => {
const mail = g('G09', { ...G09_WERTE, lieferart_text: 'Abholung', versand: 'Abholung – kostenlos' }, { versand: false, abholung: true, ausschluss: false })
assert.deepEqual(mail.problems, [])
assert.match(mail.text, /Abholort: Werkstatt, Hauptstr\. 1/)
assert.doesNotMatch(mail.text, /Lieferadresse|Musterweg|Hinweis zum Widerrufsrecht|ausgeschlossen/)
assert.match(mail.text, /Abholung – kostenlos/)
})
test('G15: Eingang ohne Vertragsschluss - Lastschrift in Bearbeitung, Bestellbestätigung folgt, Positionen und Gesamt', () => {
const mail = g('G15', { datum: '05.10.2026', positionen: '2 × Tasse (blau) – 24,00 €', gesamt: '41,40 €' })
assert.deepEqual(mail.problems, [])
assert.equal(mail.subject, 'Wir haben Ihre Bestellung 26-K7QM2X erhalten')
assert.equal(loadTemplate('G15').file, 'G15 Bestellung eingegangen.html')
for (const teil of ['26-K7QM2X', '05.10.2026', '2 × Tasse (blau) – 24,00 €', '41,40 €', 'Ihre Zahlung per Lastschrift ist noch in Bearbeitung. Sobald sie bei uns eingegangen ist, erhalten Sie die Bestellbestätigung; erst damit kommt der Kaufvertrag zustande.']) {
assert.ok(mail.text.includes(teil), teil)
}
assert.doesNotMatch(mail.text, /Kaufvertrag ist damit geschlossen|Widerrufsbelehrung|Allgemeine Geschäftsbedingungen/)
})
test('G10: Sendungsnummer und Versanddienst nur im Block sendung', () => {
const mit = g('G10', { sendungsnummer: '0034<1>', versanddienst: 'DHL' }, { sendung: true })
assert.deepEqual(mit.problems, [])
assert.equal(mit.subject, 'Ihre Bestellung 26-K7QM2X wurde versendet')
assert.match(mit.text, /Versanddienst: DHL/)
assert.match(mit.text, /Sendungsnummer: 0034<1>/)
assert.match(mit.html, /0034&lt;1&gt;/)
const ohne = g('G10', { sendungsnummer: '', versanddienst: '' }, {})
assert.deepEqual(ohne.problems, [])
assert.doesNotMatch(ohne.text, /Sendungsnummer|Versanddienst/)
assert.match(ohne.text, /versendet/)
})
test('G11: Abholort und Hinweis', () => {
const mail = g('G11', { abholort: 'Werkstatt, Hauptstr. 1', hinweis: 'Di–Fr 10–16 Uhr' })
assert.deepEqual(mail.problems, [])
assert.equal(mail.subject, 'Ihre Bestellung 26-K7QM2X ist abholbereit')
assert.match(mail.text, /Abholort: Werkstatt, Hauptstr\. 1/)
assert.match(mail.text, /Hinweis: Di–Fr 10–16 Uhr/)
})
test('G12: Eingang mit Datum und Uhrzeit, Erklärung nur im Block nachricht, Händler meldet sich', () => {
const werte = { name: 'Erika <E>', bestellnummer: '26-ZZZZZZ', eingang: '05.10.2026, 14:03 Uhr', nachricht: 'Bitte stornieren.\nDanke' }
const mit = g('G12', werte, { nachricht: true })
assert.deepEqual(mit.problems, [])
assert.equal(mit.subject, 'Eingangsbestätigung Ihres Widerrufs bei Töpferei <Muster>')
assert.match(mit.text, /Guten Tag Erika <E>,/)
assert.match(mit.text, /Eingegangen am: 05\.10\.2026, 14:03 Uhr/)
assert.match(mit.text, /Bestellnummer: 26-ZZZZZZ/)
assert.match(mit.html, /Bitte stornieren\.<br>Danke/)
assert.match(mit.text, /meldet sich bei Ihnen/)
const ohne = g('G12', werte, {})
assert.doesNotMatch(ohne.text, /Bitte stornieren|Ihre Erklärung:/)
})
test('G13: Betrag, Grund nur im Block, Gutschrift über die ursprüngliche Zahlungsart', () => {
const mit = g('G13', { betrag: '12,50 €', grund: 'Artikel beschädigt' }, { grund: true })
assert.deepEqual(mit.problems, [])
assert.equal(mit.subject, 'Erstattung zu Ihrer Bestellung 26-K7QM2X')
assert.match(mit.text, /Erstatteter Betrag: 12,50 €/)
assert.match(mit.text, /Grund: Artikel beschädigt/)
assert.match(mit.text, /Die Gutschrift erfolgt über die ursprüngliche Zahlungsart\./)
assert.doesNotMatch(g('G13', { betrag: '12,50 €', grund: 'x' }, {}).text, /Grund:/)
})
test('G14: nichts berechnet, kein Betrag', () => {
const mail = g('G14', {})
assert.deepEqual(mail.problems, [])
assert.equal(mail.subject, 'Ihre Bestellung 26-K7QM2X wurde nicht abgeschlossen')
assert.match(mail.text, /wurde nicht abgeschlossen; es wurde nichts berechnet\./)
})
const K25_WERTE = { kunde_name: 'Erika', shopname: 'Töpferei', bestellnummer: '26-K7QM2X', gesamt: '41,40 €', positionen: '2 × Tasse – 24,00 €\n1 × Teller – 12,50 €', lieferart_text: 'Versand nach Deutschland', kaeufer_name: 'Max <M>', portal_url: 'https://portal.example/dashboard.html#webshop?projekt=p1' }
test('K25: Betreff, Platzhalter, Blöcke; Bestand-Warnung und Lastschrift-Hinweis nur im Block', () => {
const tpl = loadTemplate('K25')
assert.equal(tpl.file, 'K25 Neue Bestellung.html')
assert.equal(tpl.subject, 'Neue Bestellung {{bestellnummer}} in Deinem Webshop')
assert.deepEqual([...tpl.placeholders].sort(), Object.keys(K25_WERTE).sort())
assert.deepEqual(tpl.blocks, ['ueberverkauft', 'sepa'])
const normal = renderMail('K25', K25_WERTE, {})
assert.deepEqual(normal.problems, [])
assert.equal(normal.subject, 'Neue Bestellung 26-K7QM2X in Deinem Webshop')
assert.match(normal.text, /Hallo Erika/)
assert.match(normal.text, /Käufer: Max <M>/)
assert.match(normal.html, /2 × Tasse – 24,00 €<br>1 × Teller – 12,50 €/)
assert.match(normal.html, /href="https:\/\/portal\.example\/dashboard\.html#webshop\?projekt=p1"/)
assert.doesNotMatch(normal.text, /Achtung|Lastschrift/)
const voll = renderMail('K25', K25_WERTE, { ueberverkauft: true, sepa: true })
assert.match(voll.text, /Achtung: Bestand reichte nicht/)
assert.match(voll.text, /Zahlung per Lastschrift/)
assert.doesNotMatch(renderMail('K25', K25_WERTE, { sepa: true }).text, /Achtung/)
assert.doesNotMatch(renderMail('K25', K25_WERTE, { ueberverkauft: true }).text, /Lastschrift/)
})
const K26_WERTE = { kunde_name: 'Erika', shopname: 'Töpferei', bestellnummer: '26-K7QM2X', kaeufer_name: 'Max <M>', kaeufer_email: 'max@example.com', eingang: '05.10.2026, 14:03 Uhr', nachricht: 'Ich widerrufe.\nBitte melden', portal_url: 'https://portal.example/dashboard.html#webshop?projekt=p1' }
test('K26: Betreff, Platzhalter, Erklärung nur im Block, Hinweis auf die 14 Tage', () => {
const tpl = loadTemplate('K26')
assert.equal(tpl.file, 'K26 Widerruf im Webshop.html')
assert.equal(tpl.subject, 'Widerruf in Deinem Webshop eingegangen')
assert.deepEqual([...tpl.placeholders].sort(), Object.keys(K26_WERTE).sort())
assert.deepEqual(tpl.blocks, ['nachricht'])
const mit = renderMail('K26', K26_WERTE, { nachricht: true })
assert.deepEqual(mit.problems, [])
assert.match(mit.text, /Hallo Erika/)
assert.match(mit.text, /Eingegangen am: 05\.10\.2026, 14:03 Uhr/)
assert.match(mit.text, /E-Mail: max@example\.com/)
assert.match(mit.html, /Ich widerrufe\.<br>Bitte melden/)
assert.match(mit.text, /innerhalb von 14 Tagen/)
assert.doesNotMatch(renderMail('K26', K26_WERTE, {}).text, /Ich widerrufe|Erklärung:/)
})
// ---------------------------------------------------------------------------
// Buchungskalender (Task 20): G16–G22 an Gäste (Sie-Form, neutral), K33, K34, K36 und K37 und T23 (Du-Form, WEBklar)
// ---------------------------------------------------------------------------
const UK_GAST = ['G16', 'G17', 'G18', 'G19', 'G20', 'G21', 'G22']
const UK_BASIS = ['gast_name', 'unterkunft', 'zeitraum', 'naechte', 'personen', 'nummer', 'firma', 'farbe', 'anbieter_kontakt']
const UK_ERWARTET = {
G16: { p: [], b: [] },
G17: { p: ['posten_text', 'gesamt', 'frist', 'buchungs_link', 'stornobedingungen', 'nachricht_vermieter'], b: ['nachricht'] },
G18: { p: ['nachricht_vermieter'], b: ['nachricht'] },
G19: { p: ['posten_text', 'gesamt', 'zahlart_text', 'rechnung_nummer', 'stornobedingungen'], b: ['online_unbezahlt'] },
G20: { p: ['gesamt', 'rechnung_nummer'], b: [] },
G21: { p: ['frist'], b: [] },
G22: { p: ['rechnung_nummer', 'erstattung', 'nachricht_vermieter'], b: ['erstattung', 'nachricht'] },
}
test('G16–G22: Platzhalter und Blöcke, Sie-Form, kein WEBklar-Logo, keine festen Adressen', () => {
for (const id of UK_GAST) {
const tpl = loadTemplate(id)
assert.deepEqual([...tpl.placeholders].sort(), [...UK_BASIS, ...UK_ERWARTET[id].p].sort(), `${id}: Platzhalter`)
assert.deepEqual([...tpl.blocks].sort(), [...UK_ERWARTET[id].b].sort(), `${id}: Blöcke`)
assert.doesNotMatch(sourceOf(id), /<img|webklar-logo|project\.webklar\.com/i, `${id}: neutral`)
for (const on of [true, false]) {
const mail = renderMail(id, sampleValues(tpl), allBlocks(tpl, on))
assert.deepEqual(mail.problems, [], id)
assert.doesNotMatch(mail.text, DU_FORM, `${id}: Sie-Form\n${mail.text}`)
assert.match(mail.html, /background:Wert-farbe/, id)
}
}
})
test('G17: Preis, Frist, Stornobedingungen, Knopf und der Satz zur verbindlichen Buchung', () => {
const mail = renderMail('G17', sampleValues(loadTemplate('G17')), { nachricht: true })
for (const w of ['Wert-posten_text', 'Wert-gesamt', 'Wert-frist', 'Wert-stornobedingungen']) assert.ok(mail.text.includes(w), w)
assert.match(mail.html, /href="Wert-buchungs_link"[^>]*>Jetzt verbindlich buchen/)
assert.ok(mail.text.includes('Erst mit dem Klick auf „Zahlungspflichtig buchen“ auf der Buchungsseite ist Ihre Buchung verbindlich.'))
})
test('G19: dauerhafter Datenträger mit allen Angaben, Hinweis kein Widerrufsrecht, Bezahl-Hinweis nur im Block', () => {
const tpl = loadTemplate('G19')
const aus = renderMail('G19', sampleValues(tpl), { online_unbezahlt: false })
const an = renderMail('G19', sampleValues(tpl), { online_unbezahlt: true })
for (const w of ['Wert-posten_text', 'Wert-gesamt', 'Wert-zahlart_text', 'Wert-stornobedingungen', 'Wert-anbieter_kontakt', 'Wert-rechnung_nummer']) assert.ok(aus.text.includes(w), w)
assert.match(aus.text, /kein gesetzliches Widerrufsrecht/)
assert.ok(!aus.text.includes('Bezahl-Link'))
assert.ok(an.text.includes('Den Bezahl-Link finden Sie auf der Buchungsseite aus unserer vorigen E-Mail.'))
})
test('G22: Erstattung „wird veranlasst“ nur im Block', () => {
const tpl = loadTemplate('G22')
assert.ok(renderMail('G22', { ...sampleValues(tpl), erstattung: '50,00 €' }, { erstattung: true }).text.includes('Eine Erstattung von 50,00 € wird veranlasst.'))
assert.ok(!renderMail('G22', sampleValues(tpl), { erstattung: false }).text.includes('Erstattung von'))
})
test('K33–K37, T23: Dateien, Platzhalter, Du-Form', () => {
const erwartet = {
K36: ['kunde_name', 'gast_name', 'gast_email', 'unterkunft', 'zeitraum', 'naechte', 'personen', 'nummer', 'nachricht_gast', 'portal_url'],
K37: ['kunde_name', 'gast_name', 'unterkunft', 'zeitraum', 'naechte', 'personen', 'nummer', 'gesamt', 'zahlart_text', 'portal_url'],
K33: ['kunde_name', 'unterkunft', 'quelle', 'seit', 'portal_url'],
K34: ['kunde_name', 'projekt', 'preis', 'ust_hinweis', 'begruendung', 'portal_url'],
K35: ['kunde_name', 'projekt', 'loeschdatum', 'portal_url'],
T23: ['kunde_name', 'email', 'projekt', 'nachricht', 'woms_url'],
}
for (const [id, p] of Object.entries(erwartet)) {
const tpl = loadTemplate(id)
assert.deepEqual([...tpl.placeholders].sort(), [...p].sort(), id)
const mail = renderMail(id, sampleValues(tpl), allBlocks(tpl, true))
assert.deepEqual(mail.problems, [], id)
}
assert.equal(loadTemplate('K37').file, 'K37 Neue Buchung.html')
assert.equal(loadTemplate('K35').file, 'K35 Buchungskalender abgeschaltet.html')
assert.equal(loadTemplate('K35').subject, 'Buchungskalender abgeschaltet – bitte sichere Deine Rechnungen')
assert.equal(loadTemplate('T23').subject, '[Buchungskalender] Neue Anfrage: {{kunde_name}}')
})