Öffnungszeiten: Mails K24 bis K27

Neue Kundenmails (Du-Form, Skelett von K23):
- K24 eingeschaltet (Blöcke angestossen/manuell nach einbau, wieder nach der Abo-Rückkehr)
- K25 Feiertage prüfen (Blöcke pruefen/mehrere, ohne BCC)
- K26 Abo beendet (abo_ende/hosting/wechsel_widerrufen, Frist, einmalig 30 € behalten)
- K27 abgeschaltet (feste Zeiten, Eingaben bis zum Löschdatum, Wiedereinschalten)

server/services/zeiten/mails.js: Builder k24Mail bis k27Mail und sendeZeitenMail
(wirft nie, ohne Adresse { sent:false, reason:'keine_adresse' }, Log ohne Adresse und Namen).

K15 bekommt den Block behalten (Gegenstück neu für den Einrichtungstext; bestellMail
setzt neu = nicht behalten, Standard unverändert), T16 die Blöcke abo und team
(sendT16 führt sie als Vorgabe aus). Pins in mailTemplates- und featureMails-Tests
angepasst; K26 darf „Wartungs-Abo“ im Betreff führen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 01:33:04 +00:00
1 parent d1f9176c99
commit 0042d8dd8f
11 files changed
+760 -22

No files matched your search

+4 -4
View File
@@ -502,7 +502,7 @@ test('Platzhalter-/Blocklisten exakt: K15 und K16', () => {
'portal_url',
'rechnung_url',
])
assert.deepEqual(k15.blocks, ['rechnung'])
assert.deepEqual(k15.blocks, ['rechnung', 'behalten', 'neu'])
assert.equal(k15.subject, 'Deine Bestellung: {{funktion}}')
const k16 = loadTemplate('K16')
assert.deepEqual(k16.placeholders, ['kunde_name', 'projekt', 'funktion', 'funktion_dein', 'funktion_nom', 'bereit', 'knopf', 'portal_url'])
@@ -514,7 +514,7 @@ test('Platzhalter-/Blocklisten exakt: T16 und T19', () => {
const t16 = loadTemplate('T16')
assert.equal(t16.subject, 'Neue Funktion bestellt: {{funktion}}')
assert.deepEqual(t16.placeholders, ['kunde_name', 'email', 'projekt', 'funktion', 'woid', 'sitekey', 'sitzung', 'zustand', 'anleitung_url', 'gesundheit_text'])
assert.deepEqual(t16.blocks, ['zahlung_fehlgeschlagen', 'ungeklaert', 'kein_abo', 'bereits_aktiv', 'anleitung', 'gesundheit'])
assert.deepEqual(t16.blocks, ['zahlung_fehlgeschlagen', 'ungeklaert', 'kein_abo', 'bereits_aktiv', 'anleitung', 'gesundheit', 'abo', 'team'])
const t19 = loadTemplate('T19')
assert.equal(t19.file, 'T19 Erstattung faellig.html')
assert.equal(t19.subject, '[Abo] Erstattung fällig: {{funktion}}-Einrichtung')
@@ -533,8 +533,8 @@ test('Die Mail-Werte je Funktion decken genau die Platzhalter der Vorlagen ab',
assert.equal(k16.template, 'K16')
assert.deepEqual(Object.keys(k15.values).sort(), [...loadTemplate('K15').placeholders].sort(), `${key}: K15`)
assert.deepEqual(Object.keys(k16.values).sort(), [...loadTemplate('K16').placeholders].sort(), `${key}: K16`)
assert.deepEqual(k15.blocks, { rechnung: true })
assert.deepEqual(sf.bestellMail(feature, { ...ctx, invoiceUrl: '' }).blocks, { rechnung: false })
assert.deepEqual(k15.blocks, { rechnung: true, behalten: false, neu: true })
assert.deepEqual(sf.bestellMail(feature, { ...ctx, invoiceUrl: '' }).blocks, { rechnung: false, behalten: false, neu: true })
for (const nachricht of [k15, k16]) {
const mail = renderMail(nachricht.template, nachricht.values, nachricht.blocks)
assert.deepEqual(mail.problems, [], `${key}: ${nachricht.template}`)
+10 -10
View File
@@ -6,9 +6,9 @@ import { contractAttachmentFor, termsDocFor } from '../server/services/products.
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)
// 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); K24–K27 Öffnungszeiten live (eingeschaltet, Feiertage prüfen, Abo beendet, abgeschaltet; Öffnungszeiten live Task 13); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b)
const ids = (prefix, count) => Array.from({ length: count }, (_, i) => `${prefix}${String(i + 1).padStart(2, '0')}`)
const EXPECTED = [...ids('G', 8), ...ids('K', 23), ...ids('T', 19), 'T20']
const EXPECTED = [...ids('G', 8), ...ids('K', 27), ...ids('T', 19), 'T20']
const GUEST = ids('G', 8)
const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`]))
@@ -75,14 +75,14 @@ test('K15, K16: Platzhalter und Blöcke je Funktion, keine Funktion fest im Text
const k15 = loadTemplate('K15')
assert.equal(k15.subject, 'Deine Bestellung: {{funktion}}')
assert.deepEqual(k15.placeholders, ['kunde_name', 'projekt', 'funktion', 'funktion_gen', 'funktion_akk', 'funktion_nom', 'funktion_pron', 'preis', 'preis_hinweis', 'zeit', 'danach', 'portal_url', 'rechnung_url'])
assert.deepEqual(k15.blocks, ['rechnung'])
assert.deepEqual(k15.blocks, ['rechnung', 'behalten', 'neu'])
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 })
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']) {
@@ -101,7 +101,7 @@ test('K15, K16: Platzhalter und Blöcke je Funktion, keine Funktion fest im Text
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'])
assert.deepEqual(t16.blocks, ['zahlung_fehlgeschlagen', 'ungeklaert', 'kein_abo', 'bereits_aktiv', 'anleitung', 'gesundheit', 'abo', 'team'])
const t19 = loadTemplate('T19')
assert.equal(t19.file, 'T19 Erstattung faellig.html')
assert.match(t19.subject, /^\[Abo\] Erstattung fällig: \{\{funktion\}\}-Einrichtung$/)
@@ -511,8 +511,8 @@ test('T02 und T07 erinnern an UptimeRobot', () => {
assert.ok(t07.text.includes('Monitor in UptimeRobot entfernen'), t07.text)
})
test('Keine Vorlage nennt fest „Wartungs-Abo“ im Betreff außer K12, K14 und T12', () => {
for (const id of templateIds()) if (!['K12', 'K14', 'T12'].includes(id)) assert.ok(!loadTemplate(id).subject.includes('Wartungs-Abo'), id)
test('Keine Vorlage nennt fest „Wartungs-Abo“ im Betreff außer K12, K14, T12 und K26 (Abo beendet, Öffnungszeiten live)', () => {
for (const id of templateIds()) if (!['K12', 'K14', 'T12', 'K26'].includes(id)) assert.ok(!loadTemplate(id).subject.includes('Wartungs-Abo'), id)
})
test('K01-Anhang: Fassung 3 für v3-Abos, Fassung 4 sonst', () => {
@@ -918,9 +918,9 @@ test('G08: Eingangsbestätigung mit Datum und Uhrzeit, Blöcke zugeordnet und na
assert.match(ohne.text, /Waren Sie das nicht, können Sie diese Nachricht ignorieren\./)
})
// Terminplaner TP2 (E3): K22 und K23 stehen in Du-Form (K02/K03 bleiben Sie-Form wie ihr Rest)
test('K22, K23: Du-Form fest, kein Sie und kein Ihr, auch mit allen Blöcken', () => {
for (const id of ['K22', 'K23']) {
// Terminplaner TP2 (E3): K22 und K23 stehen in Du-Form (K02/K03 bleiben Sie-Form wie ihr Rest); Öffnungszeiten live: K24–K27 ebenso
test('K22, K23, K24–K27: Du-Form fest, kein Sie und kein Ihr, auch mit allen Blöcken', () => {
for (const id of ['K22', 'K23', 'K24', 'K25', 'K26', 'K27']) {
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))
+363
View File
@@ -0,0 +1,363 @@
import { test, after, beforeEach, afterEach, mock } from 'node:test'
import { readFileSync } from 'node:fs'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { mailSink } from './support/mailSink.js'
import { useTestEnv } from './support/testEnv.js'
// Mails K24–K27 (Öffnungszeiten live: eingeschaltet, Feiertage prüfen, Abo beendet, abgeschaltet), ihre Builder und
// sendeZeitenMail (server/services/zeiten/mails.js, Task 13) sowie die neuen Blöcke behalten/neu in K15 und abo/team in T16.
// Die allgemeinen Vorlagenregeln (Kopf, Platzhalter, Blöcke, Logo, Text ohne Entities, Du-Form) prüft test/mailTemplates.test.js.
const aw = await startAppwriteMock()
const st = await startFakeStripe()
after(() => Promise.all([aw.close(), st.close()]))
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { loadTemplate, renderMail } = await import('../server/services/mailTemplates.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const reg = await import('../server/services/featureRegistry.js')
const sf = await import('../server/services/siteFeatures.js')
const { k24Mail, k25Mail, k26Mail, k27Mail, sendeZeitenMail } = await import('../server/services/zeiten/mails.js')
after(() => setMailTransportForTests(null))
const KUNDE = { $id: 'c1', name: 'Silke Müller', email: 'silke@example.com' }
const PORTAL = `${config.publicBaseUrl}/dashboard.html#zeiten`
let sink
let log
beforeEach(() => {
sink = mailSink()
setMailTransportForTests(sink)
log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}), info: mock.method(console, 'info', () => {}), log: mock.method(console, 'log', () => {}) }
})
afterEach(() => mock.restoreAll())
const logText = () => Object.values(log).flatMap((m) => m.mock.calls.map((aufruf) => aufruf.arguments.map(String).join(' '))).join('\n')
const render = (nachricht) => renderMail(nachricht.template, nachricht.values, nachricht.blocks)
const an = (nachricht) => Object.entries(nachricht.blocks || {}).filter(([, on]) => on).map(([name]) => name).sort()
// Ein Beispiel je Variante: genau diese Nachrichten gehen durch die Prüfung "Werte decken die Platzhalter ab"
const FRIST = new Date('2026-11-04T09:00:00.000Z')
const BEISPIELE = {
'K24 angestossen': () => k24Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', einbau: 'angestossen', wieder: false, ticketNr: 100123 }),
'K24 manuell mit Ticket': () => k24Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', einbau: 'manuell', wieder: false, ticketNr: 100123 }),
'K24 wieder ohne Ticket': () => k24Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', einbau: 'angestossen', wieder: true }),
'K25 ein Tag': () => k25Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', tage: [{ tag: '2026-10-03', name: 'Tag der Deutschen Einheit', art: 'feiertag' }] }),
'K25 mehrere mit prüfen': () =>
k25Mail({
kunde: KUNDE.name,
projekt: 'Praxis Muster',
tage: [
{ tag: '2026-12-24', name: 'Heiligabend', art: 'pruefen' },
{ tag: '2026-12-25', name: '1. Weihnachtstag', art: 'feiertag' },
],
}),
'K26 beendet': () => k26Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', art: 'beendet', frist: FRIST, preis: '30 €' }),
'K26 hosting': () => k26Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', art: 'hosting', frist: FRIST, preis: '30 €' }),
'K26 Wechsel widerrufen': () => k26Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', art: 'wechsel_widerrufen', frist: FRIST, preis: '30 €' }),
K27: () => k27Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', loeschdatum: FRIST, preis: '30 €' }),
}
test('Vorlagen K24–K27: Datei, Betreff, Platzhalter und Blöcke stehen fest', () => {
const erwartet = {
K24: { file: 'K24 Zeiten eingeschaltet.html', subject: 'Öffnungszeiten live ist eingeschaltet', placeholders: ['kunde_name', 'projekt', 'ticket', 'portal_url'], blocks: ['angestossen', 'manuell', 'wieder'] },
K25: { file: 'K25 Zeiten Feiertage pruefen.html', subject: 'Feiertage stehen an – stimmen Deine Zeiten?', placeholders: ['kunde_name', 'projekt', 'tage', 'portal_url'], blocks: ['pruefen', 'mehrere'] },
K26: { file: 'K26 Zeiten Abo beendet.html', subject: 'Dein Wartungs-Abo ist beendet – Öffnungszeiten live behalten?', placeholders: ['kunde_name', 'projekt', 'frist', 'preis', 'portal_url'], blocks: ['abo_ende', 'hosting', 'wechsel_widerrufen'] },
K27: { file: 'K27 Zeiten abgeschaltet.html', subject: 'Öffnungszeiten live ist abgeschaltet', placeholders: ['kunde_name', 'projekt', 'loeschdatum', 'preis', 'portal_url'], blocks: [] },
}
for (const [id, soll] of Object.entries(erwartet)) {
const tpl = loadTemplate(id)
assert.equal(tpl.file, soll.file, id)
assert.equal(tpl.subject, soll.subject, id)
assert.deepEqual(tpl.placeholders, soll.placeholders, id)
assert.deepEqual(tpl.blocks, soll.blocks, id)
}
})
test('Builder decken genau die Platzhalter und Blöcke der Vorlage ab', () => {
for (const [name, bauen] of Object.entries(BEISPIELE)) {
const nachricht = bauen()
const tpl = loadTemplate(nachricht.template)
assert.deepEqual(Object.keys(nachricht.values).sort(), [...tpl.placeholders].sort(), `${name}: Platzhalter`)
for (const block of Object.keys(nachricht.blocks || {})) assert.ok(tpl.blocks.includes(block), `${name}: Block ${block} gehört zur Vorlage`)
const mail = render(nachricht)
assert.deepEqual(mail.problems, [], name)
assert.equal(nachricht.values.portal_url, PORTAL, `${name}: Knopf führt in den Editor`)
assert.equal(nachricht.values.kunde_name, 'Silke Müller', name)
assert.match(mail.text, /Hallo Silke Müller,/, name)
assert.ok(mail.text.includes(`: ${PORTAL}`), `${name}: Knopf mit Adresse im Text`)
}
})
test('Anrede: Kunde als Name oder als Kundendokument, ohne Namen bleibt der Wert leer (wie K22/K23)', () => {
assert.equal(k27Mail({ kunde: KUNDE, projekt: 'P', loeschdatum: FRIST, preis: '30 €' }).values.kunde_name, 'Silke Müller')
for (const kunde of [undefined, null, '', ' ', {}, { name: null }]) {
assert.equal(k27Mail({ kunde, projekt: 'P', loeschdatum: FRIST, preis: '30 €' }).values.kunde_name, '', JSON.stringify(kunde))
}
})
test('K24: Text je nach einbau (angestossen: nach dem nächsten Veröffentlichen, manuell: das Team baut ein)', () => {
const auto = BEISPIELE['K24 angestossen']()
assert.equal(auto.template, 'K24')
assert.deepEqual(auto.blocks, { angestossen: true, manuell: false, wieder: false })
const textAuto = render(auto).text
assert.match(textAuto, /nach dem nächsten Veröffentlichen/)
assert.doesNotMatch(textAuto, /baut unser Team/)
assert.match(textAuto, /Ticket #100123/, 'Ticketnummer steht in beiden Fällen in der Mail')
const hand = BEISPIELE['K24 manuell mit Ticket']()
assert.deepEqual(hand.blocks, { angestossen: false, manuell: true, wieder: false })
const textHand = render(hand).text
assert.match(textHand, /baut unser Team/)
assert.doesNotMatch(textHand, /nach dem nächsten Veröffentlichen/)
assert.match(textHand, /Ticket #100123/)
// ohne bekannten Einbau sagt die Mail das Vorsichtige: das Team baut ein
for (const einbau of [undefined, null, '', 'unbekannt']) {
const unklar = k24Mail({ kunde: 'K', projekt: 'P', einbau })
assert.deepEqual(unklar.blocks, { angestossen: false, manuell: true, wieder: false }, String(einbau))
}
})
test('K24: ohne Ticketnummer steht kein leeres Ticket in der Mail; wieder nennt das Abo', () => {
const ohne = k24Mail({ kunde: 'K', projekt: 'P', einbau: 'manuell' })
assert.equal(ohne.values.ticket, '')
assert.doesNotMatch(render(ohne).text, /Ticket|\(\)|\s\./)
const leer = k24Mail({ kunde: 'K', projekt: 'P', einbau: 'manuell', ticketNr: ' ' })
assert.equal(leer.values.ticket, '')
assert.equal(k24Mail({ kunde: 'K', projekt: 'P', einbau: 'manuell', ticketNr: '#100124' }).values.ticket, ' (Ticket #100124)')
const wieder = BEISPIELE['K24 wieder ohne Ticket']()
assert.deepEqual(wieder.blocks, { angestossen: true, manuell: false, wieder: true })
const mitWieder = render(wieder).text
assert.match(mitWieder, /Dein Wartungs-Abo läuft wieder/)
assert.match(mitWieder, /wieder eingeschaltet/)
assert.match(mitWieder, /Zeiten und Einstellungen sind unverändert/)
assert.doesNotMatch(render(BEISPIELE['K24 angestossen']()).text, /Wartungs-Abo läuft wieder/)
})
test('K24: Betreff, Projekt und Knopf', () => {
const mail = render(BEISPIELE['K24 angestossen']())
assert.equal(mail.subject, 'Öffnungszeiten live ist eingeschaltet')
assert.match(mail.text, /Projekt: Praxis Muster/)
assert.match(mail.html, new RegExp(`<a href="${PORTAL.replaceAll('.', '\\.')}"`))
})
test('K25: eine Zeile je Tag mit Wochentag, Datum und Namen; „bitte prüfen“ und Mehrzahl nur bei Bedarf', () => {
const ein = BEISPIELE['K25 ein Tag']()
assert.equal(ein.template, 'K25')
assert.equal(ein.values.tage, 'Sa 03.10. – Tag der Deutschen Einheit')
assert.deepEqual(an(ein), [])
assert.deepEqual(ein.blocks, { pruefen: false, mehrere: false })
const textEin = render(ein).text
assert.match(textEin, /Betrifft: Sa 03\.10\. – Tag der Deutschen Einheit/)
assert.doesNotMatch(textEin, /bitte prüfen|mehrere Tage/)
assert.match(textEin, /Stimmen Deine Zeiten/)
assert.match(textEin, /Google/, 'Hinweis: bei Google auch eintragen')
const viele = BEISPIELE['K25 mehrere mit prüfen']()
assert.equal(viele.values.tage, 'Do 24.12. – Heiligabend (bitte prüfen)\nFr 25.12. – 1. Weihnachtstag')
assert.deepEqual(viele.blocks, { pruefen: true, mehrere: true })
const mail = render(viele)
assert.match(mail.text, /Betrifft: Do 24\.12\. – Heiligabend \(bitte prüfen\)\nFr 25\.12\. – 1\. Weihnachtstag/)
assert.match(mail.text, /mehrere Tage/)
assert.match(mail.text, /Silvester/, 'Erklärung der „bitte prüfen“-Tage')
assert.match(mail.html, /Heiligabend \(bitte prüfen\)<br>Fr 25\.12\./, 'Zeilenumbrüche werden zu <br>')
})
test('K25: Namen werden HTML-sicher eingesetzt, Wochentage stimmen über die Woche', () => {
const nachricht = k25Mail({
kunde: 'K',
projekt: 'P',
tage: [
{ tag: '2026-10-05', name: 'A <b>fett</b>', art: 'feiertag' },
{ tag: '2026-10-06', name: 'B', art: 'feiertag' },
{ tag: '2026-10-07', name: 'C', art: 'feiertag' },
{ tag: '2026-10-08', name: 'D', art: 'feiertag' },
{ tag: '2026-10-09', name: 'E', art: 'feiertag' },
{ tag: '2026-10-10', name: 'F', art: 'feiertag' },
{ tag: '2026-10-11', name: 'G', art: 'feiertag' },
],
})
assert.deepEqual(nachricht.values.tage.split('\n').map((zeile) => zeile.slice(0, 9)), ['Mo 05.10.', 'Di 06.10.', 'Mi 07.10.', 'Do 08.10.', 'Fr 09.10.', 'Sa 10.10.', 'So 11.10.'])
const mail = render(nachricht)
assert.match(mail.html, /A &lt;b&gt;fett&lt;\/b&gt;/)
assert.doesNotMatch(mail.html, /<b>fett/)
})
test('K25 geht ohne BCC ans Team, K24 mit', async () => {
const k25 = await sendeZeitenMail(KUNDE, BEISPIELE['K25 ein Tag'](), { bccTeam: false })
assert.deepEqual(k25, { sent: true })
assert.equal(sink.sent.length, 1)
assert.deepEqual(sink.sent[0].to.map((t) => t.address), ['silke@example.com'])
assert.ok(!sink.sent[0].bcc, 'keine Kopie ans Team')
assert.equal(sink.sent[0].subject, 'Feiertage stehen an – stimmen Deine Zeiten?')
assert.deepEqual(await sendeZeitenMail(KUNDE, BEISPIELE['K24 angestossen']()), { sent: true })
assert.equal(sink.sent.length, 2)
assert.deepEqual(sink.sent[1].bcc.map((t) => t.address), config.chatNotify.emails, 'Standard: Kopie ans Team')
assert.ok(sink.sent[1].bcc.length > 0)
assert.equal(sink.sent[1].subject, 'Öffnungszeiten live ist eingeschaltet')
assert.match(sink.sent[1].html, /Hallo Silke Müller,/)
})
test('K26: je Variante nur der passende Grund, Frist, Preis und Behalten-Knopf', () => {
const faelle = [
['K26 beendet', { abo_ende: true, hosting: false, wechsel_widerrufen: false }, /Dein Wartungs-Abo ist beendet/, /Hosting-Abo|Wechsel/],
['K26 hosting', { abo_ende: false, hosting: true, wechsel_widerrufen: false }, /Hosting-Abo/, /Dein Wartungs-Abo ist beendet|widerrufen/],
['K26 Wechsel widerrufen', { abo_ende: false, hosting: false, wechsel_widerrufen: true }, /Wechsel.*widerrufen/, /Dein Wartungs-Abo ist beendet|Dein Abo ist ein Hosting-Abo/],
]
for (const [name, blocks, passt, fremd] of faelle) {
const nachricht = BEISPIELE[name]()
assert.equal(nachricht.template, 'K26')
assert.deepEqual(nachricht.blocks, blocks, name)
assert.equal(nachricht.values.frist, '04.11.2026', `${name}: Datum TT.MM.JJJJ`)
assert.equal(nachricht.values.preis, '30 €')
const text = render(nachricht).text
assert.match(text, passt, name)
assert.doesNotMatch(text, fremd, name)
assert.match(text, /04\.11\.2026/, name)
assert.match(text, /einmalig 30 €/, name)
assert.match(text, /Für 30 € behalten: .*#zeiten/, `${name}: Knopf zum Behalten`)
}
// unbekannte Lage: lieber die Standard-Fassung (Abo beendet) als gar kein Grund
assert.deepEqual(k26Mail({ kunde: 'K', projekt: 'P', art: 'was-neues', frist: FRIST, preis: '30 €' }).blocks, { abo_ende: true, hosting: false, wechsel_widerrufen: false })
assert.equal(render(BEISPIELE['K26 beendet']()).subject, 'Dein Wartungs-Abo ist beendet – Öffnungszeiten live behalten?')
})
test('K26/K27: Datum aus Date (Berliner Tag), ungültiges Datum wirft früh', () => {
// Berliner Tag, nicht UTC: 22:30 UTC am 30.10. ist 23:30 MEZ (noch der 30.), 23:30 UTC am 31.10. ist 00:30 MEZ (schon der 1.11.)
assert.equal(k26Mail({ kunde: 'K', projekt: 'P', art: 'beendet', frist: new Date('2026-10-30T22:30:00.000Z'), preis: '30 €' }).values.frist, '30.10.2026')
assert.equal(k26Mail({ kunde: 'K', projekt: 'P', art: 'beendet', frist: new Date('2026-10-31T23:30:00.000Z'), preis: '30 €' }).values.frist, '01.11.2026')
assert.throws(() => k26Mail({ kunde: 'K', projekt: 'P', art: 'beendet', frist: new Date('kaputt'), preis: '30 €' }), TypeError)
assert.throws(() => k26Mail({ kunde: 'K', projekt: 'P', art: 'beendet', preis: '30 €' }), TypeError)
assert.throws(() => k27Mail({ kunde: 'K', projekt: 'P', loeschdatum: 'morgen', preis: '30 €' }), TypeError)
})
test('K27: Leisten weg, feste Zeiten, Daten bis zum Löschdatum, Wiedereinschalten für den Preis', () => {
const nachricht = BEISPIELE.K27()
assert.equal(nachricht.template, 'K27')
assert.equal(nachricht.values.loeschdatum, '04.11.2026')
assert.equal(nachricht.values.preis, '30 €')
const mail = render(nachricht)
assert.equal(mail.subject, 'Öffnungszeiten live ist abgeschaltet')
assert.match(mail.text, /wieder feste Zeiten/)
assert.match(mail.text, /04\.11\.2026/)
assert.match(mail.text, /für einmalig 30 € wieder einschalten/)
assert.match(mail.text, /Projekt: Praxis Muster/)
})
test('K26 und K27 gehen mit Kopie ans Team', async () => {
assert.equal((await sendeZeitenMail(KUNDE, BEISPIELE['K26 beendet']())).sent, true)
assert.equal((await sendeZeitenMail(KUNDE, BEISPIELE.K27())).sent, true)
for (const mail of sink.sent) assert.ok(mail.bcc?.length > 0)
})
test('ohne E-Mail-Adresse: nichts senden, nichts werfen', async () => {
for (const kunde of [{ name: 'Silke' }, { name: 'Silke', email: '' }, { email: ' ' }, { email: null }, null, undefined]) {
assert.deepEqual(await sendeZeitenMail(kunde, BEISPIELE.K27()), { sent: false, reason: 'keine_adresse' }, JSON.stringify(kunde))
}
assert.equal(sink.sent.length, 0)
})
test('Versandfehler werden gemeldet, nie geworfen; der Grund nennt keine Adresse', async () => {
setMailTransportForTests({
sendMail: async () => {
throw new Error('550 Recipient silke@example.com rejected for Silke Müller')
},
})
const ergebnis = await sendeZeitenMail(KUNDE, BEISPIELE.K27())
assert.equal(ergebnis.sent, false)
assert.equal(ergebnis.reason, 'fehler')
assert.doesNotMatch(JSON.stringify(ergebnis), /silke|Müller/i)
// auch ohne SMTP-Konfiguration (sendCustomerMail wirft) und mit unbrauchbarer Nachricht
setMailTransportForTests(null)
assert.equal((await sendeZeitenMail(KUNDE, BEISPIELE.K27())).sent, false)
setMailTransportForTests(sink)
assert.equal((await sendeZeitenMail(KUNDE, undefined)).sent, false)
assert.equal((await sendeZeitenMail(KUNDE, { template: 'K99' })).sent, false)
assert.equal(sink.sent.length, 0)
})
test('Log enthält weder Adresse noch Namen', async () => {
setMailTransportForTests({
sendMail: async () => {
throw new Error('550 Recipient silke@example.com rejected for Silke Müller (Praxis Muster)')
},
})
const ergebnis = await sendeZeitenMail(KUNDE, BEISPIELE['K26 beendet']())
assert.equal(ergebnis.sent, false)
const text = logText()
assert.match(text, /K26/, 'das Log nennt die Vorlage')
assert.ok(text.length > 0)
assert.doesNotMatch(text, /silke|example\.com|Müller/i, text)
// auch der Fall ohne Adresse loggt höchstens ohne Namen
await sendeZeitenMail({ name: 'Silke Müller' }, BEISPIELE.K27())
assert.doesNotMatch(logText(), /silke|Müller/i)
})
test('Erfolg schreibt nichts ins Log', async () => {
await sendeZeitenMail(KUNDE, BEISPIELE['K24 angestossen']())
assert.equal(logText(), '')
})
// ---------------------------------------------------------------- K15 behalten, T16 abo/team ---
test('K15: Block behalten ersetzt den Einrichtungstext, ohne ihn bleibt die Mail wie gehabt', () => {
const feature = reg.featureConfig('zeiten')
const ctx = { kundeName: 'Silke Müller', projekt: 'Praxis Muster', invoiceUrl: 'https://invoice.stripe.test/i/1' }
const neu = sf.bestellMail(feature, ctx)
assert.deepEqual(neu.blocks, { rechnung: true, behalten: false, neu: true })
assert.deepEqual(sf.bestellMail(feature, { ...ctx, behalten: false }).blocks, neu.blocks)
const behalten = sf.bestellMail(feature, { ...ctx, behalten: true })
assert.deepEqual(behalten.blocks, { rechnung: true, behalten: true, neu: false })
assert.deepEqual(behalten.values, neu.values, 'dieselben Werte, nur der Wortlaut wechselt')
const textNeu = render(neu)
assert.deepEqual(textNeu.problems, [])
assert.match(textNeu.text, /Wir richten Öffnungszeiten live jetzt für Dein Projekt ein\./)
assert.match(textNeu.text, /Funktion: Öffnungszeiten live – Einrichtung/)
assert.match(textNeu.text, /Einrichtung: meist innerhalb weniger Tage/)
assert.match(textNeu.text, /Sobald Öffnungszeiten live auf Deiner Website eingebaut ist/)
assert.doesNotMatch(textNeu.text, /behält|behalten/)
const mail = render(behalten)
assert.deepEqual(mail.problems, [])
assert.equal(mail.subject, 'Deine Bestellung: Öffnungszeiten live')
assert.match(mail.text, /Hallo Silke Müller,\nvielen Dank für Deine Bestellung und Deine Zahlung\. Du behältst Öffnungszeiten live für Dein Projekt/)
assert.match(mail.text, /Funktion: Öffnungszeiten live – behalten/)
assert.match(mail.text, /Einmalig bezahlt: 30,00 €/)
assert.match(mail.text, /nichts neu eingebaut/)
assert.match(mail.text, /Rechnung ansehen: https:\/\/invoice\.stripe\.test\/i\/1/)
assert.ok(mail.text.includes(`Zum Kundenportal: ${config.publicBaseUrl}/dashboard.html#zeiten`))
// nichts vom Einrichtungsablauf in der Behalten-Fassung
assert.doesNotMatch(mail.text, /Wir richten|Einrichtung|Sobald|eingebaut ist/)
})
test('K15: die Blöcke behalten und neu stehen im Kopf der Vorlage', () => {
const tpl = loadTemplate('K15')
assert.deepEqual(tpl.blocks, ['rechnung', 'behalten', 'neu'])
// jeder Block erscheint, und in der Behalten-Fassung (neu aus) fehlt jeder Rest von zeit/danach
const behalten = renderMail('K15', Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`])), { behalten: true })
assert.deepEqual(behalten.problems, [])
assert.doesNotMatch(behalten.text, /Wert-zeit|Wert-danach/)
})
test('T16: Blöcke abo und team nennen den Weg ohne Zahlung', () => {
const tpl = loadTemplate('T16')
assert.deepEqual(tpl.blocks, ['zahlung_fehlgeschlagen', 'ungeklaert', 'kein_abo', 'bereits_aktiv', 'anleitung', 'gesundheit', 'abo', 'team'])
const werte = { kunde_name: 'Silke Müller', email: 'silke@example.com', projekt: 'Praxis Muster', funktion: 'Öffnungszeiten live', woid: '10042', sitekey: 'abc', sitzung: '', zustand: '', anleitung_url: '', gesundheit_text: '' }
const aus = { zahlung_fehlgeschlagen: false, ungeklaert: false, kein_abo: false, bereits_aktiv: false, anleitung: false, gesundheit: false }
const abo = renderMail('T16', werte, { ...aus, abo: true, team: false })
assert.deepEqual(abo.problems, [])
assert.match(abo.text, /im Wartungs-Abo enthalten/i)
assert.doesNotMatch(abo.text, /vom Team freigeschaltet/i)
const team = renderMail('T16', werte, { ...aus, abo: false, team: true })
assert.deepEqual(team.problems, [])
assert.match(team.text, /vom Team freigeschaltet/i)
assert.doesNotMatch(team.text, /im Wartungs-Abo enthalten/i)
const keiner = renderMail('T16', werte, aus)
assert.doesNotMatch(keiner.text, /im Wartungs-Abo enthalten|vom Team freigeschaltet/i)
// sendT16 führt beide Blöcke als Vorgabe (aus), damit jede Team-Mail sie nennt
assert.match(readFileSync(new URL('../server/services/siteFeatures.js', import.meta.url), 'utf8'), /gesundheit: false, abo: false, team: false, \.\.\.bloecke/)
})