604 lines
18 KiB
JavaScript
604 lines
18 KiB
JavaScript
// Mock dependencies BEFORE requiring modules
|
|
jest.mock('@aws-sdk/client-ses');
|
|
jest.mock('../../../config/aws', () => ({
|
|
getAWSConfig: jest.fn(() => ({
|
|
region: 'us-east-1',
|
|
credentials: {
|
|
accessKeyId: 'test-key',
|
|
secretAccessKey: 'test-secret'
|
|
}
|
|
}))
|
|
}));
|
|
jest.mock('../../../models', () => ({
|
|
User: {
|
|
findByPk: jest.fn()
|
|
}
|
|
}));
|
|
|
|
const emailService = require('../../../services/emailService');
|
|
const { SESClient, SendEmailCommand } = require('@aws-sdk/client-ses');
|
|
const { getAWSConfig } = require('../../../config/aws');
|
|
|
|
describe('EmailService', () => {
|
|
let mockSESClient;
|
|
let mockSend;
|
|
|
|
beforeEach(() => {
|
|
mockSend = jest.fn();
|
|
mockSESClient = {
|
|
send: mockSend
|
|
};
|
|
|
|
SESClient.mockImplementation(() => mockSESClient);
|
|
|
|
// Reset environment variables
|
|
process.env.EMAIL_ENABLED = 'true';
|
|
process.env.AWS_REGION = 'us-east-1';
|
|
process.env.AWS_ACCESS_KEY_ID = 'test-key';
|
|
process.env.AWS_SECRET_ACCESS_KEY = 'test-secret';
|
|
process.env.SES_FROM_EMAIL = 'test@example.com';
|
|
process.env.SES_REPLY_TO_EMAIL = 'reply@example.com';
|
|
|
|
// Reset the service instance
|
|
emailService.initialized = false;
|
|
emailService.sesClient = null;
|
|
emailService.templates.clear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
describe('initialization', () => {
|
|
it('should initialize SES client using AWS config', async () => {
|
|
await emailService.initialize();
|
|
|
|
expect(getAWSConfig).toHaveBeenCalled();
|
|
expect(SESClient).toHaveBeenCalledWith({
|
|
region: 'us-east-1',
|
|
credentials: {
|
|
accessKeyId: 'test-key',
|
|
secretAccessKey: 'test-secret'
|
|
}
|
|
});
|
|
expect(emailService.initialized).toBe(true);
|
|
});
|
|
|
|
it('should handle initialization errors', async () => {
|
|
SESClient.mockImplementationOnce(() => {
|
|
throw new Error('AWS credentials not found');
|
|
});
|
|
|
|
// Reset initialized state
|
|
emailService.initialized = false;
|
|
|
|
await expect(emailService.initialize()).rejects.toThrow('AWS credentials not found');
|
|
});
|
|
});
|
|
|
|
describe('sendEmail', () => {
|
|
beforeEach(async () => {
|
|
mockSend.mockResolvedValue({ MessageId: 'test-message-id' });
|
|
await emailService.initialize();
|
|
});
|
|
|
|
it('should send email successfully', async () => {
|
|
const result = await emailService.sendEmail(
|
|
'recipient@example.com',
|
|
'Test Subject',
|
|
'<h1>Test HTML</h1>',
|
|
'Test Text'
|
|
);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.messageId).toBe('test-message-id');
|
|
expect(mockSend).toHaveBeenCalledWith(expect.any(SendEmailCommand));
|
|
});
|
|
|
|
it('should handle single email address', async () => {
|
|
const result = await emailService.sendEmail('single@example.com', 'Subject', '<p>Content</p>');
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(mockSend).toHaveBeenCalledWith(expect.any(SendEmailCommand));
|
|
});
|
|
|
|
it('should handle array of email addresses', async () => {
|
|
const result = await emailService.sendEmail(
|
|
['first@example.com', 'second@example.com'],
|
|
'Subject',
|
|
'<p>Content</p>'
|
|
);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(mockSend).toHaveBeenCalledWith(expect.any(SendEmailCommand));
|
|
});
|
|
|
|
it('should include reply-to address when configured', async () => {
|
|
const result = await emailService.sendEmail('test@example.com', 'Subject', '<p>Content</p>');
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(mockSend).toHaveBeenCalledWith(expect.any(SendEmailCommand));
|
|
});
|
|
|
|
it('should handle SES errors', async () => {
|
|
mockSend.mockRejectedValue(new Error('SES Error'));
|
|
|
|
const result = await emailService.sendEmail('test@example.com', 'Subject', '<p>Content</p>');
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('SES Error');
|
|
});
|
|
|
|
it('should skip sending when email is disabled', async () => {
|
|
process.env.EMAIL_ENABLED = 'false';
|
|
|
|
const result = await emailService.sendEmail('test@example.com', 'Subject', '<p>Content</p>');
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.messageId).toBe('disabled');
|
|
expect(mockSend).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('template rendering', () => {
|
|
it('should render template with variables', () => {
|
|
const template = '<h1>Hello {{name}}</h1><p>Your order {{orderId}} is ready.</p>';
|
|
emailService.templates.set('test', template);
|
|
|
|
const rendered = emailService.renderTemplate('test', {
|
|
name: 'John Doe',
|
|
orderId: '12345'
|
|
});
|
|
|
|
expect(rendered).toBe('<h1>Hello John Doe</h1><p>Your order 12345 is ready.</p>');
|
|
});
|
|
|
|
it('should handle missing variables by replacing with empty string', () => {
|
|
const template = '<h1>Hello {{name}}</h1><p>Your order {{orderId}} is ready.</p>';
|
|
emailService.templates.set('test', template);
|
|
|
|
const rendered = emailService.renderTemplate('test', {
|
|
name: 'John Doe',
|
|
orderId: '' // Explicitly provide empty string
|
|
});
|
|
|
|
expect(rendered).toContain('Hello John Doe');
|
|
expect(rendered).toContain('Your order');
|
|
});
|
|
|
|
it('should use fallback template when template not found', () => {
|
|
const rendered = emailService.renderTemplate('nonexistent', {
|
|
title: 'Test Title',
|
|
content: 'Test Content',
|
|
message: 'Test message'
|
|
});
|
|
|
|
expect(rendered).toContain('Test Title');
|
|
expect(rendered).toContain('Test message');
|
|
expect(rendered).toContain('RentAll');
|
|
});
|
|
});
|
|
|
|
describe('notification-specific senders', () => {
|
|
beforeEach(async () => {
|
|
mockSend.mockResolvedValue({ MessageId: 'test-message-id' });
|
|
await emailService.initialize();
|
|
});
|
|
|
|
it('should send condition check reminder', async () => {
|
|
const notification = {
|
|
title: 'Condition Check Required',
|
|
message: 'Please take photos of the item',
|
|
metadata: { deadline: '2024-01-15' }
|
|
};
|
|
const rental = { item: { name: 'Test Item' } };
|
|
|
|
const result = await emailService.sendConditionCheckReminder(
|
|
'test@example.com',
|
|
notification,
|
|
rental
|
|
);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(mockSend).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should send rental confirmation', async () => {
|
|
const notification = {
|
|
title: 'Rental Confirmed',
|
|
message: 'Your rental has been confirmed'
|
|
};
|
|
const rental = {
|
|
item: { name: 'Test Item' },
|
|
startDateTime: '2024-01-15T10:00:00Z',
|
|
endDateTime: '2024-01-17T10:00:00Z'
|
|
};
|
|
|
|
const result = await emailService.sendRentalConfirmation(
|
|
'test@example.com',
|
|
notification,
|
|
rental
|
|
);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(mockSend).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('error handling', () => {
|
|
beforeEach(async () => {
|
|
await emailService.initialize();
|
|
});
|
|
|
|
it('should handle missing rental data gracefully', async () => {
|
|
mockSend.mockResolvedValue({ MessageId: 'test-message-id' });
|
|
|
|
const notification = {
|
|
title: 'Test',
|
|
message: 'Test message',
|
|
metadata: {}
|
|
};
|
|
|
|
const result = await emailService.sendConditionCheckReminder(
|
|
'test@example.com',
|
|
notification,
|
|
null
|
|
);
|
|
|
|
expect(result.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('sendRentalConfirmationEmails', () => {
|
|
const { User } = require('../../../models');
|
|
|
|
beforeEach(async () => {
|
|
mockSend.mockResolvedValue({ MessageId: 'test-message-id' });
|
|
await emailService.initialize();
|
|
});
|
|
|
|
it('should send emails to both owner and renter successfully', async () => {
|
|
const mockOwner = { email: 'owner@example.com' };
|
|
const mockRenter = { email: 'renter@example.com' };
|
|
|
|
User.findByPk
|
|
.mockResolvedValueOnce(mockOwner) // First call for owner
|
|
.mockResolvedValueOnce(mockRenter); // Second call for renter
|
|
|
|
const rental = {
|
|
id: 1,
|
|
ownerId: 10,
|
|
renterId: 20,
|
|
item: { name: 'Test Item' },
|
|
startDateTime: '2024-01-15T10:00:00Z',
|
|
endDateTime: '2024-01-17T10:00:00Z'
|
|
};
|
|
|
|
const results = await emailService.sendRentalConfirmationEmails(rental);
|
|
|
|
expect(results.ownerEmailSent).toBe(true);
|
|
expect(results.renterEmailSent).toBe(true);
|
|
expect(mockSend).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('should send renter email even if owner email fails', async () => {
|
|
const mockOwner = { email: 'owner@example.com' };
|
|
const mockRenter = { email: 'renter@example.com' };
|
|
|
|
User.findByPk
|
|
.mockResolvedValueOnce(mockOwner)
|
|
.mockResolvedValueOnce(mockRenter);
|
|
|
|
// First call (owner) fails, second call (renter) succeeds
|
|
mockSend
|
|
.mockRejectedValueOnce(new Error('SES Error for owner'))
|
|
.mockResolvedValueOnce({ MessageId: 'renter-message-id' });
|
|
|
|
const rental = {
|
|
id: 1,
|
|
ownerId: 10,
|
|
renterId: 20,
|
|
item: { name: 'Test Item' },
|
|
startDateTime: '2024-01-15T10:00:00Z',
|
|
endDateTime: '2024-01-17T10:00:00Z'
|
|
};
|
|
|
|
const results = await emailService.sendRentalConfirmationEmails(rental);
|
|
|
|
expect(results.ownerEmailSent).toBe(false);
|
|
expect(results.renterEmailSent).toBe(true);
|
|
expect(mockSend).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('should send owner email even if renter email fails', async () => {
|
|
const mockOwner = { email: 'owner@example.com' };
|
|
const mockRenter = { email: 'renter@example.com' };
|
|
|
|
User.findByPk
|
|
.mockResolvedValueOnce(mockOwner)
|
|
.mockResolvedValueOnce(mockRenter);
|
|
|
|
// First call (owner) succeeds, second call (renter) fails
|
|
mockSend
|
|
.mockResolvedValueOnce({ MessageId: 'owner-message-id' })
|
|
.mockRejectedValueOnce(new Error('SES Error for renter'));
|
|
|
|
const rental = {
|
|
id: 1,
|
|
ownerId: 10,
|
|
renterId: 20,
|
|
item: { name: 'Test Item' },
|
|
startDateTime: '2024-01-15T10:00:00Z',
|
|
endDateTime: '2024-01-17T10:00:00Z'
|
|
};
|
|
|
|
const results = await emailService.sendRentalConfirmationEmails(rental);
|
|
|
|
expect(results.ownerEmailSent).toBe(true);
|
|
expect(results.renterEmailSent).toBe(false);
|
|
expect(mockSend).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('should handle both emails failing gracefully', async () => {
|
|
const mockOwner = { email: 'owner@example.com' };
|
|
const mockRenter = { email: 'renter@example.com' };
|
|
|
|
User.findByPk
|
|
.mockResolvedValueOnce(mockOwner)
|
|
.mockResolvedValueOnce(mockRenter);
|
|
|
|
// Both calls fail
|
|
mockSend
|
|
.mockRejectedValueOnce(new Error('SES Error for owner'))
|
|
.mockRejectedValueOnce(new Error('SES Error for renter'));
|
|
|
|
const rental = {
|
|
id: 1,
|
|
ownerId: 10,
|
|
renterId: 20,
|
|
item: { name: 'Test Item' },
|
|
startDateTime: '2024-01-15T10:00:00Z',
|
|
endDateTime: '2024-01-17T10:00:00Z'
|
|
};
|
|
|
|
const results = await emailService.sendRentalConfirmationEmails(rental);
|
|
|
|
expect(results.ownerEmailSent).toBe(false);
|
|
expect(results.renterEmailSent).toBe(false);
|
|
expect(mockSend).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('should handle missing owner email', async () => {
|
|
const mockOwner = { email: null };
|
|
const mockRenter = { email: 'renter@example.com' };
|
|
|
|
User.findByPk
|
|
.mockResolvedValueOnce(mockOwner)
|
|
.mockResolvedValueOnce(mockRenter);
|
|
|
|
const rental = {
|
|
id: 1,
|
|
ownerId: 10,
|
|
renterId: 20,
|
|
item: { name: 'Test Item' },
|
|
startDateTime: '2024-01-15T10:00:00Z',
|
|
endDateTime: '2024-01-17T10:00:00Z'
|
|
};
|
|
|
|
const results = await emailService.sendRentalConfirmationEmails(rental);
|
|
|
|
expect(results.ownerEmailSent).toBe(false);
|
|
expect(results.renterEmailSent).toBe(true);
|
|
expect(mockSend).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should handle missing renter email', async () => {
|
|
const mockOwner = { email: 'owner@example.com' };
|
|
const mockRenter = { email: null };
|
|
|
|
User.findByPk
|
|
.mockResolvedValueOnce(mockOwner)
|
|
.mockResolvedValueOnce(mockRenter);
|
|
|
|
const rental = {
|
|
id: 1,
|
|
ownerId: 10,
|
|
renterId: 20,
|
|
item: { name: 'Test Item' },
|
|
startDateTime: '2024-01-15T10:00:00Z',
|
|
endDateTime: '2024-01-17T10:00:00Z'
|
|
};
|
|
|
|
const results = await emailService.sendRentalConfirmationEmails(rental);
|
|
|
|
expect(results.ownerEmailSent).toBe(true);
|
|
expect(results.renterEmailSent).toBe(false);
|
|
expect(mockSend).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('sendRentalRequestEmail', () => {
|
|
const { User } = require('../../../models');
|
|
|
|
beforeEach(async () => {
|
|
mockSend.mockResolvedValue({ MessageId: 'test-message-id' });
|
|
await emailService.initialize();
|
|
});
|
|
|
|
it('should send rental request email to owner', async () => {
|
|
const mockOwner = {
|
|
email: 'owner@example.com',
|
|
firstName: 'John',
|
|
lastName: 'Smith'
|
|
};
|
|
const mockRenter = {
|
|
firstName: 'Jane',
|
|
lastName: 'Doe'
|
|
};
|
|
|
|
User.findByPk
|
|
.mockResolvedValueOnce(mockOwner) // First call for owner
|
|
.mockResolvedValueOnce(mockRenter); // Second call for renter
|
|
|
|
const rental = {
|
|
id: 1,
|
|
ownerId: 10,
|
|
renterId: 20,
|
|
startDateTime: new Date('2024-12-01T10:00:00Z'),
|
|
endDateTime: new Date('2024-12-03T10:00:00Z'),
|
|
totalAmount: 150.00,
|
|
payoutAmount: 135.00,
|
|
deliveryMethod: 'pickup',
|
|
notes: 'Please have it ready by 9am',
|
|
item: { name: 'Power Drill' }
|
|
};
|
|
|
|
const result = await emailService.sendRentalRequestEmail(rental);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.messageId).toBe('test-message-id');
|
|
expect(mockSend).toHaveBeenCalledWith(expect.any(SendEmailCommand));
|
|
});
|
|
|
|
it('should handle missing owner gracefully', async () => {
|
|
User.findByPk.mockResolvedValue(null);
|
|
|
|
const rental = {
|
|
id: 1,
|
|
ownerId: 1,
|
|
renterId: 2,
|
|
item: { name: 'Power Drill' }
|
|
};
|
|
|
|
const result = await emailService.sendRentalRequestEmail(rental);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('User not found');
|
|
});
|
|
|
|
it('should handle missing renter gracefully', async () => {
|
|
const mockOwner = {
|
|
email: 'owner@example.com',
|
|
firstName: 'John'
|
|
};
|
|
|
|
User.findByPk
|
|
.mockResolvedValueOnce(mockOwner)
|
|
.mockResolvedValueOnce(null); // Renter not found
|
|
|
|
const rental = {
|
|
id: 1,
|
|
ownerId: 1,
|
|
renterId: 2,
|
|
item: { name: 'Power Drill' }
|
|
};
|
|
|
|
const result = await emailService.sendRentalRequestEmail(rental);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('User not found');
|
|
});
|
|
|
|
it('should handle free rentals (amount = 0)', async () => {
|
|
const mockOwner = {
|
|
email: 'owner@example.com',
|
|
firstName: 'John'
|
|
};
|
|
const mockRenter = {
|
|
firstName: 'Jane',
|
|
lastName: 'Doe'
|
|
};
|
|
|
|
User.findByPk
|
|
.mockResolvedValueOnce(mockOwner)
|
|
.mockResolvedValueOnce(mockRenter);
|
|
|
|
const rental = {
|
|
id: 1,
|
|
ownerId: 10,
|
|
renterId: 20,
|
|
startDateTime: new Date('2024-12-01T10:00:00Z'),
|
|
endDateTime: new Date('2024-12-03T10:00:00Z'),
|
|
totalAmount: 0,
|
|
payoutAmount: 0,
|
|
deliveryMethod: 'pickup',
|
|
notes: null,
|
|
item: { name: 'Free Item' }
|
|
};
|
|
|
|
const result = await emailService.sendRentalRequestEmail(rental);
|
|
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
it('should handle missing rental notes', async () => {
|
|
const mockOwner = {
|
|
email: 'owner@example.com',
|
|
firstName: 'John'
|
|
};
|
|
const mockRenter = {
|
|
firstName: 'Jane',
|
|
lastName: 'Doe'
|
|
};
|
|
|
|
User.findByPk
|
|
.mockResolvedValueOnce(mockOwner)
|
|
.mockResolvedValueOnce(mockRenter);
|
|
|
|
const rental = {
|
|
id: 1,
|
|
ownerId: 10,
|
|
renterId: 20,
|
|
startDateTime: new Date('2024-12-01T10:00:00Z'),
|
|
endDateTime: new Date('2024-12-03T10:00:00Z'),
|
|
totalAmount: 100,
|
|
payoutAmount: 90,
|
|
deliveryMethod: 'delivery',
|
|
notes: null, // No notes
|
|
item: { name: 'Test Item' }
|
|
};
|
|
|
|
const result = await emailService.sendRentalRequestEmail(rental);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(mockSend).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should generate correct approval URL', async () => {
|
|
const mockOwner = {
|
|
email: 'owner@example.com',
|
|
firstName: 'John'
|
|
};
|
|
const mockRenter = {
|
|
firstName: 'Jane',
|
|
lastName: 'Doe'
|
|
};
|
|
|
|
User.findByPk
|
|
.mockResolvedValueOnce(mockOwner)
|
|
.mockResolvedValueOnce(mockRenter);
|
|
|
|
process.env.FRONTEND_URL = 'https://rentall.com';
|
|
|
|
const rental = {
|
|
id: 123,
|
|
ownerId: 10,
|
|
renterId: 20,
|
|
startDateTime: new Date('2024-12-01T10:00:00Z'),
|
|
endDateTime: new Date('2024-12-03T10:00:00Z'),
|
|
totalAmount: 100,
|
|
payoutAmount: 90,
|
|
deliveryMethod: 'pickup',
|
|
notes: 'Test notes',
|
|
item: { name: 'Test Item' }
|
|
};
|
|
|
|
const result = await emailService.sendRentalRequestEmail(rental);
|
|
|
|
expect(result.success).toBe(true);
|
|
// The URL should be constructed correctly
|
|
// We can't directly test the content, but we know it was called
|
|
expect(mockSend).toHaveBeenCalled();
|
|
});
|
|
});
|
|
}); |