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 K27–K30 (Ö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 { k27Mail, k28Mail, k29Mail, k30Mail, 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 = { 'K27 angestossen': () => k27Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', einbau: 'angestossen', wieder: false, ticketNr: 100123 }), 'K27 manuell mit Ticket': () => k27Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', einbau: 'manuell', wieder: false, ticketNr: 100123 }), 'K27 wieder ohne Ticket': () => k27Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', einbau: 'angestossen', wieder: true }), 'K27 wieder ohne Neubau': () => k27Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', einbau: null, wieder: true }), 'K28 ein Tag': () => k28Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', tage: [{ tag: '2026-10-03', name: 'Tag der Deutschen Einheit', art: 'feiertag' }] }), 'K28 mehrere mit prüfen': () => k28Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', tage: [ { tag: '2026-12-24', name: 'Heiligabend', art: 'pruefen' }, { tag: '2026-12-25', name: '1. Weihnachtstag', art: 'feiertag' }, ], }), 'K29 beendet': () => k29Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', art: 'beendet', frist: FRIST, preis: '30 €' }), 'K29 hosting': () => k29Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', art: 'hosting', frist: FRIST, preis: '30 €' }), 'K29 Wechsel widerrufen': () => k29Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', art: 'wechsel_widerrufen', frist: FRIST, preis: '30 €' }), K30: () => k30Mail({ kunde: KUNDE.name, projekt: 'Praxis Muster', loeschdatum: FRIST, preis: '30 €' }), } test('Vorlagen K27–K30: Datei, Betreff, Platzhalter und Blöcke stehen fest', () => { const erwartet = { K27: { file: 'K27 Zeiten eingeschaltet.html', subject: 'Öffnungszeiten live ist eingeschaltet', placeholders: ['kunde_name', 'projekt', 'ticket', 'portal_url'], blocks: ['angestossen', 'manuell', 'wieder', 'weiter'] }, K28: { file: 'K28 Zeiten Feiertage pruefen.html', subject: 'Feiertage stehen an – stimmen Deine Zeiten?', placeholders: ['kunde_name', 'projekt', 'tage', 'portal_url'], blocks: ['pruefen', 'mehrere'] }, K29: { file: 'K29 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'] }, K30: { file: 'K30 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(k30Mail({ kunde: KUNDE, projekt: 'P', loeschdatum: FRIST, preis: '30 €' }).values.kunde_name, 'Silke Müller') for (const kunde of [undefined, null, '', ' ', {}, { name: null }]) { assert.equal(k30Mail({ kunde, projekt: 'P', loeschdatum: FRIST, preis: '30 €' }).values.kunde_name, '', JSON.stringify(kunde)) } }) test('K27: Text je nach einbau (angestossen: nach dem nächsten Veröffentlichen, manuell: das Team baut ein)', () => { const auto = BEISPIELE['K27 angestossen']() assert.equal(auto.template, 'K27') assert.deepEqual(auto.blocks, { angestossen: true, manuell: false, wieder: false, weiter: true }) 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['K27 manuell mit Ticket']() assert.deepEqual(hand.blocks, { angestossen: false, manuell: true, wieder: false, weiter: true }) 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 (null gilt nur mit wieder als "kein Einbau nötig", siehe unten) for (const einbau of [undefined, null, '', 'unbekannt']) { const unklar = k27Mail({ kunde: 'K', projekt: 'P', einbau }) assert.deepEqual(unklar.blocks, { angestossen: false, manuell: true, wieder: false, weiter: true }, String(einbau)) } }) test('K27 wieder ohne Neubau (einbau null, Abo kehrt vor dem Abschalten zurück): beide Einbau-Blöcke und der Kasten "Wie geht es weiter" entfallen', () => { const ohne = BEISPIELE['K27 wieder ohne Neubau']() assert.deepEqual(ohne.blocks, { angestossen: false, manuell: false, wieder: true, weiter: false }) const mail = render(ohne) assert.deepEqual(mail.problems, []) assert.match(mail.text, /Dein Wartungs-Abo läuft wieder/) assert.match(mail.text, /Zeiten und Einstellungen sind unverändert/) // nichts, was nach Einbau klingt (die Leisten waren nie weg) assert.doesNotMatch(mail.text, /Wie geht es weiter|nach dem nächsten Veröffentlichen|baut unser Team|Ohne gespeicherte Zeiten|Ticket/) // Knopf, Projekt und Funktion bleiben assert.match(mail.text, /Projekt: Praxis Muster/) assert.match(mail.html, new RegExp(` { const ohne = k27Mail({ kunde: 'K', projekt: 'P', einbau: 'manuell' }) assert.equal(ohne.values.ticket, '') assert.doesNotMatch(render(ohne).text, /Ticket|\(\)|\s\./) const leer = k27Mail({ kunde: 'K', projekt: 'P', einbau: 'manuell', ticketNr: ' ' }) assert.equal(leer.values.ticket, '') assert.equal(k27Mail({ kunde: 'K', projekt: 'P', einbau: 'manuell', ticketNr: '#100124' }).values.ticket, ' (Ticket #100124)') const wieder = BEISPIELE['K27 wieder ohne Ticket']() assert.deepEqual(wieder.blocks, { angestossen: true, manuell: false, wieder: true, weiter: 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['K27 angestossen']()).text, /Wartungs-Abo läuft wieder/) }) test('K27: Betreff, Projekt und Knopf', () => { const mail = render(BEISPIELE['K27 angestossen']()) assert.equal(mail.subject, 'Öffnungszeiten live ist eingeschaltet') assert.match(mail.text, /Projekt: Praxis Muster/) assert.match(mail.html, new RegExp(` { const ein = BEISPIELE['K28 ein Tag']() assert.equal(ein.template, 'K28') 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['K28 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\)
Fr 25\.12\./, 'Zeilenumbrüche werden zu
') }) test('K28: Namen werden HTML-sicher eingesetzt, Wochentage stimmen über die Woche', () => { const nachricht = k28Mail({ kunde: 'K', projekt: 'P', tage: [ { tag: '2026-10-05', name: 'A fett', 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 <b>fett<\/b>/) assert.doesNotMatch(mail.html, /fett/) }) test('K28 geht ohne BCC ans Team, K27 mit', async () => { const k28 = await sendeZeitenMail(KUNDE, BEISPIELE['K28 ein Tag'](), { bccTeam: false }) assert.deepEqual(k28, { 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['K27 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('K29: je Variante nur der passende Grund, Frist, Preis und Behalten-Knopf', () => { const faelle = [ ['K29 beendet', { abo_ende: true, hosting: false, wechsel_widerrufen: false }, /Dein Wartungs-Abo ist beendet/, /Hosting-Abo|Wechsel/], ['K29 hosting', { abo_ende: false, hosting: true, wechsel_widerrufen: false }, /Hosting-Abo/, /Dein Wartungs-Abo ist beendet|widerrufen/], ['K29 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, 'K29') 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(k29Mail({ kunde: 'K', projekt: 'P', art: 'was-neues', frist: FRIST, preis: '30 €' }).blocks, { abo_ende: true, hosting: false, wechsel_widerrufen: false }) assert.equal(render(BEISPIELE['K29 beendet']()).subject, 'Dein Wartungs-Abo ist beendet – Öffnungszeiten live behalten?') }) test('K29/K30: 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(k29Mail({ kunde: 'K', projekt: 'P', art: 'beendet', frist: new Date('2026-10-30T22:30:00.000Z'), preis: '30 €' }).values.frist, '30.10.2026') assert.equal(k29Mail({ kunde: 'K', projekt: 'P', art: 'beendet', frist: new Date('2026-10-31T23:30:00.000Z'), preis: '30 €' }).values.frist, '01.11.2026') assert.throws(() => k29Mail({ kunde: 'K', projekt: 'P', art: 'beendet', frist: new Date('kaputt'), preis: '30 €' }), TypeError) assert.throws(() => k29Mail({ kunde: 'K', projekt: 'P', art: 'beendet', preis: '30 €' }), TypeError) assert.throws(() => k30Mail({ kunde: 'K', projekt: 'P', loeschdatum: 'morgen', preis: '30 €' }), TypeError) }) test('K30: Leisten weg, feste Zeiten, Daten bis zum Löschdatum, Wiedereinschalten für den Preis', () => { const nachricht = BEISPIELE.K30() assert.equal(nachricht.template, 'K30') 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('K29 und K30 gehen mit Kopie ans Team', async () => { assert.equal((await sendeZeitenMail(KUNDE, BEISPIELE['K29 beendet']())).sent, true) assert.equal((await sendeZeitenMail(KUNDE, BEISPIELE.K30())).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.K30()), { 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.K30()) 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.K30())).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['K29 beendet']()) assert.equal(ergebnis.sent, false) const text = logText() assert.match(text, /K29/, '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.K30()) assert.doesNotMatch(logText(), /silke|Müller/i) }) test('Erfolg schreibt nichts ins Log', async () => { await sendeZeitenMail(KUNDE, BEISPIELE['K27 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) // anlagen, anhang und zustimmungen (TP2 F8) bleiben aus: die Oeffnungszeiten haben keine Rechtsdokumente zum Anhaengen assert.deepEqual(neu.blocks, { rechnung: true, anlagen: false, anhang: false, zustimmungen: false, 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, anlagen: false, anhang: false, zustimmungen: false, 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', 'anlagen', 'anhang', 'zustimmungen']) // 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', 'anhang_fehlt']) 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'), /abo: false,\s+team: false,\s+\.\.\.bloecke/) })