|
|
@@ -0,0 +1,429 @@
|
|
|
+import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
|
+import { DataSource } from 'typeorm';
|
|
|
+import { FileService } from '../../src/services/file.service';
|
|
|
+import { File } from '../../src/entities/file.entity';
|
|
|
+import { MinioService } from '../../src/services/minio.service';
|
|
|
+import { logger } from '@d8d/shared-utils';
|
|
|
+
|
|
|
+// Mock dependencies
|
|
|
+vi.mock('../../src/services/minio.service');
|
|
|
+vi.mock('@d8d/shared-utils', () => ({
|
|
|
+ logger: {
|
|
|
+ error: vi.fn(),
|
|
|
+ db: vi.fn()
|
|
|
+ }
|
|
|
+}));
|
|
|
+vi.mock('uuid', () => ({
|
|
|
+ v4: () => 'test-uuid-123'
|
|
|
+}));
|
|
|
+
|
|
|
+describe('FileService', () => {
|
|
|
+ let mockDataSource: DataSource;
|
|
|
+
|
|
|
+ beforeEach(() => {
|
|
|
+ mockDataSource = {
|
|
|
+ getRepository: vi.fn(() => ({
|
|
|
+ findOneBy: vi.fn(),
|
|
|
+ save: vi.fn()
|
|
|
+ }))
|
|
|
+ } as unknown as DataSource;
|
|
|
+
|
|
|
+ });
|
|
|
+
|
|
|
+ afterEach(() => {
|
|
|
+ vi.clearAllMocks();
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('createFile', () => {
|
|
|
+ it('should create file with upload policy successfully', async () => {
|
|
|
+ const mockFileData = {
|
|
|
+ name: 'test.txt',
|
|
|
+ type: 'text/plain',
|
|
|
+ size: 1024,
|
|
|
+ uploadUserId: 1
|
|
|
+ };
|
|
|
+
|
|
|
+ const mockUploadPolicy = {
|
|
|
+ 'x-amz-algorithm': 'test-algorithm',
|
|
|
+ 'x-amz-credential': 'test-credential',
|
|
|
+ host: 'https://minio.example.com'
|
|
|
+ };
|
|
|
+
|
|
|
+ const mockSavedFile = {
|
|
|
+ id: 1,
|
|
|
+ ...mockFileData,
|
|
|
+ path: '1/test-uuid-123-test.txt',
|
|
|
+ uploadTime: new Date(),
|
|
|
+ createdAt: new Date(),
|
|
|
+ updatedAt: new Date()
|
|
|
+ };
|
|
|
+
|
|
|
+
|
|
|
+ const mockGenerateUploadPolicy = vi.fn().mockResolvedValue(mockUploadPolicy);
|
|
|
+ vi.mocked(MinioService).mockImplementation(() => ({
|
|
|
+ generateUploadPolicy: mockGenerateUploadPolicy
|
|
|
+ } as unknown as MinioService));
|
|
|
+
|
|
|
+ const fileService = new FileService(mockDataSource);
|
|
|
+
|
|
|
+ // Mock GenericCrudService methods
|
|
|
+ vi.spyOn(fileService, 'create').mockResolvedValue(mockSavedFile as File);
|
|
|
+
|
|
|
+ const result = await fileService.createFile(mockFileData);
|
|
|
+
|
|
|
+ expect(mockGenerateUploadPolicy).toHaveBeenCalledWith('1/test-uuid-123-test.txt');
|
|
|
+ expect(fileService.create).toHaveBeenCalledWith(expect.objectContaining({
|
|
|
+ name: 'test.txt',
|
|
|
+ path: '1/test-uuid-123-test.txt',
|
|
|
+ uploadUserId: 1
|
|
|
+ }));
|
|
|
+ expect(result).toEqual({
|
|
|
+ file: mockSavedFile,
|
|
|
+ uploadPolicy: mockUploadPolicy
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ it('should handle errors during file creation', async () => {
|
|
|
+ const mockFileData = {
|
|
|
+ name: 'test.txt',
|
|
|
+ uploadUserId: 1
|
|
|
+ };
|
|
|
+
|
|
|
+ const mockGenerateUploadPolicy = vi.fn().mockRejectedValue(new Error('MinIO error'));
|
|
|
+ vi.mocked(MinioService).mockImplementation(() => ({
|
|
|
+ generateUploadPolicy: mockGenerateUploadPolicy
|
|
|
+ } as unknown as MinioService));
|
|
|
+
|
|
|
+ const fileService = new FileService(mockDataSource);
|
|
|
+
|
|
|
+ await expect(fileService.createFile(mockFileData)).rejects.toThrow('文件创建失败');
|
|
|
+ expect(logger.error).toHaveBeenCalled();
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('deleteFile', () => {
|
|
|
+ it('should delete file successfully when file exists', async () => {
|
|
|
+ const mockFile = {
|
|
|
+ id: 1,
|
|
|
+ path: '1/test-file.txt',
|
|
|
+ name: 'test-file.txt'
|
|
|
+ } as File;
|
|
|
+
|
|
|
+ const mockObjectExists = vi.fn().mockResolvedValue(true);
|
|
|
+ const mockDeleteObject = vi.fn().mockResolvedValue(undefined);
|
|
|
+
|
|
|
+ vi.mocked(MinioService).mockImplementation(() => ({
|
|
|
+ objectExists: mockObjectExists,
|
|
|
+ deleteObject: mockDeleteObject,
|
|
|
+ bucketName: 'd8dai'
|
|
|
+ } as unknown as MinioService));
|
|
|
+
|
|
|
+ const fileService = new FileService(mockDataSource);
|
|
|
+ vi.spyOn(fileService, 'getById').mockResolvedValue(mockFile);
|
|
|
+ vi.spyOn(fileService, 'delete').mockResolvedValue(true);
|
|
|
+
|
|
|
+ const result = await fileService.deleteFile(1);
|
|
|
+
|
|
|
+ expect(fileService.getById).toHaveBeenCalledWith(1);
|
|
|
+ expect(mockObjectExists).toHaveBeenCalledWith('d8dai', '1/test-file.txt');
|
|
|
+ expect(mockDeleteObject).toHaveBeenCalledWith('d8dai', '1/test-file.txt');
|
|
|
+ expect(fileService.delete).toHaveBeenCalledWith(1);
|
|
|
+ expect(result).toBe(true);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('should delete database record even when MinIO file not found', async () => {
|
|
|
+ const mockFile = {
|
|
|
+ id: 1,
|
|
|
+ path: '1/test-file.txt',
|
|
|
+ name: 'test-file.txt'
|
|
|
+ } as File;
|
|
|
+
|
|
|
+ const mockObjectExists = vi.fn().mockResolvedValue(false);
|
|
|
+
|
|
|
+ vi.mocked(MinioService).mockImplementation(() => ({
|
|
|
+ objectExists: mockObjectExists,
|
|
|
+ deleteObject: vi.fn(),
|
|
|
+ bucketName: 'd8dai'
|
|
|
+ } as unknown as MinioService));
|
|
|
+
|
|
|
+ const fileService = new FileService(mockDataSource);
|
|
|
+ vi.spyOn(fileService, 'getById').mockResolvedValue(mockFile);
|
|
|
+ vi.spyOn(fileService, 'delete').mockResolvedValue(true);
|
|
|
+
|
|
|
+ const result = await fileService.deleteFile(1);
|
|
|
+
|
|
|
+ expect(mockObjectExists).toHaveBeenCalledWith('d8dai', '1/test-file.txt');
|
|
|
+ expect(fileService.delete).toHaveBeenCalledWith(1);
|
|
|
+ expect(result).toBe(true);
|
|
|
+ expect(logger.error).toHaveBeenCalled();
|
|
|
+ });
|
|
|
+
|
|
|
+ it('should throw error when file not found', async () => {
|
|
|
+ const fileService = new FileService(mockDataSource);
|
|
|
+ vi.spyOn(fileService, 'getById').mockResolvedValue(null);
|
|
|
+
|
|
|
+ await expect(fileService.deleteFile(999)).rejects.toThrow('文件不存在');
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('getFileUrl', () => {
|
|
|
+ it('should return file URL successfully', async () => {
|
|
|
+ const mockFile = {
|
|
|
+ id: 1,
|
|
|
+ path: '1/test-file.txt'
|
|
|
+ } as File;
|
|
|
+
|
|
|
+ const mockPresignedUrl = 'https://minio.example.com/presigned-url';
|
|
|
+
|
|
|
+ const mockGetPresignedFileUrl = vi.fn().mockResolvedValue(mockPresignedUrl);
|
|
|
+
|
|
|
+ vi.mocked(MinioService).mockImplementation(() => ({
|
|
|
+ getPresignedFileUrl: mockGetPresignedFileUrl,
|
|
|
+ bucketName: 'd8dai'
|
|
|
+ } as unknown as MinioService));
|
|
|
+
|
|
|
+ const fileService = new FileService(mockDataSource);
|
|
|
+ vi.spyOn(fileService, 'getById').mockResolvedValue(mockFile);
|
|
|
+
|
|
|
+ const result = await fileService.getFileUrl(1);
|
|
|
+
|
|
|
+ expect(fileService.getById).toHaveBeenCalledWith(1);
|
|
|
+ expect(mockGetPresignedFileUrl).toHaveBeenCalledWith('d8dai', '1/test-file.txt');
|
|
|
+ expect(result).toBe(mockPresignedUrl);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('should throw error when file not found', async () => {
|
|
|
+ const fileService = new FileService(mockDataSource);
|
|
|
+ vi.spyOn(fileService, 'getById').mockResolvedValue(null);
|
|
|
+
|
|
|
+ await expect(fileService.getFileUrl(999)).rejects.toThrow('文件不存在');
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('getFileDownloadUrl', () => {
|
|
|
+ it('should return download URL with filename', async () => {
|
|
|
+ const mockFile = {
|
|
|
+ id: 1,
|
|
|
+ path: '1/test-file.txt',
|
|
|
+ name: '测试文件.txt'
|
|
|
+ } as File;
|
|
|
+
|
|
|
+ const mockPresignedUrl = 'https://minio.example.com/download-url';
|
|
|
+
|
|
|
+ const mockGetPresignedFileDownloadUrl = vi.fn().mockResolvedValue(mockPresignedUrl);
|
|
|
+
|
|
|
+ vi.mocked(MinioService).mockImplementation(() => ({
|
|
|
+ getPresignedFileDownloadUrl: mockGetPresignedFileDownloadUrl,
|
|
|
+ bucketName: 'd8dai'
|
|
|
+ } as unknown as MinioService));
|
|
|
+
|
|
|
+ const fileService = new FileService(mockDataSource);
|
|
|
+ vi.spyOn(fileService, 'getById').mockResolvedValue(mockFile);
|
|
|
+
|
|
|
+ const result = await fileService.getFileDownloadUrl(1);
|
|
|
+
|
|
|
+ expect(fileService.getById).toHaveBeenCalledWith(1);
|
|
|
+ expect(mockGetPresignedFileDownloadUrl).toHaveBeenCalledWith(
|
|
|
+ 'd8dai',
|
|
|
+ '1/test-file.txt',
|
|
|
+ '测试文件.txt'
|
|
|
+ );
|
|
|
+ expect(result).toEqual({
|
|
|
+ url: mockPresignedUrl,
|
|
|
+ filename: '测试文件.txt'
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ it('should throw error when file not found', async () => {
|
|
|
+ const fileService = new FileService(mockDataSource);
|
|
|
+ vi.spyOn(fileService, 'getById').mockResolvedValue(null);
|
|
|
+
|
|
|
+ await expect(fileService.getFileDownloadUrl(999)).rejects.toThrow('文件不存在');
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('createMultipartUploadPolicy', () => {
|
|
|
+ it('should create multipart upload policy successfully', async () => {
|
|
|
+ const mockFileData = {
|
|
|
+ name: 'large-file.zip',
|
|
|
+ type: 'application/zip',
|
|
|
+ uploadUserId: 1
|
|
|
+ };
|
|
|
+
|
|
|
+ const mockUploadId = 'upload-123';
|
|
|
+ const mockUploadUrls = ['url1', 'url2', 'url3'];
|
|
|
+ const mockSavedFile = {
|
|
|
+ id: 1,
|
|
|
+ ...mockFileData,
|
|
|
+ path: '1/test-uuid-123-large-file.zip',
|
|
|
+ uploadTime: new Date(),
|
|
|
+ createdAt: new Date(),
|
|
|
+ updatedAt: new Date()
|
|
|
+ } as File;
|
|
|
+
|
|
|
+ const mockCreateMultipartUpload = vi.fn().mockResolvedValue(mockUploadId);
|
|
|
+ const mockGenerateMultipartUploadUrls = vi.fn().mockResolvedValue(mockUploadUrls);
|
|
|
+
|
|
|
+ vi.mocked(MinioService).mockImplementation(() => ({
|
|
|
+ createMultipartUpload: mockCreateMultipartUpload,
|
|
|
+ generateMultipartUploadUrls: mockGenerateMultipartUploadUrls,
|
|
|
+ bucketName: 'd8dai'
|
|
|
+ } as unknown as MinioService));
|
|
|
+
|
|
|
+ const fileService = new FileService(mockDataSource);
|
|
|
+ vi.spyOn(fileService, 'create').mockResolvedValue(mockSavedFile);
|
|
|
+
|
|
|
+ const result = await fileService.createMultipartUploadPolicy(mockFileData, 3);
|
|
|
+
|
|
|
+ expect(mockCreateMultipartUpload).toHaveBeenCalledWith('d8dai', '1/test-uuid-123-large-file.zip');
|
|
|
+ expect(mockGenerateMultipartUploadUrls).toHaveBeenCalledWith(
|
|
|
+ 'd8dai',
|
|
|
+ '1/test-uuid-123-large-file.zip',
|
|
|
+ mockUploadId,
|
|
|
+ 3
|
|
|
+ );
|
|
|
+ expect(result).toEqual({
|
|
|
+ file: mockSavedFile,
|
|
|
+ uploadId: mockUploadId,
|
|
|
+ uploadUrls: mockUploadUrls,
|
|
|
+ bucket: 'd8dai',
|
|
|
+ key: '1/test-uuid-123-large-file.zip'
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ it('should handle errors during multipart upload creation', async () => {
|
|
|
+ const mockFileData = {
|
|
|
+ name: 'large-file.zip',
|
|
|
+ uploadUserId: 1
|
|
|
+ };
|
|
|
+
|
|
|
+ const mockCreateMultipartUpload = vi.fn().mockRejectedValue(new Error('MinIO error'));
|
|
|
+
|
|
|
+ vi.mocked(MinioService).mockImplementation(() => ({
|
|
|
+ createMultipartUpload: mockCreateMultipartUpload,
|
|
|
+ bucketName: 'd8dai'
|
|
|
+ } as unknown as MinioService));
|
|
|
+
|
|
|
+ const fileService = new FileService(mockDataSource);
|
|
|
+
|
|
|
+ await expect(fileService.createMultipartUploadPolicy(mockFileData, 3)).rejects.toThrow('创建多部分上传策略失败');
|
|
|
+ expect(logger.error).toHaveBeenCalled();
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('completeMultipartUpload', () => {
|
|
|
+ it('should complete multipart upload successfully', async () => {
|
|
|
+ const uploadData = {
|
|
|
+ uploadId: 'upload-123',
|
|
|
+ bucket: 'd8dai',
|
|
|
+ key: '1/test-file.txt',
|
|
|
+ parts: [
|
|
|
+ { partNumber: 1, etag: 'etag1' },
|
|
|
+ { partNumber: 2, etag: 'etag2' }
|
|
|
+ ]
|
|
|
+ };
|
|
|
+
|
|
|
+ const mockFile = {
|
|
|
+ id: 1,
|
|
|
+ path: '1/test-file.txt',
|
|
|
+ size: 0,
|
|
|
+ updatedAt: new Date()
|
|
|
+ } as File;
|
|
|
+
|
|
|
+ const mockCompleteResult = { size: 2048 };
|
|
|
+ const mockFileUrl = 'https://minio.example.com/file.txt';
|
|
|
+
|
|
|
+ const mockCompleteMultipartUpload = vi.fn().mockResolvedValue(mockCompleteResult);
|
|
|
+ const mockGetFileUrl = vi.fn().mockReturnValue(mockFileUrl);
|
|
|
+
|
|
|
+ vi.mocked(MinioService).mockImplementation(() => ({
|
|
|
+ completeMultipartUpload: mockCompleteMultipartUpload,
|
|
|
+ getFileUrl: mockGetFileUrl
|
|
|
+ } as unknown as MinioService));
|
|
|
+
|
|
|
+ const mockRepository = {
|
|
|
+ findOneBy: vi.fn().mockResolvedValue(mockFile),
|
|
|
+ save: vi.fn().mockResolvedValue({ ...mockFile, size: 2048 } as File)
|
|
|
+ };
|
|
|
+
|
|
|
+ mockDataSource.getRepository = vi.fn().mockReturnValue(mockRepository);
|
|
|
+ const fileService = new FileService(mockDataSource);
|
|
|
+
|
|
|
+ const result = await fileService.completeMultipartUpload(uploadData);
|
|
|
+
|
|
|
+ expect(mockCompleteMultipartUpload).toHaveBeenCalledWith(
|
|
|
+ 'd8dai',
|
|
|
+ '1/test-file.txt',
|
|
|
+ 'upload-123',
|
|
|
+ [{ PartNumber: 1, ETag: 'etag1' }, { PartNumber: 2, ETag: 'etag2' }]
|
|
|
+ );
|
|
|
+ expect(mockRepository.findOneBy).toHaveBeenCalledWith({ path: '1/test-file.txt' });
|
|
|
+ expect(mockRepository.save).toHaveBeenCalledWith(expect.objectContaining({
|
|
|
+ size: 2048
|
|
|
+ }));
|
|
|
+ expect(result).toEqual({
|
|
|
+ fileId: 1,
|
|
|
+ url: mockFileUrl,
|
|
|
+ key: '1/test-file.txt',
|
|
|
+ size: 2048
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ it('should throw error when file record not found', async () => {
|
|
|
+ const uploadData = {
|
|
|
+ uploadId: 'upload-123',
|
|
|
+ bucket: 'd8dai',
|
|
|
+ key: '1/nonexistent.txt',
|
|
|
+ parts: [{ partNumber: 1, etag: 'etag1' }]
|
|
|
+ };
|
|
|
+
|
|
|
+ const mockCompleteMultipartUpload = vi.fn().mockResolvedValue({ size: 1024 });
|
|
|
+
|
|
|
+ vi.mocked(MinioService).mockImplementation(() => ({
|
|
|
+ completeMultipartUpload: mockCompleteMultipartUpload
|
|
|
+ } as unknown as MinioService));
|
|
|
+
|
|
|
+ const mockRepository = {
|
|
|
+ findOneBy: vi.fn().mockResolvedValue(null)
|
|
|
+ };
|
|
|
+
|
|
|
+ mockDataSource.getRepository = vi.fn().mockReturnValue(mockRepository);
|
|
|
+ const fileService = new FileService(mockDataSource);
|
|
|
+
|
|
|
+ await expect(fileService.completeMultipartUpload(uploadData)).rejects.toThrow('文件记录不存在');
|
|
|
+ });
|
|
|
+
|
|
|
+ it('should handle errors during completion', async () => {
|
|
|
+ const uploadData = {
|
|
|
+ uploadId: 'upload-123',
|
|
|
+ bucket: 'd8dai',
|
|
|
+ key: '1/test-file.txt',
|
|
|
+ parts: [{ partNumber: 1, etag: 'etag1' }]
|
|
|
+ };
|
|
|
+
|
|
|
+ const mockFile = {
|
|
|
+ id: 1,
|
|
|
+ path: '1/test-file.txt',
|
|
|
+ size: 0,
|
|
|
+ updatedAt: new Date()
|
|
|
+ } as File;
|
|
|
+
|
|
|
+ const mockRepository = {
|
|
|
+ findOneBy: vi.fn().mockResolvedValue(mockFile),
|
|
|
+ save: vi.fn()
|
|
|
+ };
|
|
|
+
|
|
|
+ const mockCompleteMultipartUpload = vi.fn().mockRejectedValue(new Error('Completion failed'));
|
|
|
+
|
|
|
+ mockDataSource.getRepository = vi.fn().mockReturnValue(mockRepository);
|
|
|
+ vi.mocked(MinioService).mockImplementation(() => ({
|
|
|
+ completeMultipartUpload: mockCompleteMultipartUpload
|
|
|
+ } as unknown as MinioService));
|
|
|
+
|
|
|
+ const fileService = new FileService(mockDataSource);
|
|
|
+
|
|
|
+ await expect(fileService.completeMultipartUpload(uploadData)).rejects.toThrow('完成分片上传失败');
|
|
|
+ expect(logger.error).toHaveBeenCalled();
|
|
|
+ });
|
|
|
+ });
|
|
|
+});
|