unit tests

This commit is contained in:
jackiettran
2025-12-12 16:27:56 -05:00
parent 25bbf5d20b
commit 3f319bfdd0
24 changed files with 4282 additions and 1806 deletions

View File

@@ -49,7 +49,7 @@ describe('ConditionCheckService', () => {
rentalId: 'rental-123',
checkType: 'rental_start_renter',
submittedBy: 'renter-789',
photos: mockPhotos,
imageFilenames: mockPhotos,
notes: 'Item received in good condition',
})
);

View File

@@ -1,7 +1,11 @@
// Mock dependencies BEFORE requiring modules
jest.mock('../../../models');
jest.mock('../../../services/lateReturnService');
jest.mock('../../../services/emailService');
jest.mock('../../../services/email', () => ({
customerService: {
sendDamageReportToCustomerService: jest.fn().mockResolvedValue()
}
}));
jest.mock('../../../config/aws', () => ({
getAWSConfig: jest.fn(() => ({ region: 'us-east-1' })),
getAWSCredentials: jest.fn()
@@ -10,7 +14,7 @@ jest.mock('../../../config/aws', () => ({
const DamageAssessmentService = require('../../../services/damageAssessmentService');
const { Rental, Item } = require('../../../models');
const LateReturnService = require('../../../services/lateReturnService');
const emailService = require('../../../services/emailService');
const emailService = require('../../../services/email');
describe('DamageAssessmentService', () => {
beforeEach(() => {
@@ -49,7 +53,7 @@ describe('DamageAssessmentService', () => {
LateReturnService.processLateReturn.mockResolvedValue({
lateCalculation: { lateFee: 0, isLate: false }
});
emailService.sendDamageReportToCustomerService.mockResolvedValue();
emailService.customerService.sendDamageReportToCustomerService.mockResolvedValue();
});
it('should process damage assessment for replacement', async () => {
@@ -74,7 +78,7 @@ describe('DamageAssessmentService', () => {
})
});
expect(emailService.sendDamageReportToCustomerService).toHaveBeenCalled();
expect(emailService.customerService.sendDamageReportToCustomerService).toHaveBeenCalled();
expect(result.totalAdditionalFees).toBe(500);
});

View File

@@ -0,0 +1,297 @@
// Mock AWS SDK before requiring modules
jest.mock('@aws-sdk/client-ses', () => ({
SESClient: jest.fn().mockImplementation(() => ({
send: jest.fn(),
})),
SendEmailCommand: jest.fn(),
}));
jest.mock('../../../../config/aws', () => ({
getAWSConfig: jest.fn(() => ({ region: 'us-east-1' })),
}));
jest.mock('../../../../services/email/core/emailUtils', () => ({
htmlToPlainText: jest.fn((html) => html.replace(/<[^>]*>/g, '')),
}));
// Clear singleton between tests
beforeEach(() => {
jest.clearAllMocks();
// Reset the singleton instance
const EmailClient = require('../../../../services/email/core/EmailClient');
EmailClient.instance = null;
});
describe('EmailClient', () => {
const { SESClient, SendEmailCommand } = require('@aws-sdk/client-ses');
const { getAWSConfig } = require('../../../../config/aws');
describe('constructor', () => {
it('should create a new instance', () => {
const EmailClient = require('../../../../services/email/core/EmailClient');
EmailClient.instance = null;
const client = new EmailClient();
expect(client).toBeDefined();
expect(client.sesClient).toBeNull();
expect(client.initialized).toBe(false);
});
it('should return existing instance (singleton pattern)', () => {
const EmailClient = require('../../../../services/email/core/EmailClient');
EmailClient.instance = null;
const client1 = new EmailClient();
const client2 = new EmailClient();
expect(client1).toBe(client2);
});
});
describe('initialize', () => {
it('should initialize SES client with AWS config', async () => {
const EmailClient = require('../../../../services/email/core/EmailClient');
EmailClient.instance = null;
const client = new EmailClient();
await client.initialize();
expect(getAWSConfig).toHaveBeenCalled();
expect(SESClient).toHaveBeenCalledWith({ region: 'us-east-1' });
expect(client.initialized).toBe(true);
});
it('should not re-initialize if already initialized', async () => {
const EmailClient = require('../../../../services/email/core/EmailClient');
EmailClient.instance = null;
const client = new EmailClient();
await client.initialize();
await client.initialize();
expect(SESClient).toHaveBeenCalledTimes(1);
});
it('should wait for existing initialization if in progress', async () => {
const EmailClient = require('../../../../services/email/core/EmailClient');
EmailClient.instance = null;
const client = new EmailClient();
// Start two initializations concurrently
const [result1, result2] = await Promise.all([
client.initialize(),
client.initialize(),
]);
expect(SESClient).toHaveBeenCalledTimes(1);
});
it('should throw error if AWS config fails', async () => {
getAWSConfig.mockImplementationOnce(() => {
throw new Error('AWS config error');
});
const EmailClient = require('../../../../services/email/core/EmailClient');
EmailClient.instance = null;
const client = new EmailClient();
await expect(client.initialize()).rejects.toThrow('AWS config error');
});
});
describe('sendEmail', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = {
...originalEnv,
EMAIL_ENABLED: 'true',
SES_FROM_EMAIL: 'noreply@rentall.com',
SES_FROM_NAME: 'RentAll',
};
});
afterEach(() => {
process.env = originalEnv;
});
it('should return early if EMAIL_ENABLED is not true', async () => {
process.env.EMAIL_ENABLED = 'false';
const EmailClient = require('../../../../services/email/core/EmailClient');
EmailClient.instance = null;
const client = new EmailClient();
const result = await client.sendEmail(
'test@example.com',
'Test Subject',
'<p>Hello</p>'
);
expect(result).toEqual({ success: true, messageId: 'disabled' });
});
it('should return early if EMAIL_ENABLED is not set', async () => {
delete process.env.EMAIL_ENABLED;
const EmailClient = require('../../../../services/email/core/EmailClient');
EmailClient.instance = null;
const client = new EmailClient();
const result = await client.sendEmail(
'test@example.com',
'Test Subject',
'<p>Hello</p>'
);
expect(result).toEqual({ success: true, messageId: 'disabled' });
});
it('should send email with correct parameters', async () => {
const mockSend = jest.fn().mockResolvedValue({ MessageId: 'msg-123' });
SESClient.mockImplementation(() => ({ send: mockSend }));
const EmailClient = require('../../../../services/email/core/EmailClient');
EmailClient.instance = null;
const client = new EmailClient();
const result = await client.sendEmail(
'test@example.com',
'Test Subject',
'<p>Hello World</p>'
);
expect(SendEmailCommand).toHaveBeenCalledWith({
Source: 'RentAll <noreply@rentall.com>',
Destination: {
ToAddresses: ['test@example.com'],
},
Message: {
Subject: {
Data: 'Test Subject',
Charset: 'UTF-8',
},
Body: {
Html: {
Data: '<p>Hello World</p>',
Charset: 'UTF-8',
},
Text: {
Data: expect.any(String),
Charset: 'UTF-8',
},
},
},
});
expect(result).toEqual({ success: true, messageId: 'msg-123' });
});
it('should send to multiple recipients', async () => {
const mockSend = jest.fn().mockResolvedValue({ MessageId: 'msg-456' });
SESClient.mockImplementation(() => ({ send: mockSend }));
const EmailClient = require('../../../../services/email/core/EmailClient');
EmailClient.instance = null;
const client = new EmailClient();
await client.sendEmail(
['user1@example.com', 'user2@example.com'],
'Test Subject',
'<p>Hello</p>'
);
expect(SendEmailCommand).toHaveBeenCalledWith(
expect.objectContaining({
Destination: {
ToAddresses: ['user1@example.com', 'user2@example.com'],
},
})
);
});
it('should use provided text content', async () => {
const mockSend = jest.fn().mockResolvedValue({ MessageId: 'msg-789' });
SESClient.mockImplementation(() => ({ send: mockSend }));
const EmailClient = require('../../../../services/email/core/EmailClient');
EmailClient.instance = null;
const client = new EmailClient();
await client.sendEmail(
'test@example.com',
'Test Subject',
'<p>Hello</p>',
'Custom plain text'
);
expect(SendEmailCommand).toHaveBeenCalledWith(
expect.objectContaining({
Message: expect.objectContaining({
Body: expect.objectContaining({
Text: {
Data: 'Custom plain text',
Charset: 'UTF-8',
},
}),
}),
})
);
});
it('should add reply-to address if configured', async () => {
process.env.SES_REPLY_TO_EMAIL = 'support@rentall.com';
const mockSend = jest.fn().mockResolvedValue({ MessageId: 'msg-000' });
SESClient.mockImplementation(() => ({ send: mockSend }));
const EmailClient = require('../../../../services/email/core/EmailClient');
EmailClient.instance = null;
const client = new EmailClient();
await client.sendEmail(
'test@example.com',
'Test Subject',
'<p>Hello</p>'
);
expect(SendEmailCommand).toHaveBeenCalledWith(
expect.objectContaining({
ReplyToAddresses: ['support@rentall.com'],
})
);
});
it('should return error if send fails', async () => {
const mockSend = jest.fn().mockRejectedValue(new Error('SES send failed'));
SESClient.mockImplementation(() => ({ send: mockSend }));
const EmailClient = require('../../../../services/email/core/EmailClient');
EmailClient.instance = null;
const client = new EmailClient();
const result = await client.sendEmail(
'test@example.com',
'Test Subject',
'<p>Hello</p>'
);
expect(result).toEqual({ success: false, error: 'SES send failed' });
});
it('should auto-initialize if not initialized', async () => {
const mockSend = jest.fn().mockResolvedValue({ MessageId: 'msg-auto' });
SESClient.mockImplementation(() => ({ send: mockSend }));
const EmailClient = require('../../../../services/email/core/EmailClient');
EmailClient.instance = null;
const client = new EmailClient();
expect(client.initialized).toBe(false);
await client.sendEmail(
'test@example.com',
'Test Subject',
'<p>Hello</p>'
);
expect(client.initialized).toBe(true);
});
});
});

View File

@@ -0,0 +1,281 @@
// Mock fs before requiring modules
jest.mock('fs', () => ({
promises: {
readFile: jest.fn(),
},
}));
// Clear singleton between tests
beforeEach(() => {
jest.clearAllMocks();
const TemplateManager = require('../../../../services/email/core/TemplateManager');
TemplateManager.instance = null;
});
describe('TemplateManager', () => {
const fs = require('fs').promises;
describe('constructor', () => {
it('should create a new instance', () => {
const TemplateManager = require('../../../../services/email/core/TemplateManager');
TemplateManager.instance = null;
const manager = new TemplateManager();
expect(manager).toBeDefined();
expect(manager.templates).toBeInstanceOf(Map);
expect(manager.initialized).toBe(false);
});
it('should return existing instance (singleton pattern)', () => {
const TemplateManager = require('../../../../services/email/core/TemplateManager');
TemplateManager.instance = null;
const manager1 = new TemplateManager();
const manager2 = new TemplateManager();
expect(manager1).toBe(manager2);
});
});
describe('initialize', () => {
it('should load all templates on initialization', async () => {
// Mock fs.readFile to return template content
fs.readFile.mockResolvedValue('<html>{{content}}</html>');
const TemplateManager = require('../../../../services/email/core/TemplateManager');
TemplateManager.instance = null;
const manager = new TemplateManager();
await manager.initialize();
expect(manager.initialized).toBe(true);
expect(fs.readFile).toHaveBeenCalled();
});
it('should not re-initialize if already initialized', async () => {
fs.readFile.mockResolvedValue('<html>{{content}}</html>');
const TemplateManager = require('../../../../services/email/core/TemplateManager');
TemplateManager.instance = null;
const manager = new TemplateManager();
await manager.initialize();
const callCount = fs.readFile.mock.calls.length;
await manager.initialize();
expect(fs.readFile.mock.calls.length).toBe(callCount);
});
it('should wait for existing initialization if in progress', async () => {
fs.readFile.mockResolvedValue('<html>{{content}}</html>');
const TemplateManager = require('../../../../services/email/core/TemplateManager');
TemplateManager.instance = null;
const manager = new TemplateManager();
// Start two initializations concurrently
await Promise.all([manager.initialize(), manager.initialize()]);
// Should only load templates once
const uniquePaths = new Set(fs.readFile.mock.calls.map((call) => call[0]));
expect(uniquePaths.size).toBeLessThanOrEqual(fs.readFile.mock.calls.length);
});
it('should throw error if critical templates fail to load', async () => {
// All template files fail to load
fs.readFile.mockRejectedValue(new Error('File not found'));
const TemplateManager = require('../../../../services/email/core/TemplateManager');
TemplateManager.instance = null;
const manager = new TemplateManager();
await expect(manager.initialize()).rejects.toThrow('Critical email templates failed to load');
});
it('should succeed if critical templates load but non-critical fail', async () => {
const criticalTemplates = [
'emailVerificationToUser',
'passwordResetToUser',
'passwordChangedToUser',
'personalInfoChangedToUser',
];
fs.readFile.mockImplementation((path) => {
const isCritical = criticalTemplates.some((t) => path.includes(t));
if (isCritical) {
return Promise.resolve('<html>Template content</html>');
}
return Promise.reject(new Error('File not found'));
});
const TemplateManager = require('../../../../services/email/core/TemplateManager');
TemplateManager.instance = null;
const manager = new TemplateManager();
// Should not throw since critical templates loaded
await expect(manager.initialize()).resolves.not.toThrow();
expect(manager.initialized).toBe(true);
});
});
describe('renderTemplate', () => {
beforeEach(() => {
fs.readFile.mockResolvedValue('<html>Hello {{name}}, your email is {{email}}</html>');
});
it('should render template with variables', async () => {
const TemplateManager = require('../../../../services/email/core/TemplateManager');
TemplateManager.instance = null;
const manager = new TemplateManager();
await manager.initialize();
// Manually set a template for testing
manager.templates.set('testTemplate', '<html>Hello {{name}}, your email is {{email}}</html>');
const result = await manager.renderTemplate('testTemplate', {
name: 'John',
email: 'john@example.com',
});
expect(result).toBe('<html>Hello John, your email is john@example.com</html>');
});
it('should replace all occurrences of a variable', async () => {
const TemplateManager = require('../../../../services/email/core/TemplateManager');
TemplateManager.instance = null;
const manager = new TemplateManager();
await manager.initialize();
manager.templates.set('testTemplate', '<html>{{name}} {{name}} {{name}}</html>');
const result = await manager.renderTemplate('testTemplate', {
name: 'John',
});
expect(result).toBe('<html>John John John</html>');
});
it('should replace missing variables with empty string', async () => {
const TemplateManager = require('../../../../services/email/core/TemplateManager');
TemplateManager.instance = null;
const manager = new TemplateManager();
await manager.initialize();
manager.templates.set('testTemplate', '<html>Hello {{name}}, {{missing}}</html>');
const result = await manager.renderTemplate('testTemplate', {
name: 'John',
});
expect(result).toBe('<html>Hello John, {{missing}}</html>');
});
it('should use fallback template when template not found', async () => {
const TemplateManager = require('../../../../services/email/core/TemplateManager');
TemplateManager.instance = null;
const manager = new TemplateManager();
await manager.initialize();
const result = await manager.renderTemplate('nonExistentTemplate', {
title: 'Test Title',
message: 'Test Message',
});
// Should return fallback template content
expect(result).toContain('Test Title');
expect(result).toContain('Test Message');
expect(result).toContain('RentAll');
});
it('should auto-initialize if not initialized', async () => {
const TemplateManager = require('../../../../services/email/core/TemplateManager');
TemplateManager.instance = null;
const manager = new TemplateManager();
expect(manager.initialized).toBe(false);
await manager.renderTemplate('someTemplate', {});
expect(manager.initialized).toBe(true);
});
it('should handle empty variables object', async () => {
const TemplateManager = require('../../../../services/email/core/TemplateManager');
TemplateManager.instance = null;
const manager = new TemplateManager();
await manager.initialize();
manager.templates.set('testTemplate', '<html>No variables</html>');
const result = await manager.renderTemplate('testTemplate', {});
expect(result).toBe('<html>No variables</html>');
});
it('should handle null or undefined variable values', async () => {
const TemplateManager = require('../../../../services/email/core/TemplateManager');
TemplateManager.instance = null;
const manager = new TemplateManager();
await manager.initialize();
manager.templates.set('testTemplate', '<html>Hello {{name}}</html>');
const result = await manager.renderTemplate('testTemplate', {
name: null,
});
expect(result).toBe('<html>Hello </html>');
});
});
describe('getFallbackTemplate', () => {
it('should return specific fallback for known templates', async () => {
const TemplateManager = require('../../../../services/email/core/TemplateManager');
TemplateManager.instance = null;
const manager = new TemplateManager();
const fallback = manager.getFallbackTemplate('emailVerificationToUser');
expect(fallback).toContain('Verify Your Email');
expect(fallback).toContain('{{verificationUrl}}');
});
it('should return specific fallback for password reset', async () => {
const TemplateManager = require('../../../../services/email/core/TemplateManager');
TemplateManager.instance = null;
const manager = new TemplateManager();
const fallback = manager.getFallbackTemplate('passwordResetToUser');
expect(fallback).toContain('Reset Your Password');
expect(fallback).toContain('{{resetUrl}}');
});
it('should return specific fallback for rental request', async () => {
const TemplateManager = require('../../../../services/email/core/TemplateManager');
TemplateManager.instance = null;
const manager = new TemplateManager();
const fallback = manager.getFallbackTemplate('rentalRequestToOwner');
expect(fallback).toContain('New Rental Request');
expect(fallback).toContain('{{itemName}}');
});
it('should return generic fallback for unknown templates', async () => {
const TemplateManager = require('../../../../services/email/core/TemplateManager');
TemplateManager.instance = null;
const manager = new TemplateManager();
const fallback = manager.getFallbackTemplate('unknownTemplate');
expect(fallback).toContain('{{title}}');
expect(fallback).toContain('{{message}}');
expect(fallback).toContain('RentAll');
});
});
});

View File

@@ -0,0 +1,152 @@
const {
htmlToPlainText,
formatEmailDate,
formatShortDate,
formatCurrency,
} = require('../../../../services/email/core/emailUtils');
describe('Email Utils', () => {
describe('htmlToPlainText', () => {
it('should remove HTML tags', () => {
const html = '<p>Hello <strong>World</strong></p>';
const result = htmlToPlainText(html);
expect(result).toBe('Hello World');
});
it('should convert br tags to newlines', () => {
const html = 'Line 1<br>Line 2<br/>Line 3';
const result = htmlToPlainText(html);
expect(result).toBe('Line 1\nLine 2\nLine 3');
});
it('should convert p tags to double newlines', () => {
const html = '<p>Paragraph 1</p><p>Paragraph 2</p>';
const result = htmlToPlainText(html);
expect(result).toContain('Paragraph 1');
expect(result).toContain('Paragraph 2');
});
it('should convert li tags to bullet points', () => {
const html = '<ul><li>Item 1</li><li>Item 2</li></ul>';
const result = htmlToPlainText(html);
expect(result).toContain('• Item 1');
expect(result).toContain('• Item 2');
});
it('should remove style tags and their content', () => {
const html = '<style>.class { color: red; }</style><p>Content</p>';
const result = htmlToPlainText(html);
expect(result).toBe('Content');
expect(result).not.toContain('color');
});
it('should remove script tags and their content', () => {
const html = '<script>alert("test")</script><p>Content</p>';
const result = htmlToPlainText(html);
expect(result).toBe('Content');
expect(result).not.toContain('alert');
});
it('should decode HTML entities', () => {
const html = '&amp; &lt; &gt; &quot; &#39; &nbsp;';
const result = htmlToPlainText(html);
expect(result).toContain('&');
expect(result).toContain('<');
expect(result).toContain('>');
expect(result).toContain('"');
expect(result).toContain("'");
});
it('should handle empty string', () => {
expect(htmlToPlainText('')).toBe('');
});
it('should trim whitespace', () => {
const html = ' <p>Content</p> ';
const result = htmlToPlainText(html);
expect(result).toBe('Content');
});
it('should collapse multiple newlines', () => {
const html = '<p>Line 1</p>\n\n\n\n<p>Line 2</p>';
const result = htmlToPlainText(html);
expect(result).not.toMatch(/\n{4,}/);
});
});
describe('formatEmailDate', () => {
it('should format a Date object', () => {
const date = new Date('2024-03-15T14:30:00');
const result = formatEmailDate(date);
expect(result).toContain('March');
expect(result).toContain('15');
expect(result).toContain('2024');
});
it('should format a date string', () => {
const dateStr = '2024-06-20T10:00:00';
const result = formatEmailDate(dateStr);
expect(result).toContain('June');
expect(result).toContain('20');
expect(result).toContain('2024');
});
it('should include day of week', () => {
const date = new Date('2024-03-15T14:30:00'); // Friday
const result = formatEmailDate(date);
expect(result).toContain('Friday');
});
it('should include time', () => {
const date = new Date('2024-03-15T14:30:00');
const result = formatEmailDate(date);
expect(result).toMatch(/\d{1,2}:\d{2}/);
});
});
describe('formatShortDate', () => {
it('should format a Date object without time', () => {
const date = new Date('2024-03-15T14:30:00');
const result = formatShortDate(date);
expect(result).toContain('March');
expect(result).toContain('15');
expect(result).toContain('2024');
expect(result).not.toMatch(/\d{1,2}:\d{2}/); // No time
});
it('should format a date string', () => {
const dateStr = '2024-12-25T00:00:00';
const result = formatShortDate(dateStr);
expect(result).toContain('December');
expect(result).toContain('25');
expect(result).toContain('2024');
});
});
describe('formatCurrency', () => {
it('should format amount in cents to USD', () => {
const result = formatCurrency(1000);
expect(result).toBe('$10.00');
});
it('should handle decimal amounts', () => {
const result = formatCurrency(1050);
expect(result).toBe('$10.50');
});
it('should handle large amounts', () => {
const result = formatCurrency(100000);
expect(result).toBe('$1,000.00');
});
it('should handle zero', () => {
const result = formatCurrency(0);
expect(result).toBe('$0.00');
});
it('should accept currency parameter', () => {
const result = formatCurrency(1000, 'EUR');
expect(result).toContain('€');
});
});
});

View File

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

View File

@@ -1,6 +1,18 @@
// Mock dependencies BEFORE requiring modules
jest.mock('../../../models');
jest.mock('../../../services/emailService');
jest.mock('../../../models', () => ({
Rental: {
findByPk: jest.fn()
},
Item: jest.fn(),
User: {
findByPk: jest.fn()
}
}));
jest.mock('../../../services/email', () => ({
customerService: {
sendLateReturnToCustomerService: jest.fn().mockResolvedValue()
}
}));
jest.mock('../../../config/aws', () => ({
getAWSConfig: jest.fn(() => ({ region: 'us-east-1' })),
getAWSCredentials: jest.fn()
@@ -8,7 +20,7 @@ jest.mock('../../../config/aws', () => ({
const LateReturnService = require('../../../services/lateReturnService');
const { Rental, Item, User } = require('../../../models');
const emailService = require('../../../services/emailService');
const emailService = require('../../../services/email');
describe('LateReturnService', () => {
beforeEach(() => {
@@ -30,19 +42,19 @@ describe('LateReturnService', () => {
expect(result.lateHours).toBe(0);
});
it('should calculate late fee using hourly rate when available', () => {
it('should calculate late fee using daily 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 actualReturn = new Date('2023-06-01T14:00:00Z'); // 4 hours late = 1 billable day
const result = LateReturnService.calculateLateFee(rental, actualReturn);
expect(result.isLate).toBe(true);
expect(result.lateFee).toBe(40); // 4 hours * $10
expect(result.lateFee).toBe(50); // 1 billable day * $50 daily rate
expect(result.lateHours).toBe(4);
expect(result.pricingType).toBe('hourly');
expect(result.pricingType).toBe('daily');
});
it('should calculate late fee using daily rate when no hourly rate', () => {
@@ -65,13 +77,13 @@ describe('LateReturnService', () => {
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 actualReturn = new Date('2023-06-01T14:00:00Z'); // 4 hours late = 1 billable day
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');
expect(result.lateFee).toBe(10); // 1 billable day * $10 (free borrow daily rate)
expect(result.pricingType).toBe('daily');
});
});
@@ -89,39 +101,38 @@ describe('LateReturnService', () => {
};
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
const actualReturn = new Date('2023-06-01T14:00:00Z'); // 4 hours late = 1 billable day
const mockOwner = { id: 'owner-456', email: 'owner@test.com' };
const mockRenter = { id: 'renter-123', email: 'renter@test.com' };
// Mock User.findByPk for owner and renter
User.findByPk
.mockResolvedValueOnce(mockOwner) // First call for owner
.mockResolvedValueOnce(mockRenter); // Second call for renter
mockRental.update.mockResolvedValue({
...mockRental,
status: 'returned_late',
actualReturnDateTime: actualReturn
actualReturnDateTime: actualReturn,
payoutStatus: 'pending'
});
const result = await LateReturnService.processLateReturn('123', actualReturn, 'Test notes');
const result = await LateReturnService.processLateReturn('123', actualReturn);
expect(mockRental.update).toHaveBeenCalledWith({
actualReturnDateTime: actualReturn,
status: 'returned_late',
notes: 'Test notes'
payoutStatus: 'pending'
});
expect(emailService.sendLateReturnToCustomerService).toHaveBeenCalledWith(
expect.objectContaining({
status: 'returned_late'
}),
expect.objectContaining({
isLate: true,
lateFee: 40,
lateHours: 4
})
);
expect(emailService.customerService.sendLateReturnToCustomerService).toHaveBeenCalled();
expect(result.lateCalculation.isLate).toBe(true);
expect(result.lateCalculation.lateFee).toBe(40);
expect(result.lateCalculation.lateFee).toBe(240); // 1 day * $10/hr * 24 = $240
});
it('should mark as completed when returned on time', async () => {
@@ -130,17 +141,19 @@ describe('LateReturnService', () => {
mockRental.update.mockResolvedValue({
...mockRental,
status: 'completed',
actualReturnDateTime: actualReturn
actualReturnDateTime: actualReturn,
payoutStatus: 'pending'
});
const result = await LateReturnService.processLateReturn('123', actualReturn);
expect(mockRental.update).toHaveBeenCalledWith({
actualReturnDateTime: actualReturn,
status: 'completed'
status: 'completed',
payoutStatus: 'pending'
});
expect(emailService.sendLateReturnToCustomerService).not.toHaveBeenCalled();
expect(emailService.customerService.sendLateReturnToCustomerService).not.toHaveBeenCalled();
expect(result.lateCalculation.isLate).toBe(false);
});

View File

@@ -1,19 +1,15 @@
// Mock dependencies
const mockRentalFindAll = jest.fn();
const mockRentalUpdate = jest.fn();
const mockUserModel = jest.fn();
const mockCreateTransfer = jest.fn();
// Mock dependencies - define mocks inline to avoid hoisting issues
jest.mock('../../../models', () => ({
Rental: {
findAll: mockRentalFindAll,
update: mockRentalUpdate
findAll: jest.fn(),
update: jest.fn()
},
User: mockUserModel
User: jest.fn(),
Item: jest.fn()
}));
jest.mock('../../../services/stripeService', () => ({
createTransfer: mockCreateTransfer
createTransfer: jest.fn()
}));
jest.mock('sequelize', () => ({
@@ -23,6 +19,15 @@ jest.mock('sequelize', () => ({
}));
const PayoutService = require('../../../services/payoutService');
const { Rental, User, Item } = require('../../../models');
const StripeService = require('../../../services/stripeService');
// Get references to mocks after importing
const mockRentalFindAll = Rental.findAll;
const mockRentalUpdate = Rental.update;
const mockUserModel = User;
const mockItemModel = Item;
const mockCreateTransfer = StripeService.createTransfer;
describe('PayoutService', () => {
let consoleSpy, consoleErrorSpy;
@@ -84,6 +89,10 @@ describe('PayoutService', () => {
'not': null
}
}
},
{
model: mockItemModel,
as: 'item'
}
]
});
@@ -267,6 +276,11 @@ describe('PayoutService', () => {
});
it('should handle database update errors during processing', async () => {
// Stripe succeeds but database update fails
mockCreateTransfer.mockResolvedValue({
id: 'tr_123456789',
amount: 9500
});
const dbError = new Error('Database update failed');
mockRental.update.mockRejectedValueOnce(dbError);
@@ -508,6 +522,10 @@ describe('PayoutService', () => {
'not': null
}
}
},
{
model: mockItemModel,
as: 'item'
}
]
});

View File

@@ -0,0 +1,254 @@
const S3OwnershipService = require('../../../services/s3OwnershipService');
const { Message, ConditionCheck, Rental } = require('../../../models');
jest.mock('../../../models');
describe('S3OwnershipService', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('getFileTypeFromKey', () => {
it('should return "profile" for profiles folder', () => {
expect(S3OwnershipService.getFileTypeFromKey('profiles/uuid.jpg')).toBe('profile');
});
it('should return "item" for items folder', () => {
expect(S3OwnershipService.getFileTypeFromKey('items/uuid.jpg')).toBe('item');
});
it('should return "message" for messages folder', () => {
expect(S3OwnershipService.getFileTypeFromKey('messages/uuid.jpg')).toBe('message');
});
it('should return "forum" for forum folder', () => {
expect(S3OwnershipService.getFileTypeFromKey('forum/uuid.jpg')).toBe('forum');
});
it('should return "condition-check" for condition-checks folder', () => {
expect(S3OwnershipService.getFileTypeFromKey('condition-checks/uuid.jpg')).toBe('condition-check');
});
it('should return null for unknown folder', () => {
expect(S3OwnershipService.getFileTypeFromKey('unknown/uuid.jpg')).toBeNull();
});
it('should return null for null key', () => {
expect(S3OwnershipService.getFileTypeFromKey(null)).toBeNull();
});
it('should return null for undefined key', () => {
expect(S3OwnershipService.getFileTypeFromKey(undefined)).toBeNull();
});
it('should return null for empty string', () => {
expect(S3OwnershipService.getFileTypeFromKey('')).toBeNull();
});
});
describe('canAccessFile', () => {
describe('public folders', () => {
it('should authorize access to profile images for any user', async () => {
const result = await S3OwnershipService.canAccessFile('profiles/uuid.jpg', 'user-123');
expect(result).toEqual({ authorized: true });
});
it('should authorize access to item images for any user', async () => {
const result = await S3OwnershipService.canAccessFile('items/uuid.jpg', 'user-123');
expect(result).toEqual({ authorized: true });
});
it('should authorize access to forum images for any user', async () => {
const result = await S3OwnershipService.canAccessFile('forum/uuid.jpg', 'user-123');
expect(result).toEqual({ authorized: true });
});
});
describe('private folders', () => {
it('should call verifyMessageAccess for message images', async () => {
Message.findOne.mockResolvedValue({ id: 'msg-123' });
const result = await S3OwnershipService.canAccessFile('messages/uuid.jpg', 'user-123');
expect(Message.findOne).toHaveBeenCalled();
expect(result.authorized).toBe(true);
});
it('should call verifyConditionCheckAccess for condition-check images', async () => {
ConditionCheck.findOne.mockResolvedValue({ id: 'check-123' });
const result = await S3OwnershipService.canAccessFile('condition-checks/uuid.jpg', 'user-123');
expect(ConditionCheck.findOne).toHaveBeenCalled();
expect(result.authorized).toBe(true);
});
});
describe('unknown file types', () => {
it('should deny access for unknown folder', async () => {
const result = await S3OwnershipService.canAccessFile('unknown/uuid.jpg', 'user-123');
expect(result).toEqual({
authorized: false,
reason: 'Unknown file type'
});
});
});
});
describe('verifyMessageAccess', () => {
const testKey = 'messages/550e8400-e29b-41d4-a716-446655440000.jpg';
const senderId = 'sender-123';
const receiverId = 'receiver-456';
it('should authorize sender to access message image', async () => {
Message.findOne.mockResolvedValue({
id: 'msg-123',
senderId,
receiverId,
imageFilename: testKey
});
const result = await S3OwnershipService.verifyMessageAccess(testKey, senderId);
expect(result).toEqual({
authorized: true,
reason: null
});
expect(Message.findOne).toHaveBeenCalledWith({
where: expect.objectContaining({
imageFilename: testKey
})
});
});
it('should authorize receiver to access message image', async () => {
Message.findOne.mockResolvedValue({
id: 'msg-123',
senderId,
receiverId,
imageFilename: testKey
});
const result = await S3OwnershipService.verifyMessageAccess(testKey, receiverId);
expect(result.authorized).toBe(true);
});
it('should deny access to unauthorized user', async () => {
Message.findOne.mockResolvedValue(null);
const result = await S3OwnershipService.verifyMessageAccess(testKey, 'other-user');
expect(result).toEqual({
authorized: false,
reason: 'Not a participant in this message'
});
});
it('should deny access when message does not exist', async () => {
Message.findOne.mockResolvedValue(null);
const result = await S3OwnershipService.verifyMessageAccess('messages/nonexistent.jpg', senderId);
expect(result.authorized).toBe(false);
expect(result.reason).toBe('Not a participant in this message');
});
});
describe('verifyConditionCheckAccess', () => {
const testKey = 'condition-checks/550e8400-e29b-41d4-a716-446655440000.jpg';
const ownerId = 'owner-123';
const renterId = 'renter-456';
it('should authorize owner to access condition check image', async () => {
ConditionCheck.findOne.mockResolvedValue({
id: 'check-123',
imageFilenames: [testKey],
rental: {
id: 'rental-123',
ownerId,
renterId
}
});
const result = await S3OwnershipService.verifyConditionCheckAccess(testKey, ownerId);
expect(result).toEqual({
authorized: true,
reason: null
});
expect(ConditionCheck.findOne).toHaveBeenCalledWith({
where: expect.objectContaining({
imageFilenames: expect.anything()
}),
include: expect.arrayContaining([
expect.objectContaining({
model: Rental,
as: 'rental'
})
])
});
});
it('should authorize renter to access condition check image', async () => {
ConditionCheck.findOne.mockResolvedValue({
id: 'check-123',
imageFilenames: [testKey],
rental: {
id: 'rental-123',
ownerId,
renterId
}
});
const result = await S3OwnershipService.verifyConditionCheckAccess(testKey, renterId);
expect(result.authorized).toBe(true);
});
it('should deny access to unauthorized user', async () => {
ConditionCheck.findOne.mockResolvedValue(null);
const result = await S3OwnershipService.verifyConditionCheckAccess(testKey, 'other-user');
expect(result).toEqual({
authorized: false,
reason: 'Not a participant in this rental'
});
});
it('should deny access when condition check does not exist', async () => {
ConditionCheck.findOne.mockResolvedValue(null);
const result = await S3OwnershipService.verifyConditionCheckAccess(
'condition-checks/nonexistent.jpg',
ownerId
);
expect(result.authorized).toBe(false);
expect(result.reason).toBe('Not a participant in this rental');
});
it('should use Op.contains for imageFilenames array search', async () => {
const { Op } = require('sequelize');
ConditionCheck.findOne.mockResolvedValue(null);
await S3OwnershipService.verifyConditionCheckAccess(testKey, ownerId);
expect(ConditionCheck.findOne).toHaveBeenCalledWith(
expect.objectContaining({
where: {
imageFilenames: expect.anything()
}
})
);
});
});
});

View File

@@ -0,0 +1,380 @@
/**
* S3Service Unit Tests
*
* Tests the S3 service methods including presigned URL generation,
* upload verification, and file extension mapping.
*/
// Store mock implementations for tests to control
const mockGetSignedUrl = jest.fn();
const mockSend = jest.fn();
// Mock AWS SDK before anything else
jest.mock('@aws-sdk/client-s3', () => ({
S3Client: jest.fn().mockImplementation(() => ({
send: mockSend
})),
PutObjectCommand: jest.fn().mockImplementation((params) => params),
GetObjectCommand: jest.fn().mockImplementation((params) => params),
HeadObjectCommand: jest.fn().mockImplementation((params) => params)
}));
jest.mock('@aws-sdk/s3-request-presigner', () => ({
getSignedUrl: (...args) => mockGetSignedUrl(...args)
}));
jest.mock('../../../config/aws', () => ({
getAWSConfig: jest.fn(() => ({ region: 'us-east-1' }))
}));
jest.mock('uuid', () => ({
v4: jest.fn(() => '550e8400-e29b-41d4-a716-446655440000')
}));
jest.mock('../../../utils/logger', () => ({
info: jest.fn(),
error: jest.fn(),
warn: jest.fn()
}));
describe('S3Service', () => {
let s3Service;
beforeEach(() => {
// Clear all mocks
jest.clearAllMocks();
// Reset module cache to get fresh instance
jest.resetModules();
// Set up environment
process.env.S3_ENABLED = 'true';
process.env.S3_BUCKET = 'test-bucket';
// Default mock implementations
mockGetSignedUrl.mockResolvedValue('https://presigned-url.example.com');
mockSend.mockResolvedValue({});
// Load fresh module
s3Service = require('../../../services/s3Service');
s3Service.initialize();
});
afterEach(() => {
delete process.env.S3_ENABLED;
delete process.env.S3_BUCKET;
});
describe('initialize', () => {
beforeEach(() => {
jest.resetModules();
});
it('should disable S3 when S3_ENABLED is not true', () => {
process.env.S3_ENABLED = 'false';
const freshService = require('../../../services/s3Service');
freshService.initialize();
expect(freshService.isEnabled()).toBe(false);
});
it('should initialize successfully with valid config', () => {
process.env.S3_ENABLED = 'true';
process.env.S3_BUCKET = 'test-bucket';
jest.resetModules();
const freshService = require('../../../services/s3Service');
freshService.initialize();
expect(freshService.isEnabled()).toBe(true);
});
});
describe('isEnabled', () => {
it('should return true when S3 is enabled', () => {
expect(s3Service.isEnabled()).toBe(true);
});
it('should return false when S3 is disabled', () => {
jest.resetModules();
process.env.S3_ENABLED = 'false';
const freshService = require('../../../services/s3Service');
freshService.initialize();
expect(freshService.isEnabled()).toBe(false);
});
});
describe('getPresignedUploadUrl', () => {
it('should generate presigned URL for valid profile upload', async () => {
const result = await s3Service.getPresignedUploadUrl(
'profile',
'image/jpeg',
'photo.jpg',
1024 * 1024 // 1MB
);
expect(result.uploadUrl).toBe('https://presigned-url.example.com');
expect(result.key).toBe('profiles/550e8400-e29b-41d4-a716-446655440000.jpg');
expect(result.publicUrl).toBe('https://test-bucket.s3.us-east-1.amazonaws.com/profiles/550e8400-e29b-41d4-a716-446655440000.jpg');
expect(result.expiresAt).toBeInstanceOf(Date);
});
it('should generate presigned URL for item upload', async () => {
const result = await s3Service.getPresignedUploadUrl(
'item',
'image/png',
'item-photo.png',
5 * 1024 * 1024 // 5MB
);
expect(result.key).toBe('items/550e8400-e29b-41d4-a716-446655440000.png');
expect(result.publicUrl).toContain('items/');
});
it('should generate presigned URL for message (private) upload with null publicUrl', async () => {
const result = await s3Service.getPresignedUploadUrl(
'message',
'image/jpeg',
'message.jpg',
1024 * 1024
);
expect(result.key).toBe('messages/550e8400-e29b-41d4-a716-446655440000.jpg');
expect(result.publicUrl).toBeNull(); // Private uploads don't get public URLs
});
it('should generate presigned URL for condition-check (private) upload', async () => {
const result = await s3Service.getPresignedUploadUrl(
'condition-check',
'image/jpeg',
'check.jpg',
2 * 1024 * 1024
);
expect(result.key).toBe('condition-checks/550e8400-e29b-41d4-a716-446655440000.jpg');
expect(result.publicUrl).toBeNull();
});
it('should generate presigned URL for forum upload', async () => {
const result = await s3Service.getPresignedUploadUrl(
'forum',
'image/gif',
'post.gif',
3 * 1024 * 1024
);
expect(result.key).toBe('forum/550e8400-e29b-41d4-a716-446655440000.gif');
expect(result.publicUrl).toContain('forum/');
});
it('should throw error for invalid upload type', async () => {
await expect(
s3Service.getPresignedUploadUrl('invalid', 'image/jpeg', 'photo.jpg', 1024)
).rejects.toThrow('Invalid upload type: invalid');
});
it('should throw error for invalid content type', async () => {
await expect(
s3Service.getPresignedUploadUrl('profile', 'application/pdf', 'doc.pdf', 1024)
).rejects.toThrow('Invalid content type: application/pdf');
});
it('should accept all valid MIME types', async () => {
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
for (const contentType of validTypes) {
const result = await s3Service.getPresignedUploadUrl('profile', contentType, 'photo.jpg', 1024);
expect(result.uploadUrl).toBeDefined();
}
});
it('should throw error for missing file size', async () => {
await expect(
s3Service.getPresignedUploadUrl('profile', 'image/jpeg', 'photo.jpg', 0)
).rejects.toThrow('File size is required');
await expect(
s3Service.getPresignedUploadUrl('profile', 'image/jpeg', 'photo.jpg', null)
).rejects.toThrow('File size is required');
});
it('should throw error when file exceeds profile max size (5MB)', async () => {
await expect(
s3Service.getPresignedUploadUrl('profile', 'image/jpeg', 'photo.jpg', 6 * 1024 * 1024)
).rejects.toThrow('File too large. Maximum size is 5MB');
});
it('should throw error when file exceeds item max size (10MB)', async () => {
await expect(
s3Service.getPresignedUploadUrl('item', 'image/jpeg', 'photo.jpg', 11 * 1024 * 1024)
).rejects.toThrow('File too large. Maximum size is 10MB');
});
it('should throw error when file exceeds message max size (5MB)', async () => {
await expect(
s3Service.getPresignedUploadUrl('message', 'image/jpeg', 'photo.jpg', 6 * 1024 * 1024)
).rejects.toThrow('File too large. Maximum size is 5MB');
});
it('should accept files at exactly max size', async () => {
const result = await s3Service.getPresignedUploadUrl('profile', 'image/jpeg', 'photo.jpg', 5 * 1024 * 1024);
expect(result.uploadUrl).toBeDefined();
});
it('should throw error when S3 is disabled', async () => {
jest.resetModules();
process.env.S3_ENABLED = 'false';
const disabledService = require('../../../services/s3Service');
disabledService.initialize();
await expect(
disabledService.getPresignedUploadUrl('profile', 'image/jpeg', 'photo.jpg', 1024)
).rejects.toThrow('S3 storage is not enabled');
});
it('should use extension from filename when provided', async () => {
const result = await s3Service.getPresignedUploadUrl(
'profile',
'image/jpeg',
'photo.png',
1024
);
expect(result.key).toContain('.png');
});
it('should fall back to MIME type extension when filename has none', async () => {
const result = await s3Service.getPresignedUploadUrl(
'profile',
'image/png',
'photo',
1024
);
expect(result.key).toContain('.png');
});
});
describe('getPresignedDownloadUrl', () => {
it('should generate download URL with default expiration', async () => {
const result = await s3Service.getPresignedDownloadUrl('messages/test.jpg');
expect(result).toBe('https://presigned-url.example.com');
expect(mockGetSignedUrl).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ Bucket: 'test-bucket', Key: 'messages/test.jpg' }),
{ expiresIn: 3600 }
);
});
it('should generate download URL with custom expiration', async () => {
await s3Service.getPresignedDownloadUrl('messages/test.jpg', 7200);
expect(mockGetSignedUrl).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
{ expiresIn: 7200 }
);
});
it('should throw error when S3 is disabled', async () => {
jest.resetModules();
process.env.S3_ENABLED = 'false';
const disabledService = require('../../../services/s3Service');
disabledService.initialize();
await expect(
disabledService.getPresignedDownloadUrl('messages/test.jpg')
).rejects.toThrow('S3 storage is not enabled');
});
});
describe('getPublicUrl', () => {
it('should return correct public URL format', () => {
const url = s3Service.getPublicUrl('items/test-uuid.jpg');
expect(url).toBe('https://test-bucket.s3.us-east-1.amazonaws.com/items/test-uuid.jpg');
});
it('should return null when S3 is disabled', () => {
jest.resetModules();
process.env.S3_ENABLED = 'false';
const disabledService = require('../../../services/s3Service');
disabledService.initialize();
expect(disabledService.getPublicUrl('items/test.jpg')).toBeNull();
});
});
describe('verifyUpload', () => {
it('should return true when file exists', async () => {
mockSend.mockResolvedValue({});
const result = await s3Service.verifyUpload('items/test.jpg');
expect(result).toBe(true);
});
it('should return false when file does not exist (NotFound)', async () => {
mockSend.mockRejectedValue({ name: 'NotFound' });
const result = await s3Service.verifyUpload('items/nonexistent.jpg');
expect(result).toBe(false);
});
it('should return false when file does not exist (404 status)', async () => {
mockSend.mockRejectedValue({ $metadata: { httpStatusCode: 404 } });
const result = await s3Service.verifyUpload('items/nonexistent.jpg');
expect(result).toBe(false);
});
it('should throw error for other S3 errors', async () => {
const s3Error = new Error('Access Denied');
s3Error.name = 'AccessDenied';
mockSend.mockRejectedValue(s3Error);
await expect(s3Service.verifyUpload('items/test.jpg')).rejects.toThrow('Access Denied');
});
it('should return false when S3 is disabled', async () => {
jest.resetModules();
process.env.S3_ENABLED = 'false';
const disabledService = require('../../../services/s3Service');
disabledService.initialize();
const result = await disabledService.verifyUpload('items/test.jpg');
expect(result).toBe(false);
});
});
describe('getExtFromMime', () => {
it('should return correct extension for image/jpeg', () => {
expect(s3Service.getExtFromMime('image/jpeg')).toBe('.jpg');
});
it('should return correct extension for image/jpg', () => {
expect(s3Service.getExtFromMime('image/jpg')).toBe('.jpg');
});
it('should return correct extension for image/png', () => {
expect(s3Service.getExtFromMime('image/png')).toBe('.png');
});
it('should return correct extension for image/gif', () => {
expect(s3Service.getExtFromMime('image/gif')).toBe('.gif');
});
it('should return correct extension for image/webp', () => {
expect(s3Service.getExtFromMime('image/webp')).toBe('.webp');
});
it('should return .jpg as default for unknown MIME types', () => {
expect(s3Service.getExtFromMime('image/unknown')).toBe('.jpg');
});
});
});