Files
rentall-app/backend/tests/unit/services/s3Service.test.js
jackiettran 3f319bfdd0 unit tests
2025-12-12 16:27:56 -05:00

381 lines
12 KiB
JavaScript

/**
* 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');
});
});
});