import { test, after } from 'node:test' import assert from 'node:assert/strict' import { startAppwriteMock } from './support/appwriteMock.js' import { startFakeStripe } from './support/fakeStripe.js' import { useTestEnv } from './support/testEnv.js' import { schemaKeys } from './support/schema.js' // Mails K15 (Bestellung eingegangen) und K16 (Funktion bereit) je Funktion (Task B9a). Der News-Wortlaut ist als Literal // eingefroren (vor dem Umbau der Vorlagen aus den damaligen Mails uebernommen): "News-Mails bleiben woertlich". // B9b ergaenzt T16 und T19 in dieser Datei. websiteProjects, workorders und customers legt das Setup-Skript nicht an: ohne Schema. const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures') } }) 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 reg = await import('../server/services/featureRegistry.js') const sf = await import('../server/services/siteFeatures.js') const { loadTemplate, renderMail } = await import('../server/services/mailTemplates.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const { mailSink } = await import('./support/mailSink.js') const mails = mailSink() setMailTransportForTests(mails) after(() => setMailTransportForTests(null)) // Vorlagenprobleme (unbekannte oder fehlende Platzhalter) meldet der Mailer nur als Warnung: hier mitschreiben const warnungen = [] const origWarn = console.warn console.warn = (...args) => { if (String(args[0]).startsWith('[mailer] Vorlage')) warnungen.push(args.join(' ')) else origWarn(...args) } after(() => { console.warn = origWarn }) aw.seed('customers', { $id: 'c1', name: 'Kunde Eins', location: 'Berlin', email: 'kunde1@example.test' }) st.invoices.set('in_1', { id: 'in_1', object: 'invoice', hosted_invoice_url: 'https://invoice.stripe.test/i/1' }) const BASE = 'https://project.webklar.com' const FOOTER = [ 'Viele Grüße', 'Dein WEBklar-Team', '', `Kundenportal (${BASE}/dashboard.html)`, '', 'WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler', 'Telefon 0176 23726355 · support@webklar.com', 'Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein', '', ] let zaehler = 0 /** Projekt (Kunde c1) anlegen und die Funktion bestellen: liefert { projectId, sessionId }. */ async function bestelle(key, projectName) { zaehler += 1 const projectId = `bm${zaehler}` const sessionId = `cs_bm${zaehler}` aw.seed('websiteProjects', { $id: projectId, customerId: 'c1', projectName }) await sf.markOrdered({ customerId: 'c1', projectId, key, sessionId, consent: {} }) return { projectId, sessionId } } const paidEvent = (sessionId, projectId, key, extra = {}) => ({ id: `evt_${sessionId}`, object: 'event', type: 'checkout.session.completed', data: { object: { id: sessionId, object: 'checkout.session', mode: 'payment', payment_status: 'paid', metadata: { customerId: 'c1', projectId, featureKey: key }, ...extra }, }, }) /** Bestellung bezahlen (Stripe-Ereignis) und die K15-Mail an den Kunden liefern. */ async function bezahle(key, projectName, extra = {}) { const { projectId, sessionId } = await bestelle(key, projectName) const vor = mails.sent.length await sf.handleFeatureCheckoutEvent(paidEvent(sessionId, projectId, key, extra)) const k15 = mails.sent.slice(vor).filter((m) => m.subject.startsWith('Deine Bestellung')) assert.equal(k15.length, 1, `genau eine K15-Mail: ${mails.sent.slice(vor).map((m) => m.subject)}`) return { projectId, sessionId, mail: k15[0] } } // ------------------------------------------------------------------------- News bleibt woertlich --- test('News-Mails bleiben wörtlich: K15 mit Rechnung', async () => { const { mail } = await bezahle('news', 'Bäckerei', { invoice: 'in_1' }) assert.equal(mail.subject, 'Deine Bestellung: News-Pinnwand') assert.equal( mail.text, [ 'WEBklar', '', 'Bestätigung', '', '✓ Bestellung eingegangen', '', 'Deine Bestellung: News-Pinnwand', '', 'Hallo Kunde Eins,', 'vielen Dank für Deine Bestellung und Deine Zahlung. Wir richten die News-Pinnwand jetzt für Dein Projekt ein.', '', 'Funktion: News-Pinnwand – Einrichtung', 'Projekt: Bäckerei', 'Einmalig bezahlt: 30,00 € (brutto, einmalig; die Nutzung ist kostenlos, es läuft kein Abo)', 'Einrichtung: meist innerhalb von 1–2 Wochen', '', 'Wie geht es weiter?', 'Sobald die News-Pinnwand auf Deiner Website eingebaut ist, schalten wir sie im Kundenportal frei und Du bekommst eine weitere E-Mail. Danach kannst Du Beiträge im Portal schreiben.', '', 'Rechnung ansehen: https://invoice.stripe.test/i/1', '', `Zum Kundenportal: ${BASE}/dashboard.html#news`, '', ...FOOTER, ].join('\n') ) assert.deepEqual(mail.to.map((t) => t.address), ['kunde1@example.test']) }) test('News-Mails bleiben wörtlich: K15 ohne Rechnung', async () => { const { mail } = await bezahle('news', 'Café') assert.equal(mail.subject, 'Deine Bestellung: News-Pinnwand') assert.equal( mail.text, [ 'WEBklar', '', 'Bestätigung', '', '✓ Bestellung eingegangen', '', 'Deine Bestellung: News-Pinnwand', '', 'Hallo Kunde Eins,', 'vielen Dank für Deine Bestellung und Deine Zahlung. Wir richten die News-Pinnwand jetzt für Dein Projekt ein.', '', 'Funktion: News-Pinnwand – Einrichtung', 'Projekt: Café', 'Einmalig bezahlt: 30,00 € (brutto, einmalig; die Nutzung ist kostenlos, es läuft kein Abo)', 'Einrichtung: meist innerhalb von 1–2 Wochen', '', 'Wie geht es weiter?', 'Sobald die News-Pinnwand auf Deiner Website eingebaut ist, schalten wir sie im Kundenportal frei und Du bekommst eine weitere E-Mail. Danach kannst Du Beiträge im Portal schreiben.', '', `Zum Kundenportal: ${BASE}/dashboard.html#news`, '', ...FOOTER, ].join('\n') ) assert.doesNotMatch(mail.text, /Rechnung ansehen/) }) test('News-Mails bleiben wörtlich: K16', async () => { const { projectId } = await bezahle('news', 'Bistro') const doc = await sf.getFeatureDoc('c1', projectId, 'news') aw.seed('workorders', { ...aw.docs('workorders').get(doc.ticketId), status: 'Closed' }) const vor = mails.sent.length const done = await sf.runActivation({ customerId: 'c1' }) assert.deepEqual(done.map((d) => d.projectId), [projectId]) const gesendet = mails.sent.slice(vor) assert.equal(gesendet.length, 1) const [mail] = gesendet assert.equal(mail.subject, 'Deine News-Pinnwand ist bereit') assert.equal( mail.text, [ 'WEBklar', '', 'Bereit', '', '✓ News-Pinnwand', '', 'Deine News-Pinnwand ist bereit', '', 'Hallo Kunde Eins,', 'die News-Pinnwand ist auf Deiner Website eingebaut und freigeschaltet. Du kannst ab sofort Beiträge schreiben, sie erscheinen direkt bei Dir auf der Seite.', '', 'Funktion: News-Pinnwand', 'Projekt: Bistro', '', `Beitrag schreiben: ${BASE}/dashboard.html#news`, '', ...FOOTER, ].join('\n') ) assert.deepEqual(mail.to.map((t) => t.address), ['kunde1@example.test']) }) // ------------------------------------------------------------------------------------- Termine --- test('Termine-Mails: K15 mit den Werten des Terminplaners', async () => { const termine = config.features.termine.mails const { mail } = await bezahle('termine', 'Salon Haarlinie', { invoice: 'in_1' }) assert.equal(mail.subject, 'Deine Bestellung: Terminplaner') assert.doesNotMatch(mail.text, /News|Beitr/) assert.doesNotMatch(mail.html, /News|Beitr/) assert.match(mail.text, /Wir richten den Terminplaner jetzt für Dein Projekt ein\./) assert.match(mail.text, /Funktion: Terminplaner – Einrichtung/) assert.match(mail.text, /Projekt: Salon Haarlinie/) assert.ok(mail.text.includes(`Einmalig bezahlt: 149,00 € (${termine.preisHinweis})`), mail.text) assert.ok( mail.text.includes(`Sobald der Terminplaner auf Deiner Website eingebaut ist, schalten wir ihn im Kundenportal frei und Du bekommst eine weitere E-Mail. ${termine.danach}`), mail.text ) assert.match(mail.text, /Rechnung ansehen: https:\/\/invoice\.stripe\.test\/i\/1/) assert.ok(mail.text.includes(`Zum Kundenportal: ${BASE}/dashboard.html#termine`), mail.text) assert.match(mail.html, new RegExp(`href="${BASE.replace(/\./g, '\\.')}/dashboard\\.html#termine"`)) assert.deepEqual(mail.to.map((t) => t.address), ['kunde1@example.test']) }) test('Termine-Mails: K16 mit den Werten des Terminplaners', () => { const termine = config.features.termine.mails const nachricht = sf.bereitMail(reg.featureConfig('termine'), { kundeName: 'Kunde Eins', projekt: 'Salon Haarlinie' }) assert.equal(nachricht.template, 'K16') const mail = renderMail(nachricht.template, nachricht.values, nachricht.blocks) assert.deepEqual(mail.problems, []) assert.equal(mail.subject, 'Dein Terminplaner ist bereit') assert.doesNotMatch(mail.text, /News|Beitr/) assert.doesNotMatch(mail.html, /News|Beitr/) assert.match(mail.text, /Hallo Kunde Eins,\nder Terminplaner ist auf Deiner Website eingebaut und freigeschaltet\./) assert.ok(mail.text.includes(termine.bereit), mail.text) assert.match(mail.text, /Funktion: Terminplaner\nProjekt: Salon Haarlinie/) assert.ok(mail.text.includes(`Termine öffnen: ${BASE}/dashboard.html#termine`), mail.text) }) // ----------------------------------------------------------------------------- Platzhalter, Blöcke --- test('Platzhalter-/Blocklisten exakt: K15 und K16', () => { const k15 = loadTemplate('K15') 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.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']) assert.deepEqual(k16.blocks, []) assert.equal(k16.subject, '{{funktion_dein}} ist bereit') }) test('Die Mail-Werte je Funktion decken genau die Platzhalter der Vorlagen ab', () => { const ctx = { kundeName: 'Kunde Eins', projekt: 'Salon', invoiceUrl: 'https://invoice.stripe.test/i/1' } for (const key of reg.featureKeys()) { const feature = reg.featureConfig(key) const k15 = sf.bestellMail(feature, ctx) const k16 = sf.bereitMail(feature, ctx) assert.equal(k15.template, 'K15') 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 }) for (const nachricht of [k15, k16]) { const mail = renderMail(nachricht.template, nachricht.values, nachricht.blocks) assert.deepEqual(mail.problems, [], `${key}: ${nachricht.template}`) } // Preis aus dem Katalog der Funktion (feature.product), nicht aus News assert.equal(k15.values.preis, `${(config[feature.product].amountCents / 100).toFixed(2).replace('.', ',')} €`) assert.equal(k15.values.portal_url, `${config.publicBaseUrl}/dashboard.html#${feature.returnHash}`) assert.equal(k16.values.portal_url, k15.values.portal_url) } }) // ------------------------------------------------------------------------------------ Anhänge --- test('K15 ohne Anhänge: die Termine-Bestellung ruft keinen Registerhaken', async () => { const termine = config.features.termine const aufrufe = [] const alt = {} const spion = (name) => (...args) => { aufrufe.push(name) return name === 'mails.anhaenge' ? [] : true } for (const name of ['verfuegbar', 'bestellpruefung', 'unterlagenBereit', 'belehrung', 'freischaltbar']) { alt[name] = termine[name] termine[name] = spion(name) } termine.mails.anhaenge = spion('mails.anhaenge') try { const { mail } = await bezahle('termine', 'Anhang-Test') assert.ok(!mail.attachments || mail.attachments.length === 0, 'K15 trägt keinen Anhang') assert.deepEqual(aufrufe, []) } finally { for (const name of Object.keys(alt)) { if (alt[name] === undefined) delete termine[name] else termine[name] = alt[name] } delete termine.mails.anhaenge } }) test('keine Vorlagenwarnungen in allen Abläufen dieser Datei', () => { assert.deepEqual(warnungen, []) })