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

205 lines
12 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 { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { mkdtempSync, writeFileSync } from 'node:fs'
import { useTestEnv } from './support/testEnv.js'
import { mailSink } from './support/mailSink.js'
import { TOKEN, abDokument, kunde } from './auftraegeBestaetigungHilfe.js'
// mails.js: K31 (mit Teamkopie), K32, T22 aufbauen und über die Senke verschicken. Kein Datenbankzugriff: jede versehentliche Anfrage scheitert sofort.
process.env.AUFTRAEGE_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'auftraege-mails-'))
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1/v1', stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { loadTemplate, renderMail } = await import('../server/services/mailTemplates.js')
const { euroDe } = await import('../server/services/finance/format.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { AGB_FASSUNG } = await import('../server/services/auftraege/konstanten.js')
const { FRUEHBEGINN_TEXT } = await import('../server/services/auftraege/texte.js')
const { speicherePdf } = await import('../server/services/auftraege/ablage.js')
const { agbPdfPfad, k31Mail, k32Mail, sendeAuftragMail, sendeBestaetigtMails, setzeAgbVerzeichnisFuerTests, t22Mail } = await import('../server/services/auftraege/mails.js')
const agbVerzeichnis = mkdtempSync(path.join(os.tmpdir(), 'auftraege-agb-'))
const AB_PDF = Buffer.from('%PDF-1.4 AB-Test')
const AGB_PDF = Buffer.from('%PDF-1.4 AGB-Test')
writeFileSync(path.join(agbVerzeichnis, 'agb-2026-09-30.pdf'), AGB_PDF)
setzeAgbVerzeichnisFuerTests(agbVerzeichnis)
after(() => {
setzeAgbVerzeichnisFuerTests(null)
setMailTransportForTests(null)
})
let sink
let protokoll
beforeEach(() => {
sink = mailSink()
setMailTransportForTests(sink)
protokoll = mock.method(console, 'error', () => {})
})
afterEach(() => mock.restoreAll())
const adressen = (liste) => liste.map((a) => a.address)
const AB = abDokument()
const AUFWAND = { art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 10000, schaetzungMinuten: 600, kunde: kunde('unternehmen') }
const BESTAETIGT = (over = {}) => abDokument({ status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z', bestaetigtUeber: 'link', ...over })
const K31_BETREFF = 'Ihre Auftragsbestätigung AB-2026-0001 – Website für Musterbäckerei'
const T22_BETREFF = '[Aufträge] Auftrag AB-2026-0001 bestätigt: Erika Muster'
const WERTE_K31 = { name: 'Erika Muster', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', datum: '06.10.2026', betrag: euroDe(80000), gueltig_bis: '05.12.2026' }
test('agbPdfPfad: Vorgabe ist public/rechtliches/agb-<Fassung>.pdf; eine ungültige Fassung wirft', () => {
setzeAgbVerzeichnisFuerTests(null)
try {
const rechtliches = fileURLToPath(new URL('../public/rechtliches/', import.meta.url))
assert.equal(agbPdfPfad(), path.join(rechtliches, `agb-${AGB_FASSUNG}.pdf`))
assert.equal(agbPdfPfad('2025-01-01'), path.join(rechtliches, 'agb-2025-01-01.pdf'))
for (const fassung of ['', '../x', 'a/b', 'x'.repeat(21)]) assert.throws(() => agbPdfPfad(fassung), TypeError, fassung)
} finally {
setzeAgbVerzeichnisFuerTests(agbVerzeichnis)
}
})
test('k31Mail: Link im Fragment; als Teamkopie ohne Link und ohne Kundenblöcke; Aufwand als Erinnerung (Berliner Tag des Sendens)', () => {
assert.deepEqual(k31Mail(AB, { token: TOKEN }), {
template: 'K31',
values: { ...WERTE_K31, link: `${config.publicBaseUrl}/auftrag.html#t=${TOKEN}` },
blocks: { erinnerung: false, privat: true, festpreis: true, aufwand: false, teamkopie: false, kundenmail: true },
})
// auch wenn man das Token mitgibt: die Teamkopie enthält es nie
assert.deepEqual(k31Mail(AB, { token: TOKEN, teamkopie: true }), {
template: 'K31',
values: { ...WERTE_K31, link: '' },
blocks: { erinnerung: false, privat: false, festpreis: true, aufwand: false, teamkopie: true, kundenmail: false },
})
const nachts = k31Mail(abDokument({ ...AUFWAND, gesendetAm: '2026-10-06T22:30:00.000Z' }), { token: TOKEN, erinnerung: true })
assert.equal(nachts.values.datum, '07.10.2026')
assert.equal(nachts.values.betrag, `${euroDe(10000)} pro Stunde`)
assert.deepEqual(nachts.blocks, { erinnerung: true, privat: false, festpreis: false, aufwand: true, teamkopie: false, kundenmail: true })
})
test('k32Mail und t22Mail: Zeitpunkt in Berliner Zeit, Frühbeginn-Erklärung wörtlich, Blöcke je Kundenart und Wunsch', () => {
const ja = BESTAETIGT({ fruehbeginn: true })
assert.deepEqual(k32Mail(ja), {
template: 'K32',
values: { name: 'Erika Muster', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', zeitpunkt: '07.10.2026, 10:30', fruehester_beginn: '', fruehbeginn_text: FRUEHBEGINN_TEXT },
blocks: { fruehbeginn: true, ohne_fruehbeginn: false },
})
const nein = BESTAETIGT({ bestaetigtUeber: 'portal' })
assert.equal(k32Mail(nein).values.fruehester_beginn, '21.10.2026')
assert.equal(k32Mail(nein).values.fruehbeginn_text, '')
assert.deepEqual(k32Mail(nein).blocks, { fruehbeginn: false, ohne_fruehbeginn: true })
const firma = BESTAETIGT({ kunde: kunde('unternehmen') })
assert.deepEqual(k32Mail(firma).blocks, { fruehbeginn: false, ohne_fruehbeginn: false })
assert.deepEqual([k32Mail(firma).values.fruehester_beginn, k32Mail(firma).values.fruehbeginn_text], ['', ''])
assert.deepEqual(t22Mail(nein), {
template: 'T22',
values: { kunde: 'Erika Muster', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', zeitpunkt: '07.10.2026, 10:30', ueber: 'Kundenportal', woms_link: `${config.womsBaseUrl}/customers/c1?tab=invoices`, fruehester_beginn: '21.10.2026' },
blocks: { privat_fruehbeginn: false, privat_ohne_fruehbeginn: true },
})
assert.equal(t22Mail(firma).values.kunde, 'Muster GmbH')
assert.equal(t22Mail(ja).values.ueber, 'Bestätigungslink')
assert.deepEqual(t22Mail(ja).blocks, { privat_fruehbeginn: true, privat_ohne_fruehbeginn: false })
})
test('Builder und Vorlagen passen zusammen: alle Platzhalter und Blöcke belegt, keine Unstimmigkeiten beim Rendern', () => {
const nachrichten = [
k31Mail(AB, { token: TOKEN }),
k31Mail(AB, { token: TOKEN, teamkopie: true }),
k31Mail(abDokument(AUFWAND), { token: TOKEN, erinnerung: true }),
k31Mail(abDokument(AUFWAND), { erinnerung: true, teamkopie: true }),
k32Mail(BESTAETIGT({ fruehbeginn: true })),
k32Mail(BESTAETIGT()),
t22Mail(BESTAETIGT({ fruehbeginn: true })),
t22Mail(BESTAETIGT({ kunde: kunde('unternehmen') })),
]
for (const { template, values, blocks } of nachrichten) {
const vorlage = loadTemplate(template)
assert.deepEqual(Object.keys(values).sort(), [...vorlage.placeholders].sort(), template)
assert.deepEqual(Object.keys(blocks).sort(), [...vorlage.blocks].sort(), template)
assert.deepEqual(renderMail(template, values, blocks).problems, [], template)
}
})
test('sendeAuftragMail: K31 an den Kunden ohne BCC (Link im Fragment, AB-PDF und AGB), danach die Teamkopie ohne Link, Token und Anhänge', async () => {
await sendeAuftragMail(AB, { pdf: AB_PDF, token: TOKEN })
assert.equal(sink.sent.length, 2)
const [kundenmail, teamkopie] = sink.sent
assert.deepEqual(adressen(kundenmail.to), ['k@example.invalid'])
assert.ok(!kundenmail.bcc || kundenmail.bcc.length === 0, 'kein BCC ans Team: die Mail trägt den Bestätigungslink')
assert.equal(kundenmail.subject, K31_BETREFF)
assert.ok(kundenmail.text.includes(`Auftrag bestätigen: ${config.publicBaseUrl}/auftrag.html#t=${TOKEN}`))
assert.deepEqual(kundenmail.attachments.map((a) => [a.filename, a.contentType]), [['AB-2026-0001.pdf', 'application/pdf'], ['WEBklar-AGB-2026-09-30.pdf', 'application/pdf']])
assert.ok(Buffer.from(kundenmail.attachments[0].content, 'base64').equals(AB_PDF))
assert.ok(Buffer.from(kundenmail.attachments[1].content, 'base64').equals(AGB_PDF))
assert.deepEqual(adressen(teamkopie.to), config.chatNotify.emails)
assert.equal(teamkopie.subject, K31_BETREFF)
assert.ok(teamkopie.text.split('\n').includes('Kopie für das Team – der Bestätigungslink steht nur in der Mail an den Kunden.'))
assert.ok(!teamkopie.attachments || teamkopie.attachments.length === 0, 'die AB-PDF liegt im Ticketsystem')
const geheim = JSON.stringify(teamkopie)
for (const spur of [TOKEN, 'auftrag.html#t=', 'auftrag.html?t=']) assert.ok(!geheim.includes(spur), `die Teamkopie enthält ${spur}`)
})
test('sendeAuftragMail: die Erinnerung geht auch als Teamkopie raus; ohne pdf wird die gespeicherte Datei aus der Ablage gelesen', async () => {
const { pdfDatei } = await speicherePdf('c1', 'ab-erinnerung', AB_PDF)
await sendeAuftragMail(abDokument({ ...AUFWAND, pdfDatei }), { token: TOKEN, erinnerung: true })
assert.equal(sink.sent.length, 2)
for (const mail of sink.sent) assert.ok(mail.text.includes('Wir haben Ihnen diese Auftragsbestätigung am 06.10.2026 schon einmal geschickt'))
assert.ok(Buffer.from(sink.sent[0].attachments[0].content, 'base64').equals(AB_PDF))
assert.ok(!JSON.stringify(sink.sent[1]).includes(TOKEN))
})
test('sendeAuftragMail: wirft und verschickt nichts bei fehlender AGB-Datei, falschem Token oder fehlender Adresse; scheitert die Kundenmail, geht keine Teamkopie raus', async () => {
await assert.rejects(sendeAuftragMail(abDokument({ agbFassung: '1999-01-01' }), { pdf: AB_PDF, token: TOKEN }), /AGB-PDF 1999-01-01 nicht lesbar/)
await assert.rejects(sendeAuftragMail(AB, { pdf: AB_PDF, token: 'kurz' }), TypeError)
await assert.rejects(sendeAuftragMail(abDokument({ gesendetAn: '', kunde: kunde('privat', { email: '' }) }), { pdf: AB_PDF, token: TOKEN }), /E-Mail-Adresse/)
assert.equal(sink.sent.length, 0)
let versuche = 0
setMailTransportForTests({ async sendMail() { versuche += 1; throw new Error('SMTP 451') } })
await assert.rejects(sendeAuftragMail(AB, { pdf: AB_PDF, token: TOKEN }), /SMTP 451/)
assert.equal(versuche, 1, 'nach dem Fehler der Kundenmail keine Teamkopie')
})
test('sendeAuftragMail: scheitert nur die Teamkopie, gilt der Versand als erfolgreich (nur Protokoll mit der Nummer)', async () => {
let aufrufe = 0
setMailTransportForTests({
async sendMail(mail) {
aufrufe += 1
if (aufrufe === 2) throw new Error('SMTP 451')
return sink.sendMail(mail)
},
})
await sendeAuftragMail(AB, { pdf: AB_PDF, token: TOKEN })
assert.equal(aufrufe, 2)
assert.deepEqual(sink.sent.map((m) => adressen(m.to)), [['k@example.invalid']])
assert.ok(protokoll.mock.calls.some((c) => c.arguments.join(' ').includes('Teamkopie') && c.arguments.join(' ').includes('AB-2026-0001')))
})
test('sendeBestaetigtMails: K32 an den Kunden (Kopie ans Team), danach T22 an das Team', async () => {
await sendeBestaetigtMails(BESTAETIGT({ fruehbeginn: true }))
assert.deepEqual(sink.sent.map((m) => m.subject), ['Ihre Bestätigung zu AB-2026-0001 ist eingegangen', T22_BETREFF])
const [k32, t22] = sink.sent
assert.deepEqual(adressen(k32.to), ['k@example.invalid'])
assert.deepEqual(adressen(k32.bcc), config.chatNotify.emails)
assert.deepEqual(adressen(t22.to), config.chatNotify.emails)
assert.ok(k32.text.includes('Bestätigt am: 07.10.2026, 10:30 Uhr'))
assert.ok(t22.text.includes(`Im Ticketsystem öffnen: ${config.womsBaseUrl}/customers/c1?tab=invoices`))
})
test('sendeBestaetigtMails wirft nie: scheitert K32, geht T22 trotzdem raus; ohne Adresse entfällt K32; Fehler stehen mit der Nummer im Protokoll', async () => {
let aufrufe = 0
setMailTransportForTests({
async sendMail(mail) {
aufrufe += 1
if (aufrufe === 1) throw new Error('SMTP 451')
return sink.sendMail(mail)
},
})
await sendeBestaetigtMails(BESTAETIGT())
assert.deepEqual(sink.sent.map((m) => m.subject), [T22_BETREFF])
assert.ok(protokoll.mock.calls.some((c) => c.arguments.join(' ').includes('K32') && c.arguments.join(' ').includes('AB-2026-0001')))
sink.sent.length = 0
setMailTransportForTests(sink)
await sendeBestaetigtMails(BESTAETIGT({ gesendetAn: '', kunde: kunde('privat', { email: '' }) }))
assert.deepEqual(sink.sent.map((m) => m.subject), [T22_BETREFF])
})