// Mock the mailgun config before any imports jest.mock('../mailgun.config', () => { const defaultConfig = { apiKey: 'test-api-key', domain: 'test.mailgun.org', baseUrl: 'https://api.mailgun.net/v3', fromName: 'Test App', fromEmail: 'test@example.com', }; return { getMailgunConfig: jest.fn(() => defaultConfig), }; }); // Mock the app config jest.mock('../../config/unified.config', () => ({ unifiedConfig: { app: { baseUrl: 'http://localhost:3000', }, }, getAppConfig: jest.fn(() => ({ baseUrl: 'http://localhost:3000' })), })); // Mock global fetch and related APIs global.fetch = jest.fn(); const MockFormData = jest.fn().mockImplementation(() => ({ append: jest.fn(), })); (global as any).FormData = MockFormData; global.btoa = jest .fn() .mockImplementation(str => Buffer.from(str).toString('base64')); // Import the service after mocks are set up import { MailgunService } from '../mailgun.service'; import { getMailgunConfig } from '../mailgun.config'; import { logger } from '../logging'; const mockGetMailgunConfig = getMailgunConfig as jest.MockedFunction< typeof getMailgunConfig >; mockGetMailgunConfig.mockReturnValue({ apiKey: 'test-api-key', domain: 'test.mailgun.org', baseUrl: 'https://api.mailgun.net/v3', fromName: 'Test App', fromEmail: 'test@example.com', }); describe('MailgunService', () => { let mockFetch: jest.MockedFunction; let mockFormData: jest.MockedFunction; let warnSpy: jest.SpyInstance; let infoSpy: jest.SpyInstance; let errorSpy: jest.SpyInstance; let debugSpy: jest.SpyInstance; const mockConfig = { apiKey: 'test-api-key', domain: 'test.mailgun.org', baseUrl: 'https://api.mailgun.net/v3', fromName: 'Test App', fromEmail: 'test@example.com', }; beforeEach(() => { jest.clearAllMocks(); mockFetch = fetch as jest.MockedFunction; mockFormData = MockFormData; warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined); infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => undefined); errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => undefined); debugSpy = jest.spyOn(logger, 'debug').mockImplementation(() => undefined); }); describe('constructor', () => { test('should initialize with development mode warning when not configured', () => { const unconfiguredConfig = { apiKey: undefined, domain: undefined, baseUrl: 'https://api.mailgun.net/v3', fromName: 'Test App', fromEmail: undefined, }; mockGetMailgunConfig.mockReturnValue(unconfiguredConfig); new MailgunService(); expect(warnSpy).toHaveBeenCalledWith( 'Mailgun running in development mode; emails will not be delivered', 'MAILGUN', { missingFields: [ 'VITE_MAILGUN_API_KEY', 'VITE_MAILGUN_DOMAIN', 'VITE_MAILGUN_FROM_EMAIL', ], domain: undefined, } ); expect(infoSpy).toHaveBeenCalledWith( 'To enable email delivery, configure Mailgun environment variables', 'MAILGUN', { requiredVariables: [ 'VITE_MAILGUN_API_KEY', 'VITE_MAILGUN_DOMAIN', 'VITE_MAILGUN_FROM_EMAIL', ], } ); }); test('should initialize with production mode message when configured', () => { mockGetMailgunConfig.mockReturnValue(mockConfig); new MailgunService(); expect(infoSpy).toHaveBeenCalledWith( 'Mailgun configured for delivery', 'MAILGUN', { domain: 'test.mailgun.org', fromEmail: 'test@example.com', } ); expect(warnSpy).not.toHaveBeenCalled(); }); }); describe('sendEmail', () => { let service: MailgunService; beforeEach(() => { service = new MailgunService(); }); test('should send email successfully', async () => { const mockResponse = { ok: true, json: jest.fn().mockResolvedValue({ id: 'test-message-id' }), }; mockFetch.mockResolvedValue(mockResponse as any); const template = { subject: 'Test Subject', html: '

Test HTML

', text: 'Test Text', }; const result = await service.sendEmail('test@example.com', template); expect(result).toBe(true); expect(mockFetch).toHaveBeenCalledWith( 'https://api.mailgun.net/v3/test.mailgun.org/messages', expect.objectContaining({ method: 'POST', headers: { Authorization: 'Basic YXBpOnRlc3QtYXBpLWtleQ==', // base64 of "api:test-api-key" }, body: expect.objectContaining({ append: expect.any(Function), }), }) ); expect(infoSpy).toHaveBeenCalledWith( 'Email sent via Mailgun', 'MAILGUN', { to: 'test@example.com', subject: 'Test Subject', messageId: 'test-message-id', } ); }); test('should handle email sending failure', async () => { const mockResponse = { ok: false, status: 400, text: jest.fn().mockResolvedValue('Bad Request'), }; mockFetch.mockResolvedValue(mockResponse as any); const template = { subject: 'Test Subject', html: '

Test HTML

', }; const result = await service.sendEmail('test@example.com', template); expect(result).toBe(false); expect(errorSpy).toHaveBeenCalledWith( 'Mailgun email send failed', 'MAILGUN', { domain: 'test.mailgun.org' }, expect.any(Error) ); }); test('should handle network errors', async () => { mockFetch.mockRejectedValue(new Error('Network error')); const template = { subject: 'Test Subject', html: '

Test HTML

', }; const result = await service.sendEmail('test@example.com', template); expect(result).toBe(false); expect(errorSpy).toHaveBeenCalledWith( 'Mailgun email send failed', 'MAILGUN', { domain: 'test.mailgun.org' }, expect.any(Error) ); }); test('should properly format FormData', async () => { const mockFormDataInstance = { append: jest.fn(), }; mockFormData.mockReturnValue(mockFormDataInstance as any); const mockResponse = { ok: true, json: jest.fn().mockResolvedValue({ id: 'test-id' }), }; mockFetch.mockResolvedValue(mockResponse as any); const template = { subject: 'Test Subject', html: '

Test HTML

', text: 'Test Text', }; await service.sendEmail('test@example.com', template); expect(mockFormDataInstance.append).toHaveBeenCalledWith( 'from', 'Test App ' ); expect(mockFormDataInstance.append).toHaveBeenCalledWith( 'to', 'test@example.com' ); expect(mockFormDataInstance.append).toHaveBeenCalledWith( 'subject', 'Test Subject' ); expect(mockFormDataInstance.append).toHaveBeenCalledWith( 'html', '

Test HTML

' ); expect(mockFormDataInstance.append).toHaveBeenCalledWith( 'text', 'Test Text' ); }); test('should work without text field in template', async () => { const mockFormDataInstance = { append: jest.fn(), }; mockFormData.mockReturnValue(mockFormDataInstance as any); const mockResponse = { ok: true, json: jest.fn().mockResolvedValue({ id: 'test-id' }), }; mockFetch.mockResolvedValue(mockResponse as any); const template = { subject: 'Test Subject', html: '

Test HTML

', }; await service.sendEmail('test@example.com', template); expect(mockFormDataInstance.append).not.toHaveBeenCalledWith( 'text', expect.anything() ); }); test('logs preview and skips send when configuration is missing', async () => { const unconfiguredConfig = { apiKey: undefined, domain: undefined, baseUrl: 'https://api.mailgun.net/v3', fromName: 'Test App', fromEmail: undefined, }; mockGetMailgunConfig.mockReturnValue(unconfiguredConfig); const unconfiguredService = new MailgunService(); const template = { subject: 'Test Subject', html: '

Test HTML

', }; const result = await unconfiguredService.sendEmail( 'test@example.com', template ); expect(result).toBe(false); expect(mockFetch).not.toHaveBeenCalled(); expect(warnSpy).toHaveBeenCalledWith( 'Skipping email send; Mailgun is not configured', 'MAILGUN', expect.objectContaining({ to: 'test@example.com', missingFields: [ 'VITE_MAILGUN_API_KEY', 'VITE_MAILGUN_DOMAIN', 'VITE_MAILGUN_FROM_EMAIL', ], preview: true, }) ); expect(debugSpy).toHaveBeenCalledWith( 'Mailgun email preview', 'MAILGUN', expect.objectContaining({ to: 'test@example.com', subject: 'Test Subject', html: '

Test HTML

', }) ); mockGetMailgunConfig.mockReturnValue(mockConfig); }); }); describe('sendVerificationEmail', () => { let service: MailgunService; beforeEach(() => { service = new MailgunService(); service.sendEmail = jest.fn().mockResolvedValue(true); }); test('should send verification email with correct URL and template', async () => { const result = await service.sendVerificationEmail( 'user@example.com', 'verification-token' ); expect(result).toBe(true); expect(service.sendEmail).toHaveBeenCalledWith( 'user@example.com', expect.objectContaining({ subject: 'Verify Your Email - Medication Reminder', html: expect.stringContaining( 'http://localhost:3000/verify-email?token=verification-token' ), text: expect.stringContaining( 'http://localhost:3000/verify-email?token=verification-token' ), }) ); }); test('should include proper HTML structure in verification email', async () => { await service.sendVerificationEmail('user@example.com', 'test-token'); const mockCall = (service.sendEmail as jest.Mock).mock.calls[0]; const template = mockCall[1]; expect(template.html).toContain('Verify Your Email Address'); expect(template.html).toContain('Verify Email Address'); expect(template.html).toContain('This link will expire in 24 hours'); expect(template.html).toContain('color: #4f46e5'); }); test('should include text version in verification email', async () => { await service.sendVerificationEmail('user@example.com', 'test-token'); const mockCall = (service.sendEmail as jest.Mock).mock.calls[0]; const template = mockCall[1]; expect(template.text).toContain( 'Verify Your Email - Medication Reminder' ); expect(template.text).toContain('This link will expire in 24 hours'); expect(template.text).not.toContain('<'); }); }); describe('sendPasswordResetEmail', () => { let service: MailgunService; beforeEach(() => { service = new MailgunService(); service.sendEmail = jest.fn().mockResolvedValue(true); }); test('should send password reset email with correct URL and template', async () => { const result = await service.sendPasswordResetEmail( 'user@example.com', 'reset-token' ); expect(result).toBe(true); expect(service.sendEmail).toHaveBeenCalledWith( 'user@example.com', expect.objectContaining({ subject: 'Reset Your Password - Medication Reminder', html: expect.stringContaining( 'http://localhost:3000/reset-password?token=reset-token' ), text: expect.stringContaining( 'http://localhost:3000/reset-password?token=reset-token' ), }) ); }); test('should include proper HTML structure in password reset email', async () => { await service.sendPasswordResetEmail('user@example.com', 'test-token'); const mockCall = (service.sendEmail as jest.Mock).mock.calls[0]; const template = mockCall[1]; expect(template.html).toContain('Reset Your Password'); expect(template.html).toContain('Reset Password'); expect(template.html).toContain('This link will expire in 1 hour'); expect(template.html).toContain("If you didn't request this"); }); test('should include text version in password reset email', async () => { await service.sendPasswordResetEmail('user@example.com', 'test-token'); const mockCall = (service.sendEmail as jest.Mock).mock.calls[0]; const template = mockCall[1]; expect(template.text).toContain( 'Reset Your Password - Medication Reminder' ); expect(template.text).toContain('This link will expire in 1 hour'); expect(template.text).toContain("If you didn't request this"); expect(template.text).not.toContain('<'); }); }); describe('getConfigurationStatus', () => { test('should return configured status when all fields are present', () => { const service = new MailgunService(); const status = service.getConfigurationStatus(); expect(status).toEqual({ configured: true, mode: 'production', domain: 'test.mailgun.org', fromEmail: 'test@example.com', missingFields: [], }); }); test('should return unconfigured status when fields are missing', () => { const unconfiguredConfig = { apiKey: undefined, domain: undefined, baseUrl: 'https://api.mailgun.net/v3', fromName: 'Test App', fromEmail: undefined, }; mockGetMailgunConfig.mockReturnValue(unconfiguredConfig); const service = new MailgunService(); const status = service.getConfigurationStatus(); expect(status).toEqual({ configured: false, mode: 'development', domain: undefined, fromEmail: undefined, missingFields: [ 'VITE_MAILGUN_API_KEY', 'VITE_MAILGUN_DOMAIN', 'VITE_MAILGUN_FROM_EMAIL', ], }); }); test('should return unconfigured status when fields are empty strings', () => { const emptyConfig = { apiKey: '', domain: '', baseUrl: '', fromName: '', fromEmail: '', }; mockGetMailgunConfig.mockReturnValue(emptyConfig); const service = new MailgunService(); const status = service.getConfigurationStatus(); expect(status).toEqual({ configured: false, mode: 'development', domain: '', fromEmail: '', missingFields: [ 'VITE_MAILGUN_API_KEY', 'VITE_MAILGUN_DOMAIN', 'VITE_MAILGUN_FROM_EMAIL', 'VITE_MAILGUN_BASE_URL', 'VITE_MAILGUN_FROM_NAME', ], }); }); }); });