import { beforeEach, describe, expect, it, vi } from 'vitest'; import * as nodemailer from 'nodemailer'; import { TenderMailService } from './tender-mail.service'; /** * TenderMailService.spec — DkvMailService-Klon (Plan 12-02, Task 1, RED * first). Covers NOTIFY-04: mandanten-SMTP-Auflösung je Send * (`getDecryptedSmtpConfig(tenantId)`), ein frischer nodemailer-Transport * pro Send + `transport.close()` im finally (WR-01 Socket-Leck-Schutz), * kein Throw + falsy „skipped"-Rückgabe bei fehlender SmtpConfig, und * sektionierter Digest-Body ohne blinde `Number()`-Coercion von * `estimatedValue`. * * `nodemailer` wird gemockt (kein echter SMTP-Kontakt) — exakt wie im * `DkvMailService`-Vorbild beschrieben (createTransport → { sendMail, close }). */ vi.mock('nodemailer', () => ({ createTransport: vi.fn(), })); function makeSettingsService(smtpConfig: unknown) { return { getDecryptedSmtpConfig: vi.fn(async (_tenantId: string) => smtpConfig), }; } const BASE_SMTP_CONFIG = { host: 'smtp.example.com', port: 587, encryption: 'starttls', username: 'smtp-user', fromAddress: 'noreply@example.com', decryptedPassword: 'super-secret', }; beforeEach(() => { vi.clearAllMocks(); }); describe('TenderMailService — mandanten-SMTP-Auflösung (D-08)', () => { it('sendDigest calls settingsService.getDecryptedSmtpConfig with exactly the given tenantId, and builds the transport from that config', async () => { const sendMail = vi.fn().mockResolvedValue(undefined); const close = vi.fn(); (nodemailer.createTransport as unknown as ReturnType).mockReturnValue({ sendMail, close, }); const settingsService = makeSettingsService(BASE_SMTP_CONFIG); const service = new TenderMailService(settingsService as never); await service.sendDigest({ email: 'user@tenant.de' }, 'tenant-42', {}); expect(settingsService.getDecryptedSmtpConfig).toHaveBeenCalledWith('tenant-42'); expect(nodemailer.createTransport).toHaveBeenCalledWith( expect.objectContaining({ host: 'smtp.example.com', port: 587, secure: false, // starttls -> secure=false requireTLS: true, // starttls -> requireTLS=true auth: { user: 'smtp-user', pass: 'super-secret' }, }), ); }); it('resolves secure=true/requireTLS=false for ssl-tls encryption, and auth=undefined when no username is set', async () => { const sendMail = vi.fn().mockResolvedValue(undefined); (nodemailer.createTransport as unknown as ReturnType).mockReturnValue({ sendMail, close: vi.fn(), }); const settingsService = makeSettingsService({ ...BASE_SMTP_CONFIG, encryption: 'ssl-tls', username: null, decryptedPassword: null, }); const service = new TenderMailService(settingsService as never); await service.sendDigest({ email: 'user@tenant.de' }, 'tenant-1', {}); expect(nodemailer.createTransport).toHaveBeenCalledWith( expect.objectContaining({ secure: true, requireTLS: false, auth: undefined }), ); }); }); describe('TenderMailService — frischer Transport + close() (WR-01)', () => { it('calls nodemailer.createTransport exactly once per send, and calls transport.close() in finally even when sendMail rejects', async () => { const sendMail = vi.fn().mockRejectedValue(new Error('smtp boom')); const close = vi.fn(); (nodemailer.createTransport as unknown as ReturnType).mockReturnValue({ sendMail, close, }); const settingsService = makeSettingsService(BASE_SMTP_CONFIG); const service = new TenderMailService(settingsService as never); const result = await service.sendDigest({ email: 'user@tenant.de' }, 'tenant-1', {}); expect(nodemailer.createTransport).toHaveBeenCalledTimes(1); expect(close).toHaveBeenCalledTimes(1); expect(result).toBe(false); // send failure -> falsy, no throw }); it('sendInstant also builds exactly one fresh transport and closes it in finally', async () => { const sendMail = vi.fn().mockResolvedValue(undefined); const close = vi.fn(); (nodemailer.createTransport as unknown as ReturnType).mockReturnValue({ sendMail, close, }); const settingsService = makeSettingsService(BASE_SMTP_CONFIG); const service = new TenderMailService(settingsService as never); await service.sendInstant( { email: 'user@tenant.de' }, 'tenant-1', { name: 'Straßenbau NRW' }, [{ title: 'Tender A' }], ); expect(nodemailer.createTransport).toHaveBeenCalledTimes(1); expect(close).toHaveBeenCalledTimes(1); }); }); describe('TenderMailService — fehlende SmtpConfig (Skip, kein Throw)', () => { it('sendDigest sends nothing, does NOT throw, and returns a falsy indicator when getDecryptedSmtpConfig returns null', async () => { const settingsService = makeSettingsService(null); const service = new TenderMailService(settingsService as never); await expect( service.sendDigest({ email: 'user@tenant.de' }, 'tenant-1', {}), ).resolves.toBe(false); expect(nodemailer.createTransport).not.toHaveBeenCalled(); }); it('sendInstant sends nothing, does NOT throw, and returns a falsy indicator when getDecryptedSmtpConfig returns null', async () => { const settingsService = makeSettingsService(null); const service = new TenderMailService(settingsService as never); await expect( service.sendInstant( { email: 'user@tenant.de' }, 'tenant-1', { name: 'Profil' }, [{ title: 'Tender A' }], ), ).resolves.toBe(false); expect(nodemailer.createTransport).not.toHaveBeenCalled(); }); }); describe('TenderMailService — Betreff/Body (D-02, D-05, Sicherheit)', () => { it('sendDigest builds ONE mail to user.email, sectioned by profile name — estimatedValue is preserved verbatim, never blindly Number()-coerced', async () => { const sendMail = vi.fn().mockResolvedValue(undefined); (nodemailer.createTransport as unknown as ReturnType).mockReturnValue({ sendMail, close: vi.fn(), }); const settingsService = makeSettingsService(BASE_SMTP_CONFIG); const service = new TenderMailService(settingsService as never); const sections = { 'Straßenbau NRW': [ { title: 'Fahrbahnsanierung B1', buyerName: 'Stadt Beispielstadt', deadlineAt: new Date('2026-08-15T00:00:00Z'), estimatedValue: '123000.50', sourceUrl: 'https://example.com/tender/a', }, ], 'IT-Beschaffung': [ { title: 'Serverwartung', buyerName: null, deadlineAt: null, estimatedValue: null, sourceUrl: null, }, ], }; await service.sendDigest({ email: 'user@tenant.de' }, 'tenant-1', sections); expect(sendMail).toHaveBeenCalledTimes(1); const call = sendMail.mock.calls[0][0] as { to: string; subject: string; text: string; html: string }; expect(call.to).toBe('user@tenant.de'); expect(call.text).toContain('Straßenbau NRW'); expect(call.text).toContain('IT-Beschaffung'); expect(call.text).toContain('Fahrbahnsanierung B1'); expect(call.text).toContain('Serverwartung'); expect(call.text).toContain('123000.50'); // verbatim string, no Number() coercion expect(call.text).not.toMatch(/NaN/); expect(call.html).toContain('IT-Beschaffung'); }); it('sendInstant builds ONE mail for one profile with its full tender list', async () => { const sendMail = vi.fn().mockResolvedValue(undefined); (nodemailer.createTransport as unknown as ReturnType).mockReturnValue({ sendMail, close: vi.fn(), }); const settingsService = makeSettingsService(BASE_SMTP_CONFIG); const service = new TenderMailService(settingsService as never); await service.sendInstant( { email: 'user@tenant.de' }, 'tenant-1', { name: 'Straßenbau NRW' }, [ { title: 'Tender A', buyerName: 'Stadt X', deadlineAt: null, estimatedValue: null, sourceUrl: null }, { title: 'Tender B', buyerName: 'Stadt Y', deadlineAt: null, estimatedValue: null, sourceUrl: null }, ], ); expect(sendMail).toHaveBeenCalledTimes(1); const call = sendMail.mock.calls[0][0] as { to: string; subject: string; text: string }; expect(call.to).toBe('user@tenant.de'); expect(call.subject).toContain('Straßenbau NRW'); expect(call.text).toContain('Tender A'); expect(call.text).toContain('Tender B'); }); it('escapes tender titles/profile names in the HTML body — no unescaped HTML interpolation (email injection guard)', async () => { const sendMail = vi.fn().mockResolvedValue(undefined); (nodemailer.createTransport as unknown as ReturnType).mockReturnValue({ sendMail, close: vi.fn(), }); const settingsService = makeSettingsService(BASE_SMTP_CONFIG); const service = new TenderMailService(settingsService as never); const maliciousTitle = ''; await service.sendDigest({ email: 'user@tenant.de' }, 'tenant-1', { '': [{ title: maliciousTitle }], }); const call = sendMail.mock.calls[0][0] as { html: string }; expect(call.html).not.toContain(''); expect(call.html).not.toContain(''); expect(call.html).toContain('<img'); expect(call.html).toContain('<script>'); }); });