987 lines
34 KiB
JavaScript
987 lines
34 KiB
JavaScript
// Mock dependencies
|
|
jest.mock('../../../../../services/email/core/EmailClient', () => {
|
|
return jest.fn().mockImplementation(() => ({
|
|
initialize: jest.fn().mockResolvedValue(),
|
|
sendEmail: jest.fn().mockResolvedValue({ success: true, messageId: 'msg-123' }),
|
|
}));
|
|
});
|
|
|
|
jest.mock('../../../../../services/email/core/TemplateManager', () => {
|
|
return jest.fn().mockImplementation(() => ({
|
|
initialize: jest.fn().mockResolvedValue(),
|
|
renderTemplate: jest.fn().mockResolvedValue('<html>Test</html>'),
|
|
}));
|
|
});
|
|
|
|
jest.mock('../../../../../utils/logger', () => ({
|
|
info: jest.fn(),
|
|
error: jest.fn(),
|
|
warn: jest.fn(),
|
|
}));
|
|
|
|
const RentalFlowEmailService = require('../../../../../services/email/domain/RentalFlowEmailService');
|
|
|
|
describe('RentalFlowEmailService', () => {
|
|
let service;
|
|
const originalEnv = process.env;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
process.env = { ...originalEnv, FRONTEND_URL: 'http://localhost:3000' };
|
|
service = new RentalFlowEmailService();
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env = originalEnv;
|
|
});
|
|
|
|
describe('initialize', () => {
|
|
it('should initialize only once', async () => {
|
|
await service.initialize();
|
|
await service.initialize();
|
|
|
|
expect(service.emailClient.initialize).toHaveBeenCalledTimes(1);
|
|
expect(service.templateManager.initialize).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('sendRentalRequestEmail', () => {
|
|
const owner = { firstName: 'John', email: 'john@example.com' };
|
|
const renter = { firstName: 'Jane', lastName: 'Smith' };
|
|
const rental = {
|
|
id: 123,
|
|
item: { name: 'Power Drill' },
|
|
startDateTime: '2024-01-15T10:00:00Z',
|
|
endDateTime: '2024-01-16T10:00:00Z',
|
|
totalAmount: '50.00',
|
|
payoutAmount: '45.00',
|
|
deliveryMethod: 'pickup',
|
|
intendedUse: 'Home project',
|
|
};
|
|
|
|
it('should send rental request email with correct variables', async () => {
|
|
const result = await service.sendRentalRequestEmail(owner, renter, rental);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalRequestToOwner',
|
|
expect.objectContaining({
|
|
ownerName: 'John',
|
|
renterName: 'Jane Smith',
|
|
itemName: 'Power Drill',
|
|
totalAmount: '50.00',
|
|
payoutAmount: '45.00',
|
|
deliveryMethod: 'pickup',
|
|
intendedUse: 'Home project',
|
|
approveUrl: 'http://localhost:3000/owning?rentalId=123',
|
|
})
|
|
);
|
|
expect(service.emailClient.sendEmail).toHaveBeenCalledWith(
|
|
'john@example.com',
|
|
'Rental Request for Power Drill',
|
|
expect.any(String)
|
|
);
|
|
});
|
|
|
|
it('should use default values when data is missing', async () => {
|
|
const minimalRental = { id: 123, item: null };
|
|
const minimalRenter = {};
|
|
|
|
await service.sendRentalRequestEmail(owner, minimalRenter, minimalRental);
|
|
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalRequestToOwner',
|
|
expect.objectContaining({
|
|
itemName: 'your item',
|
|
startDate: 'Not specified',
|
|
endDate: 'Not specified',
|
|
totalAmount: '0.00',
|
|
payoutAmount: '0.00',
|
|
deliveryMethod: 'Not specified',
|
|
intendedUse: 'Not specified',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should use A renter fallback when names produce empty string', async () => {
|
|
const minimalRental = { id: 123, item: null };
|
|
const renterWithEmptyNames = { firstName: '', lastName: '' };
|
|
|
|
await service.sendRentalRequestEmail(owner, renterWithEmptyNames, minimalRental);
|
|
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalRequestToOwner',
|
|
expect.objectContaining({
|
|
renterName: 'A renter',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should handle errors gracefully', async () => {
|
|
service.templateManager.renderTemplate.mockRejectedValueOnce(new Error('Template error'));
|
|
|
|
const result = await service.sendRentalRequestEmail(owner, renter, rental);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('Template error');
|
|
});
|
|
});
|
|
|
|
describe('sendRentalRequestConfirmationEmail', () => {
|
|
const renter = { firstName: 'Jane', email: 'jane@example.com' };
|
|
const rental = {
|
|
item: { name: 'Power Drill' },
|
|
startDateTime: '2024-01-15T10:00:00Z',
|
|
endDateTime: '2024-01-16T10:00:00Z',
|
|
totalAmount: '50.00',
|
|
deliveryMethod: 'pickup',
|
|
};
|
|
|
|
it('should send rental request confirmation with correct variables', async () => {
|
|
const result = await service.sendRentalRequestConfirmationEmail(renter, rental);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalRequestConfirmationToRenter',
|
|
expect.objectContaining({
|
|
renterName: 'Jane',
|
|
itemName: 'Power Drill',
|
|
totalAmount: '50.00',
|
|
deliveryMethod: 'pickup',
|
|
viewRentalsUrl: 'http://localhost:3000/renting',
|
|
})
|
|
);
|
|
expect(service.emailClient.sendEmail).toHaveBeenCalledWith(
|
|
'jane@example.com',
|
|
'Rental Request Submitted - Power Drill',
|
|
expect.any(String)
|
|
);
|
|
});
|
|
|
|
it('should use default name when firstName is missing', async () => {
|
|
const renterNoName = { email: 'jane@example.com' };
|
|
|
|
await service.sendRentalRequestConfirmationEmail(renterNoName, rental);
|
|
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalRequestConfirmationToRenter',
|
|
expect.objectContaining({ renterName: 'there' })
|
|
);
|
|
});
|
|
|
|
it('should show different payment message for free rentals', async () => {
|
|
const freeRental = { ...rental, totalAmount: '0' };
|
|
|
|
await service.sendRentalRequestConfirmationEmail(renter, freeRental);
|
|
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalRequestConfirmationToRenter',
|
|
expect.objectContaining({
|
|
paymentMessage: 'The owner will review your request and respond soon.',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should show payment pending message for paid rentals', async () => {
|
|
await service.sendRentalRequestConfirmationEmail(renter, rental);
|
|
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalRequestConfirmationToRenter',
|
|
expect.objectContaining({
|
|
paymentMessage: "The owner will review your request. You'll only be charged if they approve it.",
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should handle errors gracefully', async () => {
|
|
service.emailClient.sendEmail.mockRejectedValueOnce(new Error('Send error'));
|
|
|
|
const result = await service.sendRentalRequestConfirmationEmail(renter, rental);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('Send error');
|
|
});
|
|
});
|
|
|
|
describe('sendRentalApprovalConfirmationEmail', () => {
|
|
const owner = {
|
|
firstName: 'John',
|
|
email: 'john@example.com',
|
|
stripeConnectedAccountId: 'acct_123',
|
|
};
|
|
const renter = { firstName: 'Jane', lastName: 'Smith' };
|
|
const rental = {
|
|
id: 123,
|
|
item: { name: 'Power Drill' },
|
|
startDateTime: '2024-01-15T10:00:00Z',
|
|
endDateTime: '2024-01-16T10:00:00Z',
|
|
deliveryMethod: 'delivery',
|
|
totalAmount: '50.00',
|
|
payoutAmount: '45.00',
|
|
platformFee: '5.00',
|
|
};
|
|
|
|
it('should send approval confirmation with correct variables', async () => {
|
|
const result = await service.sendRentalApprovalConfirmationEmail(owner, renter, rental);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalApprovalConfirmationToOwner',
|
|
expect.objectContaining({
|
|
ownerName: 'John',
|
|
itemName: 'Power Drill',
|
|
renterName: 'Jane Smith',
|
|
deliveryMethod: 'Delivery',
|
|
paymentMessage: 'their payment has been processed successfully.',
|
|
rentalDetailsUrl: 'http://localhost:3000/owning?rentalId=123',
|
|
})
|
|
);
|
|
expect(service.emailClient.sendEmail).toHaveBeenCalledWith(
|
|
'john@example.com',
|
|
'Rental Approved - Power Drill',
|
|
expect.any(String)
|
|
);
|
|
});
|
|
|
|
it('should show stripe setup reminder when no stripe account for paid rental', async () => {
|
|
const ownerNoStripe = { firstName: 'John', email: 'john@example.com' };
|
|
|
|
await service.sendRentalApprovalConfirmationEmail(ownerNoStripe, renter, rental);
|
|
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalApprovalConfirmationToOwner',
|
|
expect.objectContaining({
|
|
stripeSection: expect.stringContaining('Action Required'),
|
|
stripeSection: expect.stringContaining('Set Up Your Earnings Account'),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should show earnings active message when stripe account exists for paid rental', async () => {
|
|
await service.sendRentalApprovalConfirmationEmail(owner, renter, rental);
|
|
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalApprovalConfirmationToOwner',
|
|
expect.objectContaining({
|
|
stripeSection: expect.stringContaining('Earnings Account Active'),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should handle free rentals correctly', async () => {
|
|
const freeRental = { ...rental, totalAmount: '0', payoutAmount: '0', platformFee: '0' };
|
|
|
|
await service.sendRentalApprovalConfirmationEmail(owner, renter, freeRental);
|
|
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalApprovalConfirmationToOwner',
|
|
expect.objectContaining({
|
|
paymentMessage: 'this is a free rental (no payment required).',
|
|
earningsSection: '',
|
|
stripeSection: '',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should use default name when firstName is missing', async () => {
|
|
const ownerNoName = { email: 'john@example.com', stripeConnectedAccountId: 'acct_123' };
|
|
|
|
await service.sendRentalApprovalConfirmationEmail(ownerNoName, renter, rental);
|
|
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalApprovalConfirmationToOwner',
|
|
expect.objectContaining({ ownerName: 'there' })
|
|
);
|
|
});
|
|
|
|
it('should handle errors gracefully', async () => {
|
|
service.templateManager.renderTemplate.mockRejectedValueOnce(new Error('Template error'));
|
|
|
|
const result = await service.sendRentalApprovalConfirmationEmail(owner, renter, rental);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('Template error');
|
|
});
|
|
});
|
|
|
|
describe('sendRentalDeclinedEmail', () => {
|
|
const renter = { firstName: 'Jane', email: 'jane@example.com' };
|
|
const rental = {
|
|
item: { name: 'Power Drill' },
|
|
startDateTime: '2024-01-15T10:00:00Z',
|
|
endDateTime: '2024-01-16T10:00:00Z',
|
|
totalAmount: '50.00',
|
|
payoutAmount: '45.00',
|
|
deliveryMethod: 'pickup',
|
|
};
|
|
|
|
it('should send declined email with correct variables', async () => {
|
|
const result = await service.sendRentalDeclinedEmail(renter, rental, 'Item unavailable');
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalDeclinedToRenter',
|
|
expect.objectContaining({
|
|
renterName: 'Jane',
|
|
itemName: 'Power Drill',
|
|
deliveryMethod: 'pickup',
|
|
browseItemsUrl: 'http://localhost:3000/',
|
|
})
|
|
);
|
|
expect(service.emailClient.sendEmail).toHaveBeenCalledWith(
|
|
'jane@example.com',
|
|
'Rental Request Declined - Power Drill',
|
|
expect.any(String)
|
|
);
|
|
});
|
|
|
|
it('should include owner message when decline reason is provided', async () => {
|
|
await service.sendRentalDeclinedEmail(renter, rental, 'Item is broken');
|
|
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalDeclinedToRenter',
|
|
expect.objectContaining({
|
|
ownerMessage: expect.stringContaining('Item is broken'),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should not include owner message when no decline reason', async () => {
|
|
await service.sendRentalDeclinedEmail(renter, rental, null);
|
|
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalDeclinedToRenter',
|
|
expect.objectContaining({
|
|
ownerMessage: '',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should show no charge message for paid rentals', async () => {
|
|
await service.sendRentalDeclinedEmail(renter, rental, null);
|
|
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalDeclinedToRenter',
|
|
expect.objectContaining({
|
|
paymentMessage: 'Since your request was declined before payment was processed, you will not be charged.',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should show no payment message for free rentals', async () => {
|
|
const freeRental = { ...rental, totalAmount: '0' };
|
|
|
|
await service.sendRentalDeclinedEmail(renter, freeRental, null);
|
|
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalDeclinedToRenter',
|
|
expect.objectContaining({
|
|
paymentMessage: 'No payment was required for this rental request.',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should use default name when firstName is missing', async () => {
|
|
const renterNoName = { email: 'jane@example.com' };
|
|
|
|
await service.sendRentalDeclinedEmail(renterNoName, rental, null);
|
|
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalDeclinedToRenter',
|
|
expect.objectContaining({ renterName: 'there' })
|
|
);
|
|
});
|
|
|
|
it('should handle errors gracefully', async () => {
|
|
service.emailClient.sendEmail.mockRejectedValueOnce(new Error('Send error'));
|
|
|
|
const result = await service.sendRentalDeclinedEmail(renter, rental, null);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('Send error');
|
|
});
|
|
});
|
|
|
|
describe('sendRentalConfirmation', () => {
|
|
const userEmail = 'jane@example.com';
|
|
const notification = {
|
|
title: 'Rental Confirmed',
|
|
message: 'Your rental has been confirmed.',
|
|
};
|
|
const rental = {
|
|
item: { name: 'Power Drill' },
|
|
startDateTime: '2024-01-15T10:00:00Z',
|
|
endDateTime: '2024-01-16T10:00:00Z',
|
|
totalAmount: '50.00',
|
|
paymentStatus: 'paid',
|
|
paymentMethodBrand: 'visa',
|
|
paymentMethodLast4: '4242',
|
|
stripePaymentIntentId: 'pi_123',
|
|
chargedAt: '2024-01-15T09:00:00Z',
|
|
};
|
|
|
|
it('should send rental confirmation with correct variables', async () => {
|
|
const result = await service.sendRentalConfirmation(
|
|
userEmail,
|
|
notification,
|
|
rental,
|
|
'Jane',
|
|
true
|
|
);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalConfirmationToUser',
|
|
expect.objectContaining({
|
|
recipientName: 'Jane',
|
|
title: 'Rental Confirmed',
|
|
message: 'Your rental has been confirmed.',
|
|
itemName: 'Power Drill',
|
|
isRenter: true,
|
|
})
|
|
);
|
|
expect(service.emailClient.sendEmail).toHaveBeenCalledWith(
|
|
'jane@example.com',
|
|
'Rental Confirmation - Power Drill',
|
|
expect.any(String)
|
|
);
|
|
});
|
|
|
|
it('should include payment receipt for renter with paid rental', async () => {
|
|
await service.sendRentalConfirmation(userEmail, notification, rental, 'Jane', true);
|
|
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalConfirmationToUser',
|
|
expect.objectContaining({
|
|
paymentSection: expect.stringContaining('Payment Receipt'),
|
|
paymentSection: expect.stringContaining('Visa ending in 4242'),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should show free rental message for zero amount', async () => {
|
|
const freeRental = { ...rental, totalAmount: '0' };
|
|
|
|
await service.sendRentalConfirmation(userEmail, notification, freeRental, 'Jane', true);
|
|
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalConfirmationToUser',
|
|
expect.objectContaining({
|
|
paymentSection: expect.stringContaining('No Payment Required'),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should not include payment section for owner', async () => {
|
|
await service.sendRentalConfirmation(userEmail, notification, rental, 'John', false);
|
|
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalConfirmationToUser',
|
|
expect.objectContaining({
|
|
paymentSection: '',
|
|
isRenter: false,
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should use default name when recipientName is null', async () => {
|
|
await service.sendRentalConfirmation(userEmail, notification, rental, null, true);
|
|
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalConfirmationToUser',
|
|
expect.objectContaining({ recipientName: 'there' })
|
|
);
|
|
});
|
|
|
|
it('should handle missing rental data', async () => {
|
|
const minimalRental = {};
|
|
|
|
await service.sendRentalConfirmation(userEmail, notification, minimalRental, 'Jane', true);
|
|
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalConfirmationToUser',
|
|
expect.objectContaining({
|
|
itemName: 'Unknown Item',
|
|
startDate: 'Not specified',
|
|
endDate: 'Not specified',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should handle errors gracefully', async () => {
|
|
service.templateManager.renderTemplate.mockRejectedValueOnce(new Error('Template error'));
|
|
|
|
const result = await service.sendRentalConfirmation(
|
|
userEmail,
|
|
notification,
|
|
rental,
|
|
'Jane',
|
|
true
|
|
);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('Template error');
|
|
});
|
|
});
|
|
|
|
describe('sendRentalConfirmationEmails', () => {
|
|
const owner = { firstName: 'John', email: 'john@example.com' };
|
|
const renter = { firstName: 'Jane', email: 'jane@example.com' };
|
|
const rental = {
|
|
id: 123,
|
|
ownerId: 1,
|
|
renterId: 2,
|
|
item: { name: 'Power Drill' },
|
|
startDateTime: '2024-01-15T10:00:00Z',
|
|
totalAmount: '50.00',
|
|
paymentStatus: 'paid',
|
|
};
|
|
|
|
it('should send confirmation emails to both owner and renter', async () => {
|
|
const result = await service.sendRentalConfirmationEmails(owner, renter, rental);
|
|
|
|
expect(result.ownerEmailSent).toBe(true);
|
|
expect(result.renterEmailSent).toBe(true);
|
|
expect(service.emailClient.sendEmail).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('should send owner email with isRenter false', async () => {
|
|
await service.sendRentalConfirmationEmails(owner, renter, rental);
|
|
|
|
// First call is for owner
|
|
const firstCall = service.templateManager.renderTemplate.mock.calls[0];
|
|
expect(firstCall[1]).toMatchObject({ isRenter: false });
|
|
});
|
|
|
|
it('should send renter email with isRenter true', async () => {
|
|
await service.sendRentalConfirmationEmails(owner, renter, rental);
|
|
|
|
// Second call is for renter
|
|
const secondCall = service.templateManager.renderTemplate.mock.calls[1];
|
|
expect(secondCall[1]).toMatchObject({ isRenter: true });
|
|
});
|
|
|
|
it('should handle missing owner email', async () => {
|
|
const ownerNoEmail = { firstName: 'John' };
|
|
|
|
const result = await service.sendRentalConfirmationEmails(ownerNoEmail, renter, rental);
|
|
|
|
expect(result.ownerEmailSent).toBe(false);
|
|
expect(result.renterEmailSent).toBe(true);
|
|
});
|
|
|
|
it('should handle missing renter email', async () => {
|
|
const renterNoEmail = { firstName: 'Jane' };
|
|
|
|
const result = await service.sendRentalConfirmationEmails(owner, renterNoEmail, rental);
|
|
|
|
expect(result.ownerEmailSent).toBe(true);
|
|
expect(result.renterEmailSent).toBe(false);
|
|
});
|
|
|
|
it('should continue sending renter email if owner email fails', async () => {
|
|
service.emailClient.sendEmail
|
|
.mockResolvedValueOnce({ success: false, error: 'Owner send failed' })
|
|
.mockResolvedValueOnce({ success: true, messageId: 'msg-456' });
|
|
|
|
const result = await service.sendRentalConfirmationEmails(owner, renter, rental);
|
|
|
|
expect(result.ownerEmailSent).toBe(false);
|
|
expect(result.renterEmailSent).toBe(true);
|
|
});
|
|
|
|
it('should handle errors in owner email without crashing', async () => {
|
|
service.emailClient.sendEmail
|
|
.mockRejectedValueOnce(new Error('Network error'))
|
|
.mockResolvedValueOnce({ success: true, messageId: 'msg-456' });
|
|
|
|
const result = await service.sendRentalConfirmationEmails(owner, renter, rental);
|
|
|
|
expect(result.ownerEmailSent).toBe(false);
|
|
expect(result.renterEmailSent).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('sendRentalCancellationEmails', () => {
|
|
const owner = { firstName: 'John', email: 'john@example.com' };
|
|
const renter = { firstName: 'Jane', email: 'jane@example.com' };
|
|
const rental = {
|
|
item: { name: 'Power Drill' },
|
|
startDateTime: '2024-01-15T10:00:00Z',
|
|
endDateTime: '2024-01-16T10:00:00Z',
|
|
cancelledBy: 'renter',
|
|
cancelledAt: '2024-01-14T10:00:00Z',
|
|
totalAmount: '50.00',
|
|
};
|
|
const refundInfo = {
|
|
amount: 50,
|
|
percentage: 1,
|
|
reason: 'Full refund - cancelled before rental start',
|
|
};
|
|
|
|
it('should send cancellation emails when renter cancels', async () => {
|
|
const result = await service.sendRentalCancellationEmails(owner, renter, rental, refundInfo);
|
|
|
|
expect(result.confirmationEmailSent).toBe(true);
|
|
expect(result.notificationEmailSent).toBe(true);
|
|
expect(service.emailClient.sendEmail).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('should send confirmation to renter and notification to owner when renter cancels', async () => {
|
|
await service.sendRentalCancellationEmails(owner, renter, rental, refundInfo);
|
|
|
|
// Confirmation to renter (canceller)
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalCancellationConfirmationToUser',
|
|
expect.objectContaining({ recipientName: 'Jane' })
|
|
);
|
|
// Notification to owner
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalCancellationNotificationToUser',
|
|
expect.objectContaining({ recipientName: 'John' })
|
|
);
|
|
});
|
|
|
|
it('should send confirmation to owner and notification to renter when owner cancels', async () => {
|
|
const ownerCancelledRental = { ...rental, cancelledBy: 'owner' };
|
|
|
|
await service.sendRentalCancellationEmails(owner, renter, ownerCancelledRental, refundInfo);
|
|
|
|
// Confirmation to owner (canceller)
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalCancellationConfirmationToUser',
|
|
expect.objectContaining({ recipientName: 'John' })
|
|
);
|
|
// Notification to renter
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalCancellationNotificationToUser',
|
|
expect.objectContaining({ recipientName: 'Jane' })
|
|
);
|
|
});
|
|
|
|
it('should include refund section for paid rentals', async () => {
|
|
await service.sendRentalCancellationEmails(owner, renter, rental, refundInfo);
|
|
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalCancellationConfirmationToUser',
|
|
expect.objectContaining({
|
|
refundSection: expect.stringContaining('Refund Information'),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should show no refund message when refund amount is zero', async () => {
|
|
const noRefundInfo = { amount: 0, percentage: 0, reason: 'Cancelled after rental period' };
|
|
|
|
await service.sendRentalCancellationEmails(owner, renter, rental, noRefundInfo);
|
|
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalCancellationConfirmationToUser',
|
|
expect.objectContaining({
|
|
refundSection: expect.stringContaining('No Refund Available'),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should not include refund section for free rentals', async () => {
|
|
const freeRental = { ...rental, totalAmount: '0' };
|
|
|
|
await service.sendRentalCancellationEmails(owner, renter, freeRental, refundInfo);
|
|
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalCancellationConfirmationToUser',
|
|
expect.objectContaining({
|
|
refundSection: '',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should use default name when firstName is missing', async () => {
|
|
const ownerNoName = { email: 'john@example.com' };
|
|
const renterNoName = { email: 'jane@example.com' };
|
|
|
|
await service.sendRentalCancellationEmails(ownerNoName, renterNoName, rental, refundInfo);
|
|
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalCancellationConfirmationToUser',
|
|
expect.objectContaining({ recipientName: 'there' })
|
|
);
|
|
});
|
|
|
|
it('should continue if confirmation email fails', async () => {
|
|
service.emailClient.sendEmail
|
|
.mockResolvedValueOnce({ success: false, error: 'Send failed' })
|
|
.mockResolvedValueOnce({ success: true, messageId: 'msg-456' });
|
|
|
|
const result = await service.sendRentalCancellationEmails(owner, renter, rental, refundInfo);
|
|
|
|
expect(result.confirmationEmailSent).toBe(false);
|
|
expect(result.notificationEmailSent).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('sendRentalCompletionEmails', () => {
|
|
const owner = {
|
|
firstName: 'John',
|
|
lastName: 'Doe',
|
|
email: 'john@example.com',
|
|
stripeConnectedAccountId: 'acct_123',
|
|
};
|
|
const renter = { firstName: 'Jane', lastName: 'Smith', email: 'jane@example.com' };
|
|
const rental = {
|
|
id: 123,
|
|
item: { name: 'Power Drill' },
|
|
startDateTime: '2024-01-15T10:00:00Z',
|
|
endDateTime: '2024-01-16T10:00:00Z',
|
|
actualReturnDateTime: '2024-01-16T09:30:00Z',
|
|
itemReviewSubmittedAt: null,
|
|
totalAmount: '50.00',
|
|
payoutAmount: '45.00',
|
|
platformFee: '5.00',
|
|
};
|
|
|
|
it('should send completion emails to both owner and renter', async () => {
|
|
const result = await service.sendRentalCompletionEmails(owner, renter, rental);
|
|
|
|
expect(result.ownerEmailSent).toBe(true);
|
|
expect(result.renterEmailSent).toBe(true);
|
|
expect(service.emailClient.sendEmail).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('should send renter email with review prompt when not yet reviewed', async () => {
|
|
await service.sendRentalCompletionEmails(owner, renter, rental);
|
|
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalCompletionThankYouToRenter',
|
|
expect.objectContaining({
|
|
reviewSection: expect.stringContaining('Share Your Experience'),
|
|
reviewSection: expect.stringContaining('Leave a Review'),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should show thank you message when review already submitted', async () => {
|
|
const reviewedRental = { ...rental, itemReviewSubmittedAt: '2024-01-16T12:00:00Z' };
|
|
|
|
await service.sendRentalCompletionEmails(owner, renter, reviewedRental);
|
|
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalCompletionThankYouToRenter',
|
|
expect.objectContaining({
|
|
reviewSection: expect.stringContaining('Thank You for Your Review'),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should send owner email with earnings for paid rental', async () => {
|
|
await service.sendRentalCompletionEmails(owner, renter, rental);
|
|
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalCompletionCongratsToOwner',
|
|
expect.objectContaining({
|
|
earningsSection: expect.stringContaining('Your Earnings'),
|
|
earningsSection: expect.stringContaining('$45.00'),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should show payout initiated message when owner has stripe account', async () => {
|
|
await service.sendRentalCompletionEmails(owner, renter, rental);
|
|
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalCompletionCongratsToOwner',
|
|
expect.objectContaining({
|
|
stripeSection: expect.stringContaining('Payout Initiated'),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should show stripe setup reminder when owner has no stripe account for paid rental', async () => {
|
|
const ownerNoStripe = { firstName: 'John', lastName: 'Doe', email: 'john@example.com' };
|
|
|
|
await service.sendRentalCompletionEmails(ownerNoStripe, renter, rental);
|
|
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalCompletionCongratsToOwner',
|
|
expect.objectContaining({
|
|
stripeSection: expect.stringContaining('Action Required'),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should not show earnings section for free rentals', async () => {
|
|
const freeRental = { ...rental, totalAmount: '0', payoutAmount: '0', platformFee: '0' };
|
|
|
|
await service.sendRentalCompletionEmails(owner, renter, freeRental);
|
|
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalCompletionCongratsToOwner',
|
|
expect.objectContaining({
|
|
earningsSection: '',
|
|
stripeSection: '',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should use default name when firstName is missing', async () => {
|
|
const ownerNoName = { email: 'john@example.com', stripeConnectedAccountId: 'acct_123' };
|
|
const renterNoName = { email: 'jane@example.com' };
|
|
|
|
await service.sendRentalCompletionEmails(ownerNoName, renterNoName, rental);
|
|
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalCompletionThankYouToRenter',
|
|
expect.objectContaining({ renterName: 'there' })
|
|
);
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'rentalCompletionCongratsToOwner',
|
|
expect.objectContaining({ ownerName: 'there' })
|
|
);
|
|
});
|
|
|
|
it('should continue if renter email fails', async () => {
|
|
service.emailClient.sendEmail
|
|
.mockResolvedValueOnce({ success: false, error: 'Send failed' })
|
|
.mockResolvedValueOnce({ success: true, messageId: 'msg-456' });
|
|
|
|
const result = await service.sendRentalCompletionEmails(owner, renter, rental);
|
|
|
|
expect(result.renterEmailSent).toBe(false);
|
|
expect(result.ownerEmailSent).toBe(true);
|
|
});
|
|
|
|
it('should handle errors in renter email without crashing', async () => {
|
|
service.emailClient.sendEmail
|
|
.mockRejectedValueOnce(new Error('Network error'))
|
|
.mockResolvedValueOnce({ success: true, messageId: 'msg-456' });
|
|
|
|
const result = await service.sendRentalCompletionEmails(owner, renter, rental);
|
|
|
|
expect(result.renterEmailSent).toBe(false);
|
|
expect(result.ownerEmailSent).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('sendPayoutReceivedEmail', () => {
|
|
const owner = { firstName: 'John', email: 'john@example.com' };
|
|
const rental = {
|
|
item: { name: 'Power Drill' },
|
|
startDateTime: '2024-01-15T10:00:00Z',
|
|
endDateTime: '2024-01-16T10:00:00Z',
|
|
totalAmount: '50.00',
|
|
platformFee: '5.00',
|
|
payoutAmount: '45.00',
|
|
stripeTransferId: 'tr_123',
|
|
};
|
|
|
|
it('should send payout email with correct variables', async () => {
|
|
const result = await service.sendPayoutReceivedEmail(owner, rental);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'payoutReceivedToOwner',
|
|
expect.objectContaining({
|
|
ownerName: 'John',
|
|
itemName: 'Power Drill',
|
|
totalAmount: '50.00',
|
|
platformFee: '5.00',
|
|
payoutAmount: '45.00',
|
|
stripeTransferId: 'tr_123',
|
|
earningsDashboardUrl: 'http://localhost:3000/earnings',
|
|
})
|
|
);
|
|
expect(service.emailClient.sendEmail).toHaveBeenCalledWith(
|
|
'john@example.com',
|
|
'Earnings Received - $45.00 for Power Drill',
|
|
expect.any(String)
|
|
);
|
|
});
|
|
|
|
it('should use default name when firstName is missing', async () => {
|
|
const ownerNoName = { email: 'john@example.com' };
|
|
|
|
await service.sendPayoutReceivedEmail(ownerNoName, rental);
|
|
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'payoutReceivedToOwner',
|
|
expect.objectContaining({ ownerName: 'there' })
|
|
);
|
|
});
|
|
|
|
it('should handle missing item name', async () => {
|
|
const rentalNoItem = { ...rental, item: null };
|
|
|
|
await service.sendPayoutReceivedEmail(owner, rentalNoItem);
|
|
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'payoutReceivedToOwner',
|
|
expect.objectContaining({ itemName: 'your item' })
|
|
);
|
|
});
|
|
|
|
it('should handle errors gracefully', async () => {
|
|
service.templateManager.renderTemplate.mockRejectedValueOnce(new Error('Template error'));
|
|
|
|
const result = await service.sendPayoutReceivedEmail(owner, rental);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('Template error');
|
|
});
|
|
});
|
|
|
|
describe('sendAuthenticationRequiredEmail', () => {
|
|
const email = 'jane@example.com';
|
|
const data = {
|
|
renterName: 'Jane',
|
|
itemName: 'Power Drill',
|
|
ownerName: 'John',
|
|
amount: 50,
|
|
};
|
|
|
|
it('should send authentication required email with correct variables', async () => {
|
|
const result = await service.sendAuthenticationRequiredEmail(email, data);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'authenticationRequiredToRenter',
|
|
expect.objectContaining({
|
|
renterName: 'Jane',
|
|
itemName: 'Power Drill',
|
|
ownerName: 'John',
|
|
amount: '50.00',
|
|
})
|
|
);
|
|
expect(service.emailClient.sendEmail).toHaveBeenCalledWith(
|
|
'jane@example.com',
|
|
'Action Required: Complete payment for Power Drill',
|
|
expect.any(String)
|
|
);
|
|
});
|
|
|
|
it('should use default values when data is missing', async () => {
|
|
const minimalData = {};
|
|
|
|
await service.sendAuthenticationRequiredEmail(email, minimalData);
|
|
|
|
expect(service.templateManager.renderTemplate).toHaveBeenCalledWith(
|
|
'authenticationRequiredToRenter',
|
|
expect.objectContaining({
|
|
renterName: 'there',
|
|
itemName: 'the item',
|
|
ownerName: 'The owner',
|
|
amount: '0.00',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should handle errors gracefully', async () => {
|
|
service.emailClient.sendEmail.mockRejectedValueOnce(new Error('Send error'));
|
|
|
|
const result = await service.sendAuthenticationRequiredEmail(email, data);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('Send error');
|
|
});
|
|
});
|
|
});
|