Files
Webklar-Kundenbereich/test/auftraegeMails.test.js
T
ClaudeandClaude Sonnet 5.5 1cbec5b32d Kontrolle: Betreff-Zusaetze Erinnerung und Kopie, Mail-Protokolle ohne Meldung und Adresse
Die Erinnerung beginnt mit 'Erinnerung: ', die Teamkopie mit 'Kopie: ' (der Mailer
nimmt dafuer ein optionales subjectPrefix). Die Protokollzeilen in mails.js nennen
nur Nummer und Fehlerkennung.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 15:24:59 +00:00

231 lines
14 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, `Kopie: ${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'))
const betreff = 'Ihre Auftragsbestätigung AB-2026-0001 – Website für Musterbäckerei'
assert.deepEqual(sink.sent.map((m) => m.subject), [`Erinnerung: ${betreff}`, `Kopie: Erinnerung: ${betreff}`])
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])
})
test('Protokoll der Mail-Wege: nur Nummer und Fehlerkennung, nie die Meldung des Transports und nie eine Adresse', async () => {
const meldung = 'Recipient address rejected: <k@example.invalid>'
// nur die Zeilen von mails.js; das Protokoll des Mailers (shared) ist nicht Gegenstand
const protokolliert = () => protokoll.mock.calls.map((c) => c.arguments.join(' ')).filter((zeile) => zeile.startsWith('[auftraege]')).join('\n')
const pruefe = (mitKennung) => {
const text = protokolliert()
assert.ok(text.includes('AB-2026-0001'), 'die Nummer steht im Protokoll')
for (const verboten of ['k@example.invalid', 'Recipient', 'rejected']) assert.ok(!text.includes(verboten), `das Protokoll enthält ${verboten}`)
if (mitKennung) assert.ok(text.includes('EENVELOPE'), 'die Fehlerkennung steht im Protokoll')
protokoll.mock.resetCalls()
}
const fehler = () => Object.assign(new Error(meldung), { code: 'EENVELOPE' })
// Teamkopie scheitert (zweiter Aufruf)
let aufrufe = 0
setMailTransportForTests({ async sendMail(mail) { aufrufe += 1; if (aufrufe === 2) throw fehler(); return sink.sendMail(mail) } })
await sendeAuftragMail(AB, { pdf: AB_PDF, token: TOKEN })
pruefe(false)
// K32 und T22 scheitern beide
setMailTransportForTests({ async sendMail() { throw fehler() } })
await sendeBestaetigtMails(BESTAETIGT())
assert.ok(protokolliert().includes('K32') && protokolliert().includes('T22'))
pruefe(true)
})