From d4362074f545a9cb95e5ad609885dc2a1df5abbe Mon Sep 17 00:00:00 2001 From: jackiettran <41605212+jackiettran@users.noreply.github.com> Date: Mon, 19 Jan 2026 00:29:28 -0500 Subject: [PATCH] more unit tests --- .../AlphaInvitationEmailService.test.js | 92 ++ .../CustomerServiceEmailService.test.js | 382 +++++++ .../domain/MessagingEmailService.test.js | 107 ++ .../domain/RentalFlowEmailService.test.js | 986 ++++++++++++++++++ .../domain/RentalReminderEmailService.test.js | 114 ++ .../domain/UserEngagementEmailService.test.js | 196 ++++ 6 files changed, 1877 insertions(+) create mode 100644 backend/tests/unit/services/email/domain/AlphaInvitationEmailService.test.js create mode 100644 backend/tests/unit/services/email/domain/CustomerServiceEmailService.test.js create mode 100644 backend/tests/unit/services/email/domain/MessagingEmailService.test.js create mode 100644 backend/tests/unit/services/email/domain/RentalFlowEmailService.test.js create mode 100644 backend/tests/unit/services/email/domain/RentalReminderEmailService.test.js create mode 100644 backend/tests/unit/services/email/domain/UserEngagementEmailService.test.js diff --git a/backend/tests/unit/services/email/domain/AlphaInvitationEmailService.test.js b/backend/tests/unit/services/email/domain/AlphaInvitationEmailService.test.js new file mode 100644 index 0000000..6c3b0d2 --- /dev/null +++ b/backend/tests/unit/services/email/domain/AlphaInvitationEmailService.test.js @@ -0,0 +1,92 @@ +// 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('Test'), + })); +}); + +jest.mock('../../../../../utils/logger', () => ({ + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), +})); + +const AlphaInvitationEmailService = require('../../../../../services/email/domain/AlphaInvitationEmailService'); + +describe('AlphaInvitationEmailService', () => { + let service; + const originalEnv = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { ...originalEnv, FRONTEND_URL: 'http://localhost:3000' }; + service = new AlphaInvitationEmailService(); + }); + + 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('sendAlphaInvitation', () => { + const email = 'john@example.com'; + const code = 'ALPHA-123-XYZ'; + + it('should send alpha invitation email with correct variables', async () => { + const result = await service.sendAlphaInvitation(email, code); + + expect(result.success).toBe(true); + expect(service.templateManager.renderTemplate).toHaveBeenCalledWith( + 'alphaInvitationToUser', + expect.objectContaining({ + code: 'ALPHA-123-XYZ', + email: 'john@example.com', + frontendUrl: 'http://localhost:3000', + title: 'Welcome to Alpha Testing!', + }) + ); + expect(service.emailClient.sendEmail).toHaveBeenCalledWith( + 'john@example.com', + 'Your Alpha Access Code - Village Share', + expect.any(String) + ); + }); + + it('should include code in message variable', async () => { + await service.sendAlphaInvitation(email, code); + + expect(service.templateManager.renderTemplate).toHaveBeenCalledWith( + 'alphaInvitationToUser', + expect.objectContaining({ + message: expect.stringContaining('ALPHA-123-XYZ'), + }) + ); + }); + + it('should handle errors gracefully', async () => { + service.templateManager.renderTemplate.mockRejectedValueOnce(new Error('Template error')); + + const result = await service.sendAlphaInvitation(email, code); + + expect(result.success).toBe(false); + expect(result.error).toBe('Template error'); + }); + }); +}); diff --git a/backend/tests/unit/services/email/domain/CustomerServiceEmailService.test.js b/backend/tests/unit/services/email/domain/CustomerServiceEmailService.test.js new file mode 100644 index 0000000..7f41bbb --- /dev/null +++ b/backend/tests/unit/services/email/domain/CustomerServiceEmailService.test.js @@ -0,0 +1,382 @@ +// 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('Test'), + })); +}); + +jest.mock('../../../../../utils/logger', () => ({ + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), +})); + +const CustomerServiceEmailService = require('../../../../../services/email/domain/CustomerServiceEmailService'); + +describe('CustomerServiceEmailService', () => { + let service; + const originalEnv = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { + ...originalEnv, + CUSTOMER_SUPPORT_EMAIL: 'support@example.com', + }; + service = new CustomerServiceEmailService(); + }); + + 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('sendLateReturnToCustomerService', () => { + const rental = { + id: 123, + endDateTime: '2024-01-16T10:00:00Z', + actualReturnDateTime: '2024-01-16T15:00:00Z', + item: { name: 'Power Drill' }, + }; + const owner = { + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + }; + const renter = { + firstName: 'Jane', + lastName: 'Smith', + email: 'jane@example.com', + }; + const lateCalculation = { + lateHours: 5, + lateFee: 25.00, + }; + + it('should send late return notification with correct variables', async () => { + const result = await service.sendLateReturnToCustomerService( + rental, + owner, + renter, + lateCalculation + ); + + expect(result.success).toBe(true); + expect(service.templateManager.renderTemplate).toHaveBeenCalledWith( + 'lateReturnToCS', + expect.objectContaining({ + rentalId: 123, + itemName: 'Power Drill', + ownerName: 'John Doe', + ownerEmail: 'john@example.com', + renterName: 'Jane Smith', + renterEmail: 'jane@example.com', + hoursLate: '5.0', + lateFee: '25.00', + }) + ); + expect(service.emailClient.sendEmail).toHaveBeenCalledWith( + 'support@example.com', + 'Late Return Detected - Action Required', + expect.any(String) + ); + }); + + it('should return error when customer service email is not configured', async () => { + delete process.env.CUSTOMER_SUPPORT_EMAIL; + + const result = await service.sendLateReturnToCustomerService( + rental, + owner, + renter, + lateCalculation + ); + + expect(result.success).toBe(false); + expect(result.error).toBe('No customer service email configured'); + expect(service.emailClient.sendEmail).not.toHaveBeenCalled(); + }); + + it('should handle errors gracefully', async () => { + service.templateManager.renderTemplate.mockRejectedValueOnce(new Error('Template error')); + + const result = await service.sendLateReturnToCustomerService( + rental, + owner, + renter, + lateCalculation + ); + + expect(result.success).toBe(false); + expect(result.error).toBe('Template error'); + }); + }); + + describe('sendDamageReportToCustomerService', () => { + const rental = { + id: 123, + item: { name: 'Power Drill' }, + }; + const owner = { + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + }; + const renter = { + firstName: 'Jane', + lastName: 'Smith', + email: 'jane@example.com', + }; + const damageAssessment = { + description: 'Drill bit broken', + canBeFixed: true, + repairCost: 30, + needsReplacement: false, + replacementCost: null, + feeCalculation: { + type: 'repair', + amount: 30, + }, + proofOfOwnership: ['receipt.jpg'], + }; + + it('should send damage report with correct variables', async () => { + const result = await service.sendDamageReportToCustomerService( + rental, + owner, + renter, + damageAssessment + ); + + expect(result.success).toBe(true); + expect(service.templateManager.renderTemplate).toHaveBeenCalledWith( + 'damageReportToCS', + expect.objectContaining({ + rentalId: 123, + itemName: 'Power Drill', + ownerName: 'John Doe', + ownerEmail: 'john@example.com', + renterName: 'Jane Smith', + renterEmail: 'jane@example.com', + damageDescription: 'Drill bit broken', + canBeFixed: 'Yes', + repairCost: '30.00', + needsReplacement: 'No', + feeTypeDescription: 'Repair Cost', + damageFee: '30.00', + lateFee: '0.00', + totalFees: '30.00', + hasProofOfOwnership: 'Yes', + }) + ); + expect(service.emailClient.sendEmail).toHaveBeenCalledWith( + 'support@example.com', + 'Damage Report Filed - Action Required', + expect.any(String) + ); + }); + + it('should include late fee when provided', async () => { + const lateCalculation = { lateFee: 15 }; + + await service.sendDamageReportToCustomerService( + rental, + owner, + renter, + damageAssessment, + lateCalculation + ); + + expect(service.templateManager.renderTemplate).toHaveBeenCalledWith( + 'damageReportToCS', + expect.objectContaining({ + lateFee: '15.00', + totalFees: '45.00', + }) + ); + }); + + it('should handle replacement fee type', async () => { + const replacementAssessment = { + ...damageAssessment, + canBeFixed: false, + needsReplacement: true, + replacementCost: 150, + feeCalculation: { + type: 'replacement', + amount: 150, + }, + }; + + await service.sendDamageReportToCustomerService( + rental, + owner, + renter, + replacementAssessment + ); + + expect(service.templateManager.renderTemplate).toHaveBeenCalledWith( + 'damageReportToCS', + expect.objectContaining({ + feeTypeDescription: 'Replacement Cost', + needsReplacement: 'Yes', + replacementCost: '150.00', + }) + ); + }); + + it('should handle unknown fee type', async () => { + const unknownTypeAssessment = { + ...damageAssessment, + feeCalculation: { + type: 'other', + amount: 50, + }, + }; + + await service.sendDamageReportToCustomerService( + rental, + owner, + renter, + unknownTypeAssessment + ); + + expect(service.templateManager.renderTemplate).toHaveBeenCalledWith( + 'damageReportToCS', + expect.objectContaining({ + feeTypeDescription: 'Damage Assessment Fee', + }) + ); + }); + + it('should indicate no proof of ownership when not provided', async () => { + const noProofAssessment = { + ...damageAssessment, + proofOfOwnership: [], + }; + + await service.sendDamageReportToCustomerService( + rental, + owner, + renter, + noProofAssessment + ); + + expect(service.templateManager.renderTemplate).toHaveBeenCalledWith( + 'damageReportToCS', + expect.objectContaining({ + hasProofOfOwnership: 'No', + }) + ); + }); + + it('should return error when customer service email is not configured', async () => { + delete process.env.CUSTOMER_SUPPORT_EMAIL; + + const result = await service.sendDamageReportToCustomerService( + rental, + owner, + renter, + damageAssessment + ); + + expect(result.success).toBe(false); + expect(result.error).toBe('No customer service email configured'); + }); + + it('should handle errors gracefully', async () => { + service.emailClient.sendEmail.mockRejectedValueOnce(new Error('Send error')); + + const result = await service.sendDamageReportToCustomerService( + rental, + owner, + renter, + damageAssessment + ); + + expect(result.success).toBe(false); + expect(result.error).toBe('Send error'); + }); + }); + + describe('sendLostItemToCustomerService', () => { + const rental = { + id: 123, + endDateTime: '2024-01-16T10:00:00Z', + itemLostReportedAt: '2024-01-17T10:00:00Z', + item: { + name: 'Power Drill', + replacementCost: 200, + }, + }; + const owner = { + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + }; + const renter = { + firstName: 'Jane', + lastName: 'Smith', + email: 'jane@example.com', + }; + + it('should send lost item notification with correct variables', async () => { + const result = await service.sendLostItemToCustomerService(rental, owner, renter); + + expect(result.success).toBe(true); + expect(service.templateManager.renderTemplate).toHaveBeenCalledWith( + 'lostItemToCS', + expect.objectContaining({ + rentalId: 123, + itemName: 'Power Drill', + ownerName: 'John Doe', + ownerEmail: 'john@example.com', + renterName: 'Jane Smith', + renterEmail: 'jane@example.com', + replacementCost: '200.00', + }) + ); + expect(service.emailClient.sendEmail).toHaveBeenCalledWith( + 'support@example.com', + 'Lost Item Claim Filed - Action Required', + expect.any(String) + ); + }); + + it('should return error when customer service email is not configured', async () => { + delete process.env.CUSTOMER_SUPPORT_EMAIL; + + const result = await service.sendLostItemToCustomerService(rental, owner, renter); + + expect(result.success).toBe(false); + expect(result.error).toBe('No customer service email configured'); + expect(service.emailClient.sendEmail).not.toHaveBeenCalled(); + }); + + it('should handle errors gracefully', async () => { + service.templateManager.renderTemplate.mockRejectedValueOnce(new Error('Template error')); + + const result = await service.sendLostItemToCustomerService(rental, owner, renter); + + expect(result.success).toBe(false); + expect(result.error).toBe('Template error'); + }); + }); +}); diff --git a/backend/tests/unit/services/email/domain/MessagingEmailService.test.js b/backend/tests/unit/services/email/domain/MessagingEmailService.test.js new file mode 100644 index 0000000..c332eb4 --- /dev/null +++ b/backend/tests/unit/services/email/domain/MessagingEmailService.test.js @@ -0,0 +1,107 @@ +// 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('Test'), + })); +}); + +jest.mock('../../../../../utils/logger', () => ({ + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), +})); + +const MessagingEmailService = require('../../../../../services/email/domain/MessagingEmailService'); + +describe('MessagingEmailService', () => { + let service; + const originalEnv = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { ...originalEnv, FRONTEND_URL: 'http://localhost:3000' }; + service = new MessagingEmailService(); + }); + + 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('sendNewMessageNotification', () => { + const receiver = { firstName: 'John', email: 'john@example.com' }; + const sender = { id: 456, firstName: 'Jane', lastName: 'Smith' }; + const message = { + content: 'Hello, is the power drill still available?', + createdAt: '2024-01-15T10:00:00Z', + }; + + it('should send new message notification with correct variables', async () => { + const result = await service.sendNewMessageNotification(receiver, sender, message); + + expect(result.success).toBe(true); + expect(service.templateManager.renderTemplate).toHaveBeenCalledWith( + 'newMessageToUser', + expect.objectContaining({ + recipientName: 'John', + senderName: 'Jane Smith', + messageContent: 'Hello, is the power drill still available?', + conversationUrl: 'http://localhost:3000/messages/conversations/456', + }) + ); + expect(service.emailClient.sendEmail).toHaveBeenCalledWith( + 'john@example.com', + 'New message from Jane Smith', + expect.any(String) + ); + }); + + it('should use default name when receiver firstName is missing', async () => { + const receiverNoName = { email: 'john@example.com' }; + + await service.sendNewMessageNotification(receiverNoName, sender, message); + + expect(service.templateManager.renderTemplate).toHaveBeenCalledWith( + 'newMessageToUser', + expect.objectContaining({ recipientName: 'there' }) + ); + }); + + it('should use A user fallback when sender names produce empty string', async () => { + const senderEmptyNames = { id: 456, firstName: '', lastName: '' }; + + await service.sendNewMessageNotification(receiver, senderEmptyNames, message); + + expect(service.templateManager.renderTemplate).toHaveBeenCalledWith( + 'newMessageToUser', + expect.objectContaining({ senderName: 'A user' }) + ); + }); + + it('should handle errors gracefully', async () => { + service.templateManager.renderTemplate.mockRejectedValueOnce(new Error('Template error')); + + const result = await service.sendNewMessageNotification(receiver, sender, message); + + expect(result.success).toBe(false); + expect(result.error).toBe('Template error'); + }); + }); +}); diff --git a/backend/tests/unit/services/email/domain/RentalFlowEmailService.test.js b/backend/tests/unit/services/email/domain/RentalFlowEmailService.test.js new file mode 100644 index 0000000..4f1016d --- /dev/null +++ b/backend/tests/unit/services/email/domain/RentalFlowEmailService.test.js @@ -0,0 +1,986 @@ +// 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('Test'), + })); +}); + +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'); + }); + }); +}); diff --git a/backend/tests/unit/services/email/domain/RentalReminderEmailService.test.js b/backend/tests/unit/services/email/domain/RentalReminderEmailService.test.js new file mode 100644 index 0000000..378c889 --- /dev/null +++ b/backend/tests/unit/services/email/domain/RentalReminderEmailService.test.js @@ -0,0 +1,114 @@ +// 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('Test'), + })); +}); + +jest.mock('../../../../../utils/logger', () => ({ + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), +})); + +const RentalReminderEmailService = require('../../../../../services/email/domain/RentalReminderEmailService'); + +describe('RentalReminderEmailService', () => { + let service; + const originalEnv = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { ...originalEnv, FRONTEND_URL: 'http://localhost:3000' }; + service = new RentalReminderEmailService(); + }); + + 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('sendConditionCheckReminder', () => { + const userEmail = 'john@example.com'; + const notification = { + title: 'Condition Check Required', + message: 'Please complete the condition check for your rental.', + metadata: { + deadline: '2024-01-16T10:00:00Z', + }, + }; + const rental = { + item: { name: 'Power Drill' }, + }; + + it('should send condition check reminder with correct variables', async () => { + const result = await service.sendConditionCheckReminder(userEmail, notification, rental); + + expect(result.success).toBe(true); + expect(service.templateManager.renderTemplate).toHaveBeenCalledWith( + 'conditionCheckReminderToUser', + expect.objectContaining({ + title: 'Condition Check Required', + message: 'Please complete the condition check for your rental.', + itemName: 'Power Drill', + }) + ); + expect(service.emailClient.sendEmail).toHaveBeenCalledWith( + 'john@example.com', + 'Village Share: Condition Check Required', + expect.any(String) + ); + }); + + it('should use default item name when rental item is missing', async () => { + const rentalNoItem = {}; + + await service.sendConditionCheckReminder(userEmail, notification, rentalNoItem); + + expect(service.templateManager.renderTemplate).toHaveBeenCalledWith( + 'conditionCheckReminderToUser', + expect.objectContaining({ itemName: 'Unknown Item' }) + ); + }); + + it('should use default deadline when metadata is missing', async () => { + const notificationNoDeadline = { + ...notification, + metadata: {}, + }; + + await service.sendConditionCheckReminder(userEmail, notificationNoDeadline, rental); + + expect(service.templateManager.renderTemplate).toHaveBeenCalledWith( + 'conditionCheckReminderToUser', + expect.objectContaining({ deadline: 'Not specified' }) + ); + }); + + it('should handle errors gracefully', async () => { + service.templateManager.renderTemplate.mockRejectedValueOnce(new Error('Template error')); + + const result = await service.sendConditionCheckReminder(userEmail, notification, rental); + + expect(result.success).toBe(false); + expect(result.error).toBe('Template error'); + }); + }); +}); diff --git a/backend/tests/unit/services/email/domain/UserEngagementEmailService.test.js b/backend/tests/unit/services/email/domain/UserEngagementEmailService.test.js new file mode 100644 index 0000000..4077258 --- /dev/null +++ b/backend/tests/unit/services/email/domain/UserEngagementEmailService.test.js @@ -0,0 +1,196 @@ +// 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('Test'), + })); +}); + +jest.mock('../../../../../utils/logger', () => ({ + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), +})); + +const UserEngagementEmailService = require('../../../../../services/email/domain/UserEngagementEmailService'); + +describe('UserEngagementEmailService', () => { + let service; + const originalEnv = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { + ...originalEnv, + FRONTEND_URL: 'http://localhost:3000', + SUPPORT_EMAIL: 'support@villageshare.com', + }; + service = new UserEngagementEmailService(); + }); + + 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('sendFirstListingCelebrationEmail', () => { + const owner = { firstName: 'John', email: 'john@example.com' }; + const item = { id: 123, name: 'Power Drill' }; + + it('should send first listing celebration email with correct variables', async () => { + const result = await service.sendFirstListingCelebrationEmail(owner, item); + + expect(result.success).toBe(true); + expect(service.templateManager.renderTemplate).toHaveBeenCalledWith( + 'firstListingCelebrationToOwner', + expect.objectContaining({ + ownerName: 'John', + itemName: 'Power Drill', + itemId: 123, + viewItemUrl: 'http://localhost:3000/items/123', + }) + ); + expect(service.emailClient.sendEmail).toHaveBeenCalledWith( + 'john@example.com', + 'Congratulations! Your first item is live on Village Share', + expect.any(String) + ); + }); + + it('should use default name when firstName is missing', async () => { + const ownerNoName = { email: 'john@example.com' }; + + await service.sendFirstListingCelebrationEmail(ownerNoName, item); + + expect(service.templateManager.renderTemplate).toHaveBeenCalledWith( + 'firstListingCelebrationToOwner', + expect.objectContaining({ ownerName: 'there' }) + ); + }); + + it('should handle errors gracefully', async () => { + service.templateManager.renderTemplate.mockRejectedValueOnce(new Error('Template error')); + + const result = await service.sendFirstListingCelebrationEmail(owner, item); + + expect(result.success).toBe(false); + expect(result.error).toBe('Template error'); + }); + }); + + describe('sendItemDeletionNotificationToOwner', () => { + const owner = { firstName: 'John', email: 'john@example.com' }; + const item = { id: 123, name: 'Power Drill' }; + const deletionReason = 'Violated community guidelines'; + + it('should send item deletion notification with correct variables', async () => { + const result = await service.sendItemDeletionNotificationToOwner( + owner, + item, + deletionReason + ); + + expect(result.success).toBe(true); + expect(service.templateManager.renderTemplate).toHaveBeenCalledWith( + 'itemDeletionToOwner', + expect.objectContaining({ + ownerName: 'John', + itemName: 'Power Drill', + deletionReason: 'Violated community guidelines', + supportEmail: 'support@villageshare.com', + dashboardUrl: 'http://localhost:3000/owning', + }) + ); + expect(service.emailClient.sendEmail).toHaveBeenCalledWith( + 'john@example.com', + 'Important: Your listing "Power Drill" has been removed', + expect.any(String) + ); + }); + + it('should use default name when firstName is missing', async () => { + const ownerNoName = { email: 'john@example.com' }; + + await service.sendItemDeletionNotificationToOwner(ownerNoName, item, deletionReason); + + expect(service.templateManager.renderTemplate).toHaveBeenCalledWith( + 'itemDeletionToOwner', + expect.objectContaining({ ownerName: 'there' }) + ); + }); + + it('should handle errors gracefully', async () => { + service.emailClient.sendEmail.mockRejectedValueOnce(new Error('Send error')); + + const result = await service.sendItemDeletionNotificationToOwner( + owner, + item, + deletionReason + ); + + expect(result.success).toBe(false); + expect(result.error).toBe('Send error'); + }); + }); + + describe('sendUserBannedNotification', () => { + const bannedUser = { firstName: 'John', email: 'john@example.com' }; + const admin = { firstName: 'Admin', lastName: 'User' }; + const banReason = 'Multiple policy violations'; + + it('should send user banned notification with correct variables', async () => { + const result = await service.sendUserBannedNotification(bannedUser, admin, banReason); + + expect(result.success).toBe(true); + expect(service.templateManager.renderTemplate).toHaveBeenCalledWith( + 'userBannedNotification', + expect.objectContaining({ + userName: 'John', + banReason: 'Multiple policy violations', + supportEmail: 'support@villageshare.com', + }) + ); + expect(service.emailClient.sendEmail).toHaveBeenCalledWith( + 'john@example.com', + 'Important: Your Village Share Account Has Been Suspended', + expect.any(String) + ); + }); + + it('should use default name when firstName is missing', async () => { + const bannedUserNoName = { email: 'john@example.com' }; + + await service.sendUserBannedNotification(bannedUserNoName, admin, banReason); + + expect(service.templateManager.renderTemplate).toHaveBeenCalledWith( + 'userBannedNotification', + expect.objectContaining({ userName: 'there' }) + ); + }); + + it('should handle errors gracefully', async () => { + service.templateManager.renderTemplate.mockRejectedValueOnce(new Error('Template error')); + + const result = await service.sendUserBannedNotification(bannedUser, admin, banReason); + + expect(result.success).toBe(false); + expect(result.error).toBe('Template error'); + }); + }); +});