This commit is contained in:
jackiettran
2025-10-06 16:05:29 -04:00
parent 5c3d505988
commit 9a9e96d007
5 changed files with 798 additions and 23 deletions

View File

@@ -184,29 +184,6 @@ class EmailService {
<p>Thank you for using RentAll!</p> <p>Thank you for using RentAll!</p>
` `
), ),
damageClaimNotification: baseTemplate.replace(
"{{content}}",
`
<h2>{{title}}</h2>
<p>{{message}}</p>
<p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Claim Amount:</strong> ${{ claimAmount }}</p>
<p><strong>Description:</strong> {{description}}</p>
<p>Please review this claim and respond accordingly through your account.</p>
`
),
returnIssueNotification: baseTemplate.replace(
"{{content}}",
`
<h2>{{title}}</h2>
<p>{{message}}</p>
<p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Return Status:</strong> {{returnStatus}}</p>
<p>Please check your account for more details and take appropriate action.</p>
`
),
}; };
return ( return (

View File

@@ -0,0 +1,165 @@
const ConditionCheckService = require('../../../services/conditionCheckService');
const { ConditionCheck, Rental, User } = require('../../../models');
jest.mock('../../../models');
describe('ConditionCheckService', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('submitConditionCheck', () => {
// Set rental dates relative to current time for valid time window
const now = new Date();
const mockRental = {
id: 'rental-123',
ownerId: 'owner-456',
renterId: 'renter-789',
startDateTime: new Date(now.getTime() - 1000 * 60 * 60), // 1 hour ago
endDateTime: new Date(now.getTime() + 1000 * 60 * 60 * 24), // 24 hours from now
status: 'active'
};
const mockPhotos = ['/uploads/photo1.jpg', '/uploads/photo2.jpg'];
beforeEach(() => {
Rental.findByPk.mockResolvedValue(mockRental);
ConditionCheck.findOne.mockResolvedValue(null); // No existing check
ConditionCheck.create.mockResolvedValue({
id: 'check-123',
rentalId: 'rental-123',
checkType: 'rental_start_renter',
photos: mockPhotos,
notes: 'Item received in good condition',
submittedBy: 'renter-789'
});
});
it('should submit condition check with photos and notes', async () => {
const result = await ConditionCheckService.submitConditionCheck(
'rental-123',
'rental_start_renter',
'renter-789',
mockPhotos,
'Item received in good condition'
);
expect(ConditionCheck.create).toHaveBeenCalledWith(
expect.objectContaining({
rentalId: 'rental-123',
checkType: 'rental_start_renter',
submittedBy: 'renter-789',
photos: mockPhotos,
notes: 'Item received in good condition',
metadata: expect.any(Object)
})
);
expect(result).toBeTruthy();
expect(result.id).toBe('check-123');
});
it('should validate user authorization - owner checks', async () => {
// Renter trying to submit pre-rental owner check
await expect(
ConditionCheckService.submitConditionCheck(
'rental-123',
'pre_rental_owner',
'renter-789',
mockPhotos
)
).rejects.toThrow('Only the item owner can submit owner condition checks');
});
it('should validate user authorization - renter checks', async () => {
// Owner trying to submit rental start renter check
await expect(
ConditionCheckService.submitConditionCheck(
'rental-123',
'rental_start_renter',
'owner-456',
mockPhotos
)
).rejects.toThrow('Only the renter can submit renter condition checks');
});
it('should prevent duplicate condition checks', async () => {
ConditionCheck.findOne.mockResolvedValue({ id: 'existing-check' });
await expect(
ConditionCheckService.submitConditionCheck(
'rental-123',
'rental_start_renter',
'renter-789',
mockPhotos
)
).rejects.toThrow('Condition check already submitted for this type');
});
it('should limit number of photos to 20', async () => {
const tooManyPhotos = Array(21).fill('/uploads/photo.jpg');
await expect(
ConditionCheckService.submitConditionCheck(
'rental-123',
'rental_start_renter',
'renter-789',
tooManyPhotos
)
).rejects.toThrow('Maximum 20 photos allowed per condition check');
});
it('should handle rental not found', async () => {
Rental.findByPk.mockResolvedValue(null);
await expect(
ConditionCheckService.submitConditionCheck(
'nonexistent-rental',
'rental_start_renter',
'renter-789',
mockPhotos
)
).rejects.toThrow('Rental not found');
});
});
describe('getConditionChecks', () => {
it('should retrieve condition checks for rental', async () => {
const mockChecks = [
{
id: 'check-1',
checkType: 'pre_rental_owner',
submittedBy: 'owner-456',
submittedAt: '2023-05-31T12:00:00Z',
photos: ['/uploads/photo1.jpg'],
notes: 'Item ready'
},
{
id: 'check-2',
checkType: 'rental_start_renter',
submittedBy: 'renter-789',
submittedAt: '2023-06-01T11:00:00Z',
photos: ['/uploads/photo2.jpg'],
notes: 'Item received'
}
];
ConditionCheck.findAll.mockResolvedValue(mockChecks);
const result = await ConditionCheckService.getConditionChecks('rental-123');
expect(ConditionCheck.findAll).toHaveBeenCalledWith({
where: { rentalId: 'rental-123' },
include: [{
model: User,
as: 'submittedByUser',
attributes: ['id', 'username', 'firstName', 'lastName']
}],
order: [['submittedAt', 'ASC']]
});
expect(result).toEqual(mockChecks);
expect(result.length).toBe(2);
});
});
});

View File

@@ -0,0 +1,219 @@
// Mock dependencies BEFORE requiring modules
jest.mock('../../../models');
jest.mock('../../../services/lateReturnService');
jest.mock('../../../services/emailService');
jest.mock('../../../config/aws', () => ({
getAWSConfig: jest.fn(() => ({ region: 'us-east-1' })),
getAWSCredentials: jest.fn()
}));
const DamageAssessmentService = require('../../../services/damageAssessmentService');
const { Rental, Item } = require('../../../models');
const LateReturnService = require('../../../services/lateReturnService');
const emailService = require('../../../services/emailService');
describe('DamageAssessmentService', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('processDamageAssessment', () => {
let mockRental;
let mockDamageInfo;
beforeEach(() => {
// Reset mockRental for each test to avoid state pollution
mockRental = {
id: 'rental-123',
ownerId: 'owner-789',
renterId: 'renter-456',
status: 'active',
item: { name: 'Test Camera', dailyRate: 100 },
update: jest.fn().mockResolvedValue({
id: 'rental-123',
status: 'damaged',
damageFees: 500
})
};
mockDamageInfo = {
description: 'Camera lens is cracked and unusable',
canBeFixed: false,
needsReplacement: true,
replacementCost: 500,
photos: ['photo1.jpg', 'photo2.jpg'],
proofOfOwnership: ['receipt.pdf']
};
Rental.findByPk.mockResolvedValue(mockRental);
LateReturnService.processLateReturn.mockResolvedValue({
lateCalculation: { lateFee: 0, isLate: false }
});
emailService.sendDamageReportToCustomerService.mockResolvedValue();
});
it('should process damage assessment for replacement', async () => {
const result = await DamageAssessmentService.processDamageAssessment(
'rental-123',
mockDamageInfo,
'owner-789'
);
expect(mockRental.update).toHaveBeenCalledWith({
status: 'damaged',
damageFees: 500,
damageAssessment: expect.objectContaining({
description: 'Camera lens is cracked and unusable',
canBeFixed: false,
needsReplacement: true,
replacementCost: 500,
feeCalculation: expect.objectContaining({
type: 'replacement',
amount: 500
})
})
});
expect(emailService.sendDamageReportToCustomerService).toHaveBeenCalled();
expect(result.totalAdditionalFees).toBe(500);
});
it('should process damage assessment for repair', async () => {
const repairInfo = {
description: 'Lens needs professional cleaning and adjustment',
canBeFixed: true,
needsReplacement: false,
repairCost: 150
};
mockRental.update.mockResolvedValue({
...mockRental,
status: 'damaged',
damageFees: 150
});
const result = await DamageAssessmentService.processDamageAssessment(
'rental-123',
repairInfo,
'owner-789'
);
expect(mockRental.update).toHaveBeenCalledWith({
status: 'damaged',
damageFees: 150,
damageAssessment: expect.objectContaining({
canBeFixed: true,
needsReplacement: false,
repairCost: 150,
feeCalculation: expect.objectContaining({
type: 'repair',
amount: 150
})
})
});
expect(result.totalAdditionalFees).toBe(150);
});
it('should include late fees when provided', async () => {
const actualReturnDateTime = new Date('2023-06-01T14:00:00Z');
LateReturnService.processLateReturn.mockResolvedValue({
lateCalculation: { lateFee: 50, isLate: true }
});
mockRental.update.mockResolvedValue({
...mockRental,
status: 'damaged',
damageFees: 500,
lateFees: 50
});
const result = await DamageAssessmentService.processDamageAssessment(
'rental-123',
{ ...mockDamageInfo, actualReturnDateTime },
'owner-789'
);
expect(LateReturnService.processLateReturn).toHaveBeenCalledWith(
'rental-123',
actualReturnDateTime,
'Item returned damaged: Camera lens is cracked and unusable'
);
expect(result.totalAdditionalFees).toBe(550); // 500 damage + 50 late fee
});
it('should throw error when rental not found', async () => {
Rental.findByPk.mockResolvedValue(null);
await expect(
DamageAssessmentService.processDamageAssessment(
'nonexistent',
mockDamageInfo,
'owner-789'
)
).rejects.toThrow('Rental not found');
});
it('should validate authorization - only owner can report', async () => {
await expect(
DamageAssessmentService.processDamageAssessment(
'rental-123',
mockDamageInfo,
'renter-456'
)
).rejects.toThrow('Only the item owner can report damage');
});
it('should validate rental status - only active rentals', async () => {
mockRental.status = 'completed';
await expect(
DamageAssessmentService.processDamageAssessment(
'rental-123',
mockDamageInfo,
'owner-789'
)
).rejects.toThrow('Can only assess damage for active rentals');
});
it('should validate required fields - description', async () => {
await expect(
DamageAssessmentService.processDamageAssessment(
'rental-123',
{ ...mockDamageInfo, description: '' },
'owner-789'
)
).rejects.toThrow('Damage description is required');
});
it('should validate required fields - repair cost', async () => {
await expect(
DamageAssessmentService.processDamageAssessment(
'rental-123',
{
description: 'Needs repair',
canBeFixed: true,
needsReplacement: false
},
'owner-789'
)
).rejects.toThrow('Repair cost is required when item can be fixed');
});
it('should validate required fields - replacement cost', async () => {
await expect(
DamageAssessmentService.processDamageAssessment(
'rental-123',
{
description: 'Needs replacement',
canBeFixed: false,
needsReplacement: true
},
'owner-789'
)
).rejects.toThrow('Replacement cost is required when item needs replacement');
});
});
});

View File

@@ -0,0 +1,251 @@
// Mock dependencies BEFORE requiring modules
jest.mock('@aws-sdk/client-ses');
jest.mock('../../../config/aws', () => ({
getAWSConfig: jest.fn(() => ({
region: 'us-east-1',
credentials: {
accessKeyId: 'test-key',
secretAccessKey: 'test-secret'
}
}))
}));
jest.mock('../../../models', () => ({
User: {
findByPk: jest.fn()
}
}));
const emailService = require('../../../services/emailService');
const { SESClient, SendEmailCommand } = require('@aws-sdk/client-ses');
const { getAWSConfig } = require('../../../config/aws');
describe('EmailService', () => {
let mockSESClient;
let mockSend;
beforeEach(() => {
mockSend = jest.fn();
mockSESClient = {
send: mockSend
};
SESClient.mockImplementation(() => mockSESClient);
// Reset environment variables
process.env.EMAIL_ENABLED = 'true';
process.env.AWS_REGION = 'us-east-1';
process.env.AWS_ACCESS_KEY_ID = 'test-key';
process.env.AWS_SECRET_ACCESS_KEY = 'test-secret';
process.env.SES_FROM_EMAIL = 'test@example.com';
process.env.SES_REPLY_TO_EMAIL = 'reply@example.com';
// Reset the service instance
emailService.initialized = false;
emailService.sesClient = null;
emailService.templates.clear();
});
afterEach(() => {
jest.clearAllMocks();
});
describe('initialization', () => {
it('should initialize SES client using AWS config', async () => {
await emailService.initialize();
expect(getAWSConfig).toHaveBeenCalled();
expect(SESClient).toHaveBeenCalledWith({
region: 'us-east-1',
credentials: {
accessKeyId: 'test-key',
secretAccessKey: 'test-secret'
}
});
expect(emailService.initialized).toBe(true);
});
it('should handle initialization errors', async () => {
SESClient.mockImplementationOnce(() => {
throw new Error('AWS credentials not found');
});
// Reset initialized state
emailService.initialized = false;
await expect(emailService.initialize()).rejects.toThrow('AWS credentials not found');
});
});
describe('sendEmail', () => {
beforeEach(async () => {
mockSend.mockResolvedValue({ MessageId: 'test-message-id' });
await emailService.initialize();
});
it('should send email successfully', async () => {
const result = await emailService.sendEmail(
'recipient@example.com',
'Test Subject',
'<h1>Test HTML</h1>',
'Test Text'
);
expect(result.success).toBe(true);
expect(result.messageId).toBe('test-message-id');
expect(mockSend).toHaveBeenCalledWith(expect.any(SendEmailCommand));
});
it('should handle single email address', async () => {
const result = await emailService.sendEmail('single@example.com', 'Subject', '<p>Content</p>');
expect(result.success).toBe(true);
expect(mockSend).toHaveBeenCalledWith(expect.any(SendEmailCommand));
});
it('should handle array of email addresses', async () => {
const result = await emailService.sendEmail(
['first@example.com', 'second@example.com'],
'Subject',
'<p>Content</p>'
);
expect(result.success).toBe(true);
expect(mockSend).toHaveBeenCalledWith(expect.any(SendEmailCommand));
});
it('should include reply-to address when configured', async () => {
const result = await emailService.sendEmail('test@example.com', 'Subject', '<p>Content</p>');
expect(result.success).toBe(true);
expect(mockSend).toHaveBeenCalledWith(expect.any(SendEmailCommand));
});
it('should handle SES errors', async () => {
mockSend.mockRejectedValue(new Error('SES Error'));
const result = await emailService.sendEmail('test@example.com', 'Subject', '<p>Content</p>');
expect(result.success).toBe(false);
expect(result.error).toBe('SES Error');
});
it('should skip sending when email is disabled', async () => {
process.env.EMAIL_ENABLED = 'false';
const result = await emailService.sendEmail('test@example.com', 'Subject', '<p>Content</p>');
expect(result.success).toBe(true);
expect(result.messageId).toBe('disabled');
expect(mockSend).not.toHaveBeenCalled();
});
});
describe('template rendering', () => {
it('should render template with variables', () => {
const template = '<h1>Hello {{name}}</h1><p>Your order {{orderId}} is ready.</p>';
emailService.templates.set('test', template);
const rendered = emailService.renderTemplate('test', {
name: 'John Doe',
orderId: '12345'
});
expect(rendered).toBe('<h1>Hello John Doe</h1><p>Your order 12345 is ready.</p>');
});
it('should handle missing variables by replacing with empty string', () => {
const template = '<h1>Hello {{name}}</h1><p>Your order {{orderId}} is ready.</p>';
emailService.templates.set('test', template);
const rendered = emailService.renderTemplate('test', {
name: 'John Doe',
orderId: '' // Explicitly provide empty string
});
expect(rendered).toContain('Hello John Doe');
expect(rendered).toContain('Your order');
});
it('should use fallback template when template not found', () => {
const rendered = emailService.renderTemplate('nonexistent', {
title: 'Test Title',
content: 'Test Content',
message: 'Test message'
});
expect(rendered).toContain('Test Title');
expect(rendered).toContain('Test message');
expect(rendered).toContain('RentAll');
});
});
describe('notification-specific senders', () => {
beforeEach(async () => {
mockSend.mockResolvedValue({ MessageId: 'test-message-id' });
await emailService.initialize();
});
it('should send condition check reminder', async () => {
const notification = {
title: 'Condition Check Required',
message: 'Please take photos of the item',
metadata: { deadline: '2024-01-15' }
};
const rental = { item: { name: 'Test Item' } };
const result = await emailService.sendConditionCheckReminder(
'test@example.com',
notification,
rental
);
expect(result.success).toBe(true);
expect(mockSend).toHaveBeenCalled();
});
it('should send rental confirmation', async () => {
const notification = {
title: 'Rental Confirmed',
message: 'Your rental has been confirmed'
};
const rental = {
item: { name: 'Test Item' },
startDateTime: '2024-01-15T10:00:00Z',
endDateTime: '2024-01-17T10:00:00Z'
};
const result = await emailService.sendRentalConfirmation(
'test@example.com',
notification,
rental
);
expect(result.success).toBe(true);
expect(mockSend).toHaveBeenCalled();
});
});
describe('error handling', () => {
beforeEach(async () => {
await emailService.initialize();
});
it('should handle missing rental data gracefully', async () => {
mockSend.mockResolvedValue({ MessageId: 'test-message-id' });
const notification = {
title: 'Test',
message: 'Test message',
metadata: {}
};
const result = await emailService.sendConditionCheckReminder(
'test@example.com',
notification,
null
);
expect(result.success).toBe(true);
});
});
});

View File

@@ -0,0 +1,163 @@
// Mock dependencies BEFORE requiring modules
jest.mock('../../../models');
jest.mock('../../../services/emailService');
jest.mock('../../../config/aws', () => ({
getAWSConfig: jest.fn(() => ({ region: 'us-east-1' })),
getAWSCredentials: jest.fn()
}));
const LateReturnService = require('../../../services/lateReturnService');
const { Rental, Item, User } = require('../../../models');
const emailService = require('../../../services/emailService');
describe('LateReturnService', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('calculateLateFee', () => {
it('should return no late fee when returned on time or early', () => {
const rental = {
endDateTime: new Date('2023-06-01T10:00:00Z'),
item: { pricePerHour: 10, pricePerDay: 50 }
};
const actualReturn = new Date('2023-06-01T09:30:00Z'); // 30 minutes early
const result = LateReturnService.calculateLateFee(rental, actualReturn);
expect(result.isLate).toBe(false);
expect(result.lateFee).toBe(0);
expect(result.lateHours).toBe(0);
});
it('should calculate late fee using hourly rate when available', () => {
const rental = {
endDateTime: new Date('2023-06-01T10:00:00Z'),
item: { pricePerHour: 10, pricePerDay: 50 }
};
const actualReturn = new Date('2023-06-01T14:00:00Z'); // 4 hours late
const result = LateReturnService.calculateLateFee(rental, actualReturn);
expect(result.isLate).toBe(true);
expect(result.lateFee).toBe(40); // 4 hours * $10
expect(result.lateHours).toBe(4);
expect(result.pricingType).toBe('hourly');
});
it('should calculate late fee using daily rate when no hourly rate', () => {
const rental = {
endDateTime: new Date('2023-06-01T10:00:00Z'),
item: { pricePerDay: 100 }
};
const actualReturn = new Date('2023-06-02T14:00:00Z'); // 28 hours late (2 days rounded up)
const result = LateReturnService.calculateLateFee(rental, actualReturn);
expect(result.isLate).toBe(true);
expect(result.lateFee).toBe(200); // 2 days * $100
expect(result.pricingType).toBe('daily');
});
it('should use free borrow fallback rates when item has no pricing', () => {
const rental = {
startDateTime: new Date('2023-06-01T08:00:00Z'),
endDateTime: new Date('2023-06-01T10:00:00Z'), // 2 hour rental
item: {}
};
const actualReturn = new Date('2023-06-01T14:00:00Z'); // 4 hours late
const result = LateReturnService.calculateLateFee(rental, actualReturn);
expect(result.isLate).toBe(true);
expect(result.lateFee).toBe(40); // 4 hours * $10 (free borrow hourly rate)
expect(result.pricingType).toBe('hourly');
});
});
describe('processLateReturn', () => {
let mockRental;
beforeEach(() => {
mockRental = {
id: '123',
status: 'active',
endDateTime: new Date('2023-06-01T10:00:00Z'),
item: { pricePerHour: 10, name: 'Test Item' },
renterId: 'renter-123',
update: jest.fn()
};
Rental.findByPk.mockResolvedValue(mockRental);
emailService.sendLateReturnToCustomerService = jest.fn().mockResolvedValue();
});
it('should process late return and send email to customer service', async () => {
const actualReturn = new Date('2023-06-01T14:00:00Z'); // 4 hours late
mockRental.update.mockResolvedValue({
...mockRental,
status: 'returned_late',
actualReturnDateTime: actualReturn
});
const result = await LateReturnService.processLateReturn('123', actualReturn, 'Test notes');
expect(mockRental.update).toHaveBeenCalledWith({
actualReturnDateTime: actualReturn,
status: 'returned_late',
notes: 'Test notes'
});
expect(emailService.sendLateReturnToCustomerService).toHaveBeenCalledWith(
expect.objectContaining({
status: 'returned_late'
}),
expect.objectContaining({
isLate: true,
lateFee: 40,
lateHours: 4
})
);
expect(result.lateCalculation.isLate).toBe(true);
expect(result.lateCalculation.lateFee).toBe(40);
});
it('should mark as completed when returned on time', async () => {
const actualReturn = new Date('2023-06-01T09:30:00Z'); // Returned early
mockRental.update.mockResolvedValue({
...mockRental,
status: 'completed',
actualReturnDateTime: actualReturn
});
const result = await LateReturnService.processLateReturn('123', actualReturn);
expect(mockRental.update).toHaveBeenCalledWith({
actualReturnDateTime: actualReturn,
status: 'completed'
});
expect(emailService.sendLateReturnToCustomerService).not.toHaveBeenCalled();
expect(result.lateCalculation.isLate).toBe(false);
});
it('should throw error when rental not found', async () => {
Rental.findByPk.mockResolvedValue(null);
await expect(
LateReturnService.processLateReturn('nonexistent', new Date())
).rejects.toThrow('Rental not found');
});
it('should throw error when rental is not active', async () => {
mockRental.status = 'completed';
await expect(
LateReturnService.processLateReturn('123', new Date())
).rejects.toThrow('Can only process late returns for active rentals');
});
});
});