205 lines
12 KiB
JavaScript
205 lines
12 KiB
JavaScript
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])
|
||
})
|