Aufträge L5: Mails zur Auftragsbestätigung (K31 ohne BCC mit Teamkopie ohne Link, K32, T22)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f0402a7ea9
commit
19abac0373
3 files changed
+409
No files matched your search
@@ -0,0 +1,44 @@
|
||||
import { AUFTRAEGE_COLLECTIONS } from '../server/services/auftraege/schema.js'
|
||||
import { hashToken } from '../server/services/auftraege/token.js'
|
||||
|
||||
// Testdaten der Strang-L5-Tests: gesendete AB, Token, Schema und Datumsfelder für den Appwrite-Nachbau.
|
||||
// Importiert nie server/config.js: die Tests setzen die Umgebung erst danach (useTestEnv).
|
||||
const attribute = AUFTRAEGE_COLLECTIONS.auftraege.attributes
|
||||
export const AUFTRAEGE_SCHEMA = { auftraege: attribute.map((a) => a.key) }
|
||||
export const AUFTRAEGE_DATETIMES = { auftraege: attribute.filter((a) => a.type === 'datetime').map((a) => a.key) }
|
||||
|
||||
export const TOKEN = 'T'.repeat(43)
|
||||
export const TOKEN_NEU = 'U'.repeat(43)
|
||||
export const SHA = 'a'.repeat(64)
|
||||
export const UNGUELTIG = 'Dieser Link ist ungültig oder abgelaufen.'
|
||||
|
||||
export const kunde = (kundenart = 'privat', extra = {}) => ({
|
||||
name: 'Erika Muster',
|
||||
firma: kundenart === 'unternehmen' ? 'Muster GmbH' : '',
|
||||
strasse: 'Hauptstr. 1',
|
||||
plz: '67722',
|
||||
ort: 'Winnweiler',
|
||||
land: 'DE',
|
||||
email: 'k@example.invalid',
|
||||
kundenart,
|
||||
kundennummer: 'K-100',
|
||||
...extra,
|
||||
})
|
||||
|
||||
/** Gesendete Festpreis-AB AB-2026-0001 des Kunden c1 (Form „Auftrag“ aus V3), gesendet am 06.10.2026 (Berlin 12:00), Link bis 05.12.2026. */
|
||||
export const abDokument = (over = {}) => ({
|
||||
id: 'ab1', nummer: 'AB-2026-0001', jahr: 2026, laufnummer: 1, status: 'versendet',
|
||||
kundenId: 'c1', kunde: kunde(), projektId: 'p1', titel: 'Website für Musterbäckerei', zeitquelle: [],
|
||||
art: 'festpreis', positionen: [{ beschreibung: 'Website', menge: 1, einzelpreisCents: 80000 }],
|
||||
stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000,
|
||||
zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' }, zahlungszielTage: 14,
|
||||
auftragVom: '2026-10-05', auftragWeg: 'telefon', startAm: '', fertigBis: '',
|
||||
ersetztId: '', ersetztDurchId: '', texteFassung: '2026-10-06', agbFassung: '2026-09-30',
|
||||
pdfDatei: 'c1/ab1.pdf', pdfSha256: SHA,
|
||||
gesendetAm: '2026-10-06T10:00:00.000Z', gesendetAn: 'k@example.invalid', mailStatus: 'gesendet', mailFehler: '',
|
||||
tokenHash: hashToken(TOKEN), tokenGueltigBis: '2026-12-05T10:00:00.000Z',
|
||||
bestaetigtAm: null, bestaetigtUeber: '', bestaetigtPdfSha256: '', fruehbeginn: false, fruehbeginnFassung: '',
|
||||
storniertAm: null, widerrufenAm: null,
|
||||
erstelltVon: 'kenso@webklar.com', erstelltAm: '2026-10-06T09:00:00.000Z', geaendertAm: '2026-10-06T10:00:00.000Z',
|
||||
...over,
|
||||
})
|
||||
@@ -0,0 +1,204 @@
|
||||
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])
|
||||
})
|
||||
Reference in new issue
Block a user