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-.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: ' // 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) })