381 lines
12 KiB
JavaScript
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');
|
|
});
|
|
});
|
|
});
|