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