import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { MinioService } from '@/modules/files/minio.service'; import { Client } from 'minio'; import { logger } from '@/utils/logger'; // Mock dependencies vi.mock('minio'); vi.mock('@/utils/logger'); // Mock process.env using vi.stubEnv for proper isolation beforeEach(() => { vi.stubEnv('MINIO_HOST', 'localhost'); vi.stubEnv('MINIO_PORT', '9000'); vi.stubEnv('MINIO_USE_SSL', 'false'); vi.stubEnv('MINIO_ACCESS_KEY', 'minioadmin'); vi.stubEnv('MINIO_SECRET_KEY', 'minioadmin'); vi.stubEnv('MINIO_BUCKET_NAME', 'test-bucket'); }); afterEach(() => { vi.unstubAllEnvs(); }); describe('MinioService', () => { let minioService: MinioService; let mockClient: Client; beforeEach(() => { mockClient = new Client({} as any); (Client as any).mockClear(); (Client as any).mockImplementation(() => mockClient); minioService = new MinioService(); }); afterEach(() => { vi.clearAllMocks(); }); describe('constructor', () => { it('should initialize with correct configuration', () => { expect(Client).toHaveBeenCalledWith({ endPoint: 'localhost', port: 9000, useSSL: false, accessKey: 'minioadmin', secretKey: 'minioadmin' }); expect(minioService.bucketName).toBe('test-bucket'); }); }); describe('setPublicReadPolicy', () => { it('should set public read policy successfully', async () => { const mockPolicy = JSON.stringify({ Version: '2012-10-17', Statement: [ { Effect: 'Allow', Principal: { AWS: '*' }, Action: ['s3:GetObject'], Resource: ['arn:aws:s3:::test-bucket/*'] }, { Effect: 'Allow', Principal: { AWS: '*' }, Action: ['s3:ListBucket'], Resource: ['arn:aws:s3:::test-bucket'] } ] }); vi.mocked(mockClient.setBucketPolicy).mockResolvedValue(undefined); await minioService.setPublicReadPolicy(); expect(mockClient.setBucketPolicy).toHaveBeenCalledWith('test-bucket', mockPolicy); expect(logger.db).toHaveBeenCalledWith('Bucket policy set to public read for: test-bucket'); }); it('should handle errors when setting policy', async () => { const error = new Error('Policy error'); vi.mocked(mockClient.setBucketPolicy).mockRejectedValue(error); await expect(minioService.setPublicReadPolicy()).rejects.toThrow(error); expect(logger.error).toHaveBeenCalledWith('Failed to set bucket policy for test-bucket:', error); }); }); describe('ensureBucketExists', () => { it('should create bucket if not exists', async () => { vi.mocked(mockClient.bucketExists).mockResolvedValue(false); vi.mocked(mockClient.makeBucket).mockResolvedValue(undefined); vi.spyOn(minioService, 'setPublicReadPolicy').mockResolvedValue(undefined); const result = await minioService.ensureBucketExists(); expect(mockClient.bucketExists).toHaveBeenCalledWith('test-bucket'); expect(mockClient.makeBucket).toHaveBeenCalledWith('test-bucket'); expect(minioService.setPublicReadPolicy).toHaveBeenCalledWith('test-bucket'); expect(result).toBe(true); expect(logger.db).toHaveBeenCalledWith('Created new bucket: test-bucket'); }); it('should return true if bucket already exists', async () => { vi.mocked(mockClient.bucketExists).mockResolvedValue(true); const result = await minioService.ensureBucketExists(); expect(mockClient.bucketExists).toHaveBeenCalledWith('test-bucket'); expect(mockClient.makeBucket).not.toHaveBeenCalled(); expect(result).toBe(true); }); it('should handle errors during bucket check', async () => { const error = new Error('Bucket check failed'); vi.mocked(mockClient.bucketExists).mockRejectedValue(error); await expect(minioService.ensureBucketExists()).rejects.toThrow(error); expect(logger.error).toHaveBeenCalledWith('Failed to ensure bucket exists: test-bucket', error); }); }); describe('generateUploadPolicy', () => { it('should generate upload policy successfully', async () => { const fileKey = 'test-file.txt'; const mockPolicy = { setBucket: vi.fn(), setKey: vi.fn(), setExpires: vi.fn() }; const mockFormData = { 'x-amz-algorithm': 'AWS4-HMAC-SHA256', 'x-amz-credential': 'credential', 'x-amz-date': '20250101T120000Z', policy: 'policy-string', 'x-amz-signature': 'signature' }; vi.spyOn(minioService, 'ensureBucketExists').mockResolvedValue(true); vi.mocked(mockClient.newPostPolicy).mockReturnValue(mockPolicy as any); vi.mocked(mockClient.presignedPostPolicy).mockResolvedValue({ postURL: 'https://minio.example.com', formData: mockFormData }); const result = await minioService.generateUploadPolicy(fileKey); expect(minioService.ensureBucketExists).toHaveBeenCalled(); expect(mockClient.newPostPolicy).toHaveBeenCalled(); expect(mockPolicy.setBucket).toHaveBeenCalledWith('test-bucket'); expect(mockPolicy.setKey).toHaveBeenCalledWith(fileKey); expect(mockPolicy.setExpires).toHaveBeenCalledWith(expect.any(Date)); expect(mockClient.presignedPostPolicy).toHaveBeenCalledWith(mockPolicy); expect(result).toEqual({ 'x-amz-algorithm': 'AWS4-HMAC-SHA256', 'x-amz-credential': 'credential', 'x-amz-date': '20250101T120000Z', 'x-amz-security-token': undefined, policy: 'policy-string', 'x-amz-signature': 'signature', host: 'https://minio.example.com', key: fileKey, bucket: 'test-bucket' }); }); }); describe('getFileUrl', () => { it('should generate correct file URL without SSL', () => { const url = minioService.getFileUrl('test-bucket', 'file.txt'); expect(url).toBe('http://localhost:9000/test-bucket/file.txt'); }); it('should generate correct file URL with SSL', async () => { // Create new instance with SSL by temporarily overriding env vars vi.stubEnv('MINIO_USE_SSL', 'true'); vi.stubEnv('MINIO_PORT', '443'); const sslService = new MinioService(); const url = sslService.getFileUrl('test-bucket', 'file.txt'); expect(url).toBe('https://localhost:443/test-bucket/file.txt'); }); }); describe('getPresignedFileUrl', () => { it('should generate presigned URL successfully', async () => { const mockUrl = 'https://minio.example.com/presigned-url'; vi.mocked(mockClient.presignedGetObject).mockResolvedValue(mockUrl); const result = await minioService.getPresignedFileUrl('test-bucket', 'file.txt', 3600); expect(mockClient.presignedGetObject).toHaveBeenCalledWith('test-bucket', 'file.txt', 3600); expect(result).toBe(mockUrl); expect(logger.db).toHaveBeenCalledWith( 'Generated presigned URL for test-bucket/file.txt, expires in 3600s' ); }); it('should handle errors during URL generation', async () => { const error = new Error('URL generation failed'); vi.mocked(mockClient.presignedGetObject).mockRejectedValue(error); await expect(minioService.getPresignedFileUrl('test-bucket', 'file.txt')).rejects.toThrow(error); expect(logger.error).toHaveBeenCalledWith( 'Failed to generate presigned URL for test-bucket/file.txt:', error ); }); }); describe('getPresignedFileDownloadUrl', () => { it('should generate download URL with content disposition', async () => { const mockUrl = 'https://minio.example.com/download-url'; vi.mocked(mockClient.presignedGetObject).mockResolvedValue(mockUrl); const result = await minioService.getPresignedFileDownloadUrl( 'test-bucket', 'file.txt', '测试文件.txt', 1800 ); expect(mockClient.presignedGetObject).toHaveBeenCalledWith( 'test-bucket', 'file.txt', 1800, { 'response-content-disposition': 'attachment; filename="%E6%B5%8B%E8%AF%95%E6%96%87%E4%BB%B6.txt"', 'response-content-type': 'application/octet-stream' } ); expect(result).toBe(mockUrl); expect(logger.db).toHaveBeenCalledWith( 'Generated presigned download URL for test-bucket/file.txt, filename: 测试文件.txt' ); }); }); describe('createMultipartUpload', () => { it('should create multipart upload successfully', async () => { const mockUploadId = 'upload-123'; vi.mocked(mockClient.initiateNewMultipartUpload).mockResolvedValue(mockUploadId); const result = await minioService.createMultipartUpload('test-bucket', 'large-file.zip'); expect(mockClient.initiateNewMultipartUpload).toHaveBeenCalledWith( 'test-bucket', 'large-file.zip', {} ); expect(result).toBe(mockUploadId); expect(logger.db).toHaveBeenCalledWith( 'Created multipart upload for large-file.zip with ID: upload-123' ); }); it('should handle errors during multipart upload creation', async () => { const error = new Error('Upload creation failed'); vi.mocked(mockClient.initiateNewMultipartUpload).mockRejectedValue(error); await expect(minioService.createMultipartUpload('test-bucket', 'file.zip')).rejects.toThrow(error); expect(logger.error).toHaveBeenCalledWith( 'Failed to create multipart upload for file.zip:', error ); }); }); describe('generateMultipartUploadUrls', () => { it('should generate multipart upload URLs', async () => { const mockUrls = ['url1', 'url2', 'url3']; vi.mocked(mockClient.presignedUrl) .mockResolvedValueOnce('url1') .mockResolvedValueOnce('url2') .mockResolvedValueOnce('url3'); const result = await minioService.generateMultipartUploadUrls( 'test-bucket', 'large-file.zip', 'upload-123', 3 ); expect(mockClient.presignedUrl).toHaveBeenCalledTimes(3); expect(mockClient.presignedUrl).toHaveBeenNthCalledWith( 1, 'put', 'test-bucket', 'large-file.zip', 3600, { uploadId: 'upload-123', partNumber: '1' } ); expect(result).toEqual(mockUrls); }); }); describe('completeMultipartUpload', () => { it('should complete multipart upload successfully', async () => { const parts = [ { ETag: 'etag1', PartNumber: 1 }, { ETag: 'etag2', PartNumber: 2 } ]; const mockStat = { size: 2048 }; vi.mocked(mockClient.completeMultipartUpload).mockResolvedValue({ etag: 'etag123', versionId: null }); vi.mocked(mockClient.statObject).mockResolvedValue(mockStat as any); const result = await minioService.completeMultipartUpload( 'test-bucket', 'large-file.zip', 'upload-123', parts ); expect(mockClient.completeMultipartUpload).toHaveBeenCalledWith( 'test-bucket', 'large-file.zip', 'upload-123', [{ part: 1, etag: 'etag1' }, { part: 2, etag: 'etag2' }] ); expect(mockClient.statObject).toHaveBeenCalledWith('test-bucket', 'large-file.zip'); expect(result).toEqual({ size: 2048 }); expect(logger.db).toHaveBeenCalledWith( 'Completed multipart upload for large-file.zip with ID: upload-123' ); }); it('should handle errors during completion', async () => { const error = new Error('Completion failed'); vi.mocked(mockClient.completeMultipartUpload).mockRejectedValue(error); await expect(minioService.completeMultipartUpload( 'test-bucket', 'file.zip', 'upload-123', [{ ETag: 'etag1', PartNumber: 1 }] )).rejects.toThrow(error); expect(logger.error).toHaveBeenCalledWith( 'Failed to complete multipart upload for file.zip:', error ); }); }); describe('createObject', () => { it('should create object successfully', async () => { const fileContent = Buffer.from('test content'); const mockUrl = 'http://localhost:9000/test-bucket/file.txt'; vi.spyOn(minioService, 'ensureBucketExists').mockResolvedValue(true); vi.mocked(mockClient.putObject).mockResolvedValue({ etag: 'etag123', versionId: null }); vi.spyOn(minioService, 'getFileUrl').mockReturnValue(mockUrl); const result = await minioService.createObject( 'test-bucket', 'file.txt', fileContent, 'text/plain' ); expect(minioService.ensureBucketExists).toHaveBeenCalledWith('test-bucket'); expect(mockClient.putObject).toHaveBeenCalledWith( 'test-bucket', 'file.txt', fileContent, fileContent.length, { 'Content-Type': 'text/plain' } ); expect(result).toBe(mockUrl); expect(logger.db).toHaveBeenCalledWith('Created object: test-bucket/file.txt'); }); }); describe('objectExists', () => { it('should return true when object exists', async () => { vi.mocked(mockClient.statObject).mockResolvedValue({} as any); const result = await minioService.objectExists('test-bucket', 'file.txt'); expect(result).toBe(true); }); it('should return false when object not found', async () => { const error = new Error('Object not found'); vi.mocked(mockClient.statObject).mockRejectedValue(error); const result = await minioService.objectExists('test-bucket', 'nonexistent.txt'); expect(result).toBe(false); }); it('should rethrow other errors', async () => { const error = new Error('Permission denied'); vi.mocked(mockClient.statObject).mockRejectedValue(error); await expect(minioService.objectExists('test-bucket', 'file.txt')).rejects.toThrow(error); expect(logger.error).toHaveBeenCalledWith( 'Error checking existence of object test-bucket/file.txt:', error ); }); }); describe('deleteObject', () => { it('should delete object successfully', async () => { vi.mocked(mockClient.removeObject).mockResolvedValue(undefined); await minioService.deleteObject('test-bucket', 'file.txt'); expect(mockClient.removeObject).toHaveBeenCalledWith('test-bucket', 'file.txt'); expect(logger.db).toHaveBeenCalledWith('Deleted object: test-bucket/file.txt'); }); it('should handle errors during deletion', async () => { const error = new Error('Deletion failed'); vi.mocked(mockClient.removeObject).mockRejectedValue(error); await expect(minioService.deleteObject('test-bucket', 'file.txt')).rejects.toThrow(error); expect(logger.error).toHaveBeenCalledWith( 'Failed to delete object test-bucket/file.txt:', error ); }); }); });