import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import type { Payload } from 'payload' import type { Tenant } from '@/payload-types' const mockSendMail = vi.fn(async () => ({ messageId: 'mocked-id' })) // eslint-disable-next-line @typescript-eslint/no-unused-vars const mockCreateTransport = vi.fn((_options?: unknown) => ({ sendMail: mockSendMail })) vi.mock('nodemailer', () => ({ __esModule: true, default: { createTransport: (options: unknown) => mockCreateTransport(options), }, })) import { sendTenantEmail, invalidateTenantEmailCache, invalidateGlobalEmailCache, } from '@/lib/email/tenant-email-service' describe('tenant email service', () => { let payload: Payload let mockFindByID: ReturnType let mockCreate: ReturnType beforeEach(() => { mockSendMail.mockClear() mockCreateTransport.mockClear() mockFindByID = vi.fn() mockCreate = vi.fn().mockResolvedValue({ id: 1 }) payload = { findByID: mockFindByID, create: mockCreate, update: vi.fn().mockResolvedValue({}), } as unknown as Payload process.env.SMTP_HOST = 'smtp.global.test' process.env.SMTP_PORT = '587' process.env.SMTP_SECURE = 'false' process.env.SMTP_USER = 'global-user' process.env.SMTP_PASS = 'global-pass' process.env.SMTP_FROM_ADDRESS = 'noreply@example.com' invalidateGlobalEmailCache() }) afterEach(() => { // Restore NODE_ENV after each test delete process.env.EMAIL_DELIVERY_DISABLED }) describe('with EMAIL_DELIVERY_DISABLED=false (production mode)', () => { beforeEach(() => { // Override test environment to simulate production vi.stubEnv('NODE_ENV', 'production') vi.stubEnv('EMAIL_DELIVERY_DISABLED', 'false') }) afterEach(() => { vi.unstubAllEnvs() }) it('falls back to global SMTP configuration when tenant has no custom SMTP', async () => { const tenant = { id: 1, slug: 'tenant-a', name: 'Tenant A', email: { useCustomSmtp: false, }, } as Tenant mockFindByID.mockResolvedValue(tenant) const result = await sendTenantEmail(payload, tenant.id, { to: 'user@example.com', subject: 'Test', text: 'Hello from test', }) expect(result.success).toBe(true) expect(mockCreateTransport).toHaveBeenCalledTimes(1) expect(mockCreateTransport).toHaveBeenCalledWith({ host: 'smtp.global.test', port: 587, secure: false, auth: { user: 'global-user', pass: 'global-pass', }, }) expect(mockSendMail).toHaveBeenCalledWith( expect.objectContaining({ from: '"Tenant A" ', to: 'user@example.com', }), ) }) it('uses cached tenant-specific transporter and re-creates it after invalidation', async () => { const tenant = { id: 42, slug: 'tenant-b', name: 'Tenant B', email: { useCustomSmtp: true, fromAddress: 'info@tenant-b.de', fromName: 'Tenant B', smtp: { host: 'smtp.tenant-b.de', port: 465, secure: true, user: 'tenant-user', pass: 'tenant-pass', }, }, } as Tenant mockFindByID.mockResolvedValue(tenant) await sendTenantEmail(payload, tenant.id, { to: 'recipient@example.com', subject: 'Hi', text: 'First email', }) expect(mockCreateTransport).toHaveBeenCalledTimes(1) expect(mockCreateTransport).toHaveBeenCalledWith({ host: 'smtp.tenant-b.de', port: 465, secure: true, auth: { user: 'tenant-user', pass: 'tenant-pass', }, }) mockCreateTransport.mockClear() await sendTenantEmail(payload, tenant.id, { to: 'recipient@example.com', subject: 'Hi again', text: 'Second email', }) expect(mockCreateTransport).not.toHaveBeenCalled() invalidateTenantEmailCache(tenant.id) await sendTenantEmail(payload, tenant.id, { to: 'recipient@example.com', subject: 'After invalidation', text: 'Third email', }) expect(mockCreateTransport).toHaveBeenCalledTimes(1) }) }) describe('with EMAIL_DELIVERY_DISABLED=true (test mode)', () => { it('skips SMTP delivery and returns synthetic message ID', async () => { // NODE_ENV=test is default in vitest, which disables email delivery const tenant = { id: 1, slug: 'tenant-a', name: 'Tenant A', email: { useCustomSmtp: false, }, } as Tenant mockFindByID.mockResolvedValue(tenant) const result = await sendTenantEmail(payload, tenant.id, { to: 'user@example.com', subject: 'Test', text: 'Hello from test', }) expect(result.success).toBe(true) expect(result.messageId).toMatch(/^test-message-\d+$/) // SMTP should NOT be called in test mode expect(mockCreateTransport).not.toHaveBeenCalled() expect(mockSendMail).not.toHaveBeenCalled() }) it('creates email log even when delivery is disabled', async () => { const tenant = { id: 1, slug: 'tenant-a', name: 'Tenant A', email: { useCustomSmtp: false, }, } as Tenant mockFindByID.mockResolvedValue(tenant) await sendTenantEmail(payload, tenant.id, { to: 'user@example.com', subject: 'Test', text: 'Hello from test', }) // Email log should still be created expect(mockCreate).toHaveBeenCalledWith( expect.objectContaining({ collection: 'email-logs', data: expect.objectContaining({ to: 'user@example.com', subject: 'Test', status: 'pending', }), }), ) }) }) describe('cache invalidation', () => { it('invalidates tenant-specific cache', () => { // This is a simple function that clears cache - just verify it doesn't throw expect(() => invalidateTenantEmailCache(42)).not.toThrow() }) it('invalidates global cache', () => { expect(() => invalidateGlobalEmailCache()).not.toThrow() }) }) })