|
|
@@ -1,424 +0,0 @@
|
|
|
-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
|
|
|
- );
|
|
|
- });
|
|
|
- });
|
|
|
-});
|