tests
This commit is contained in:
@@ -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 (
|
||||||
|
|||||||
165
backend/tests/unit/services/conditionCheckService.test.js
Normal file
165
backend/tests/unit/services/conditionCheckService.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
219
backend/tests/unit/services/damageAssessmentService.test.js
Normal file
219
backend/tests/unit/services/damageAssessmentService.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
251
backend/tests/unit/services/emailService.test.js
Normal file
251
backend/tests/unit/services/emailService.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
163
backend/tests/unit/services/lateReturnService.test.js
Normal file
163
backend/tests/unit/services/lateReturnService.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user