/** * Integration Tests: Storage Service (S3/R2) * * Epic: OQI-002 - Módulo Educativo * Service: storage.service.ts * * Tests validate: * - Multipart upload flow * - Presigned URL generation * - Object operations (upload, delete, copy, metadata) * - S3/R2 API integration * - Error handling */ import { StorageService, type StorageConfig } from '../../shared/services/storage.service'; import { S3Client } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; // Mock AWS SDK jest.mock('@aws-sdk/client-s3'); jest.mock('@aws-sdk/s3-request-presigner'); describe('Integration: Storage Service', () => { let service: StorageService; let mockS3Client: jest.Mocked; const testConfig: StorageConfig = { provider: 's3', bucket: 'test-bucket', region: 'us-east-1', accessKeyId: 'test-key', secretAccessKey: 'test-secret', cdnUrl: 'https://cdn.example.com', }; beforeEach(() => { // Create mock S3 client mockS3Client = { send: jest.fn(), } as any; // Mock S3Client constructor (S3Client as jest.Mock).mockImplementation(() => mockS3Client); service = new StorageService(testConfig); }); afterEach(() => { jest.clearAllMocks(); }); // ========================================================================== // Multipart Upload Flow // ========================================================================== describe('Multipart Upload', () => { it('should initialize multipart upload', async () => { mockS3Client.send.mockResolvedValue({ UploadId: 'upload-123', }); const result = await service.initMultipartUpload( 'videos/test.mp4', 'video/mp4', { title: 'Test Video', userId: 'user-123' } ); expect(result).toEqual({ uploadId: 'upload-123', key: 'videos/test.mp4', }); expect(mockS3Client.send).toHaveBeenCalledWith( expect.objectContaining({ input: expect.objectContaining({ Bucket: 'test-bucket', Key: 'videos/test.mp4', ContentType: 'video/mp4', Metadata: { title: 'Test Video', userId: 'user-123' }, }), }) ); }); it('should throw error if uploadId is missing', async () => { mockS3Client.send.mockResolvedValue({ UploadId: undefined, // Missing }); await expect(service.initMultipartUpload('videos/test.mp4')).rejects.toThrow( 'Failed to get uploadId from multipart upload init' ); }); it('should upload a part', async () => { const partData = Buffer.from('video chunk data'); mockS3Client.send.mockResolvedValue({ ETag: '"etag-abc123"', }); const result = await service.uploadPart('videos/test.mp4', 'upload-123', 1, partData); expect(result).toEqual({ etag: '"etag-abc123"', partNumber: 1, }); expect(mockS3Client.send).toHaveBeenCalledWith( expect.objectContaining({ input: expect.objectContaining({ Bucket: 'test-bucket', Key: 'videos/test.mp4', UploadId: 'upload-123', PartNumber: 1, Body: partData, }), }) ); }); it('should throw error if ETag is missing from part upload', async () => { mockS3Client.send.mockResolvedValue({ ETag: undefined, }); await expect( service.uploadPart('videos/test.mp4', 'upload-123', 1, Buffer.from('data')) ).rejects.toThrow('Failed to get ETag from part upload'); }); it('should complete multipart upload', async () => { mockS3Client.send.mockResolvedValue({}); const parts = [ { PartNumber: 1, ETag: '"etag-1"' }, { PartNumber: 2, ETag: '"etag-2"' }, ]; const result = await service.completeMultipartUpload('videos/test.mp4', 'upload-123', parts); expect(result).toEqual({ key: 'videos/test.mp4', url: 'https://cdn.example.com/videos/test.mp4', }); expect(mockS3Client.send).toHaveBeenCalledWith( expect.objectContaining({ input: expect.objectContaining({ Bucket: 'test-bucket', Key: 'videos/test.mp4', UploadId: 'upload-123', MultipartUpload: { Parts: parts, }, }), }) ); }); it('should abort multipart upload', async () => { mockS3Client.send.mockResolvedValue({}); await service.abortMultipartUpload('videos/test.mp4', 'upload-123'); expect(mockS3Client.send).toHaveBeenCalledWith( expect.objectContaining({ input: expect.objectContaining({ Bucket: 'test-bucket', Key: 'videos/test.mp4', UploadId: 'upload-123', }), }) ); }); it('should handle S3 errors in multipart operations', async () => { mockS3Client.send.mockRejectedValue(new Error('S3 Access Denied')); await expect(service.initMultipartUpload('videos/test.mp4')).rejects.toThrow( 'Multipart upload init failed' ); }); }); // ========================================================================== // Presigned URLs // ========================================================================== describe('Presigned URLs', () => { it('should generate presigned upload URL', async () => { (getSignedUrl as jest.Mock).mockResolvedValue('https://s3.example.com/presigned-upload?signature=abc'); const result = await service.getPresignedUploadUrl({ key: 'videos/test.mp4', expiresIn: 3600, contentType: 'video/mp4', }); expect(result).toBe('https://s3.example.com/presigned-upload?signature=abc'); expect(getSignedUrl).toHaveBeenCalledWith( mockS3Client, expect.anything(), { expiresIn: 3600 } ); }); it('should generate presigned download URL', async () => { (getSignedUrl as jest.Mock).mockResolvedValue('https://s3.example.com/presigned-download?signature=xyz'); const result = await service.getPresignedDownloadUrl('videos/test.mp4', 1800); expect(result).toBe('https://s3.example.com/presigned-download?signature=xyz'); expect(getSignedUrl).toHaveBeenCalledWith( mockS3Client, expect.anything(), { expiresIn: 1800 } ); }); it('should use default expiration (3600s) for presigned URLs', async () => { (getSignedUrl as jest.Mock).mockResolvedValue('https://s3.example.com/presigned'); await service.getPresignedUploadUrl({ key: 'videos/test.mp4' }); expect(getSignedUrl).toHaveBeenCalledWith( mockS3Client, expect.anything(), { expiresIn: 3600 } ); }); it('should handle presigned URL generation errors', async () => { (getSignedUrl as jest.Mock).mockRejectedValue(new Error('Invalid credentials')); await expect(service.getPresignedUploadUrl({ key: 'videos/test.mp4' })).rejects.toThrow( 'Presigned URL generation failed' ); }); }); // ========================================================================== // Simple Upload // ========================================================================== describe('Simple Upload', () => { it('should upload object with buffer', async () => { mockS3Client.send.mockResolvedValue({}); const buffer = Buffer.from('file content'); const result = await service.upload({ key: 'documents/test.pdf', body: buffer, contentType: 'application/pdf', metadata: { userId: 'user-123' }, acl: 'private', }); expect(result).toEqual({ key: 'documents/test.pdf', url: 'https://cdn.example.com/documents/test.pdf', }); expect(mockS3Client.send).toHaveBeenCalledWith( expect.objectContaining({ input: expect.objectContaining({ Bucket: 'test-bucket', Key: 'documents/test.pdf', Body: buffer, ContentType: 'application/pdf', Metadata: { userId: 'user-123' }, ACL: 'private', }), }) ); }); it('should default to private ACL', async () => { mockS3Client.send.mockResolvedValue({}); await service.upload({ key: 'test.txt', body: 'content', }); expect(mockS3Client.send).toHaveBeenCalledWith( expect.objectContaining({ input: expect.objectContaining({ ACL: 'private', }), }) ); }); it('should handle upload errors', async () => { mockS3Client.send.mockRejectedValue(new Error('Quota exceeded')); await expect( service.upload({ key: 'test.txt', body: 'content' }) ).rejects.toThrow('Storage upload failed'); }); }); // ========================================================================== // Object Operations // ========================================================================== describe('Object Operations', () => { it('should get object as buffer', async () => { const mockStream = { [Symbol.asyncIterator]: async function* () { yield Buffer.from('chunk1'); yield Buffer.from('chunk2'); }, }; mockS3Client.send.mockResolvedValue({ Body: mockStream, }); const result = await service.getObject('documents/test.pdf'); expect(result).toBeInstanceOf(Buffer); expect(result.toString()).toBe('chunk1chunk2'); expect(mockS3Client.send).toHaveBeenCalledWith( expect.objectContaining({ input: expect.objectContaining({ Bucket: 'test-bucket', Key: 'documents/test.pdf', }), }) ); }); it('should throw error if object body is empty', async () => { mockS3Client.send.mockResolvedValue({ Body: undefined, }); await expect(service.getObject('test.txt')).rejects.toThrow('Empty response body'); }); it('should delete object', async () => { mockS3Client.send.mockResolvedValue({}); await service.deleteObject('documents/old.pdf'); expect(mockS3Client.send).toHaveBeenCalledWith( expect.objectContaining({ input: expect.objectContaining({ Bucket: 'test-bucket', Key: 'documents/old.pdf', }), }) ); }); it('should copy object', async () => { mockS3Client.send.mockResolvedValue({}); await service.copyObject('source/file.txt', 'destination/file.txt'); expect(mockS3Client.send).toHaveBeenCalledWith( expect.objectContaining({ input: expect.objectContaining({ Bucket: 'test-bucket', CopySource: 'test-bucket/source/file.txt', Key: 'destination/file.txt', }), }) ); }); it('should get object metadata', async () => { mockS3Client.send.mockResolvedValue({ ContentLength: 1024, LastModified: new Date('2026-01-27T10:00:00Z'), ContentType: 'application/pdf', ETag: '"abc123"', Metadata: { userId: 'user-123' }, }); const result = await service.getObjectMetadata('documents/test.pdf'); expect(result).toEqual({ key: 'documents/test.pdf', size: 1024, lastModified: new Date('2026-01-27T10:00:00Z'), contentType: 'application/pdf', etag: '"abc123"', metadata: { userId: 'user-123' }, }); }); it('should list objects with prefix', async () => { mockS3Client.send.mockResolvedValue({ Contents: [ { Key: 'videos/test1.mp4', Size: 1024000, LastModified: new Date('2026-01-27T10:00:00Z'), ETag: '"etag1"', }, { Key: 'videos/test2.mp4', Size: 2048000, LastModified: new Date('2026-01-27T11:00:00Z'), ETag: '"etag2"', }, ], }); const result = await service.listObjects('videos/', 100); expect(result).toEqual([ { key: 'videos/test1.mp4', size: 1024000, lastModified: new Date('2026-01-27T10:00:00Z'), etag: '"etag1"', }, { key: 'videos/test2.mp4', size: 2048000, lastModified: new Date('2026-01-27T11:00:00Z'), etag: '"etag2"', }, ]); expect(mockS3Client.send).toHaveBeenCalledWith( expect.objectContaining({ input: expect.objectContaining({ Bucket: 'test-bucket', Prefix: 'videos/', MaxKeys: 100, }), }) ); }); it('should return empty array if no objects found', async () => { mockS3Client.send.mockResolvedValue({ Contents: undefined, }); const result = await service.listObjects('empty/'); expect(result).toEqual([]); }); it('should use default maxKeys (1000) for listing', async () => { mockS3Client.send.mockResolvedValue({ Contents: [] }); await service.listObjects('prefix/'); expect(mockS3Client.send).toHaveBeenCalledWith( expect.objectContaining({ input: expect.objectContaining({ MaxKeys: 1000, }), }) ); }); }); // ========================================================================== // URL Generation // ========================================================================== describe('URL Generation', () => { it('should generate CDN URL if configured', () => { const url = service.getPublicUrl('videos/test.mp4'); expect(url).toBe('https://cdn.example.com/videos/test.mp4'); }); it('should generate R2 URL for Cloudflare R2', () => { const r2Service = new StorageService({ provider: 'r2', bucket: 'test-bucket', endpoint: 'https://r2.example.com', accessKeyId: 'key', secretAccessKey: 'secret', }); const url = r2Service.getPublicUrl('videos/test.mp4'); expect(url).toBe('https://r2.example.com/test-bucket/videos/test.mp4'); }); it('should generate S3 URL for AWS S3', () => { const s3Service = new StorageService({ provider: 's3', bucket: 'my-bucket', region: 'us-west-2', accessKeyId: 'key', secretAccessKey: 'secret', }); const url = s3Service.getPublicUrl('documents/file.pdf'); expect(url).toBe('https://my-bucket.s3.us-west-2.amazonaws.com/documents/file.pdf'); }); it('should prefer CDN URL over S3/R2 URL', () => { const serviceWithCdn = new StorageService({ provider: 'r2', bucket: 'test-bucket', region: 'auto', endpoint: 'https://r2.example.com', accessKeyId: 'key', secretAccessKey: 'secret', cdnUrl: 'https://cdn.example.com', }); const url = serviceWithCdn.getPublicUrl('test.mp4'); expect(url).toBe('https://cdn.example.com/test.mp4'); }); }); // ========================================================================== // Helper Methods // ========================================================================== describe('Helper Methods', () => { it('should generate unique storage key', () => { const key1 = service.generateKey('videos', 'test.mp4'); const key2 = service.generateKey('videos', 'test.mp4'); // Should start with prefix expect(key1).toMatch(/^videos\//); expect(key2).toMatch(/^videos\//); // Should include timestamp expect(key1).toMatch(/videos\/\d+-[a-z0-9]+-test\.mp4/); // Should be unique (different random component) expect(key1).not.toBe(key2); }); it('should sanitize filename in generated key', () => { const key = service.generateKey('documents', 'my file (copy) #1.pdf'); // Special characters should be replaced with underscores expect(key).toMatch(/^documents\/\d+-[a-z0-9]+-my_file__copy___1\.pdf$/); }); it('should preserve file extension', () => { const key = service.generateKey('images', 'photo.jpg'); expect(key).toMatch(/\.jpg$/); }); }); // ========================================================================== // Error Handling // ========================================================================== describe('Error Handling', () => { it('should wrap S3 errors with context', async () => { mockS3Client.send.mockRejectedValue(new Error('AccessDenied: Insufficient permissions')); await expect( service.upload({ key: 'test.txt', body: 'content' }) ).rejects.toThrow('Storage upload failed: AccessDenied: Insufficient permissions'); }); it('should handle network errors', async () => { mockS3Client.send.mockRejectedValue(new Error('ECONNREFUSED')); await expect(service.deleteObject('test.txt')).rejects.toThrow('Delete object failed'); }); it('should handle unknown errors', async () => { mockS3Client.send.mockRejectedValue('Unknown error'); await expect(service.getObject('test.txt')).rejects.toThrow('Get object failed'); }); }); });