Files
rxminder/services/__tests__/mailgun.service.test.ts
2025-09-23 10:30:12 -07:00

525 lines
15 KiB
TypeScript

// 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<typeof fetch>;
let mockFormData: jest.MockedFunction<any>;
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<typeof fetch>;
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: '<p>Test HTML</p>',
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: '<p>Test HTML</p>',
};
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: '<p>Test HTML</p>',
};
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: '<p>Test HTML</p>',
text: 'Test Text',
};
await service.sendEmail('test@example.com', template);
expect(mockFormDataInstance.append).toHaveBeenCalledWith(
'from',
'Test App <test@example.com>'
);
expect(mockFormDataInstance.append).toHaveBeenCalledWith(
'to',
'test@example.com'
);
expect(mockFormDataInstance.append).toHaveBeenCalledWith(
'subject',
'Test Subject'
);
expect(mockFormDataInstance.append).toHaveBeenCalledWith(
'html',
'<p>Test HTML</p>'
);
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: '<p>Test HTML</p>',
};
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: '<p>Test HTML</p>',
};
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: '<p>Test HTML</p>',
})
);
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',
],
});
});
});
});