瀏覽代碼

✅ test(files): 添加完整的文件服务集成测试套件

- 新增 MinIO 服务集成测试,覆盖桶操作、文件操作、预签名 URL 和多部分上传
- 添加文件 API 集成测试,验证上传策略、文件 URL 生成、下载和删除功能
- 实现文件服务单元测试,测试文件创建、删除、URL 生成和多部分上传完成
- 包含 MinIO 服务单元测试,验证配置、策略设置和所有文件操作方法
- 添加端到端测试,覆盖管理员文件管理的完整用户流程

【性能测试】
- 包含并发操作和大文件处理测试场景
- 验证多部分上传工作流的正确性

【错误处理】
- 全面测试各种错误场景和异常处理
- 包含权限错误、连接错误和操作失败的测试用例
yourname 2 月之前
父節點
當前提交
d579256964

+ 280 - 0
src/server/__integration_tests__/minio.integration.test.ts

@@ -0,0 +1,280 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { MinioService } from '@/server/modules/files/minio.service';
+import { Client } from 'minio';
+import { logger } from '@/server/utils/logger';
+
+// Mock dependencies
+vi.mock('minio');
+vi.mock('@/server/utils/logger');
+
+describe('MinIO Integration Tests', () => {
+  let minioService: MinioService;
+  let mockClient: Client;
+
+  beforeEach(() => {
+    mockClient = new Client({} as any);
+    (Client as any).mockClear();
+
+    minioService = new MinioService();
+  });
+
+  afterEach(() => {
+    vi.clearAllMocks();
+  });
+
+  describe('Bucket Operations', () => {
+    it('should ensure bucket exists and set policy', async () => {
+      // Mock bucket doesn't exist
+      vi.mocked(mockClient.bucketExists).mockResolvedValue(false);
+      vi.mocked(mockClient.makeBucket).mockResolvedValue(undefined);
+      vi.mocked(mockClient.setBucketPolicy).mockResolvedValue(undefined);
+
+      const result = await minioService.ensureBucketExists();
+
+      expect(result).toBe(true);
+      expect(mockClient.bucketExists).toHaveBeenCalledWith('test-bucket');
+      expect(mockClient.makeBucket).toHaveBeenCalledWith('test-bucket');
+      expect(mockClient.setBucketPolicy).toHaveBeenCalled();
+      expect(logger.db).toHaveBeenCalledWith('Created new bucket: test-bucket');
+    });
+
+    it('should handle existing bucket', async () => {
+      vi.mocked(mockClient.bucketExists).mockResolvedValue(true);
+
+      const result = await minioService.ensureBucketExists();
+
+      expect(result).toBe(true);
+      expect(mockClient.bucketExists).toHaveBeenCalledWith('test-bucket');
+      expect(mockClient.makeBucket).not.toHaveBeenCalled();
+      expect(mockClient.setBucketPolicy).not.toHaveBeenCalled();
+    });
+  });
+
+  describe('File Operations', () => {
+    it('should upload and download file successfully', async () => {
+      const testContent = Buffer.from('Hello, MinIO!');
+      const mockUrl = 'http://localhost:9000/test-bucket/test.txt';
+
+      // Mock bucket operations
+      vi.mocked(mockClient.bucketExists).mockResolvedValue(true);
+      vi.mocked(mockClient.putObject).mockResolvedValue(undefined);
+      vi.mocked(mockClient.statObject).mockResolvedValue({ size: testContent.length } as any);
+      vi.mocked(mockClient.getObject).mockReturnValue({
+        on: (event: string, callback: Function) => {
+          if (event === 'data') callback(testContent);
+          if (event === 'end') callback();
+        }
+      } as any);
+
+      // Upload file
+      const uploadUrl = await minioService.createObject('test-bucket', 'test.txt', testContent, 'text/plain');
+      expect(uploadUrl).toBe(mockUrl);
+      expect(mockClient.putObject).toHaveBeenCalledWith(
+        'test-bucket',
+        'test.txt',
+        testContent,
+        testContent.length,
+        { 'Content-Type': 'text/plain' }
+      );
+
+      // Check file exists
+      const exists = await minioService.objectExists('test-bucket', 'test.txt');
+      expect(exists).toBe(true);
+      expect(mockClient.statObject).toHaveBeenCalledWith('test-bucket', 'test.txt');
+    });
+
+    it('should handle file not found', async () => {
+      const notFoundError = new Error('Object not found');
+      notFoundError.message = 'The specified key does not exist';
+      vi.mocked(mockClient.statObject).mockRejectedValue(notFoundError);
+
+      const exists = await minioService.objectExists('test-bucket', 'nonexistent.txt');
+      expect(exists).toBe(false);
+    });
+
+    it('should delete file successfully', async () => {
+      vi.mocked(mockClient.removeObject).mockResolvedValue(undefined);
+
+      await minioService.deleteObject('test-bucket', 'test.txt');
+
+      expect(mockClient.removeObject).toHaveBeenCalledWith('test-bucket', 'test.txt');
+      expect(logger.db).toHaveBeenCalledWith('Deleted object: test-bucket/test.txt');
+    });
+  });
+
+  describe('Presigned URL Operations', () => {
+    it('should generate presigned URLs correctly', async () => {
+      const mockPresignedUrl = 'https://minio.example.com/presigned-url';
+      vi.mocked(mockClient.presignedGetObject).mockResolvedValue(mockPresignedUrl);
+
+      // Test regular presigned URL
+      const url = await minioService.getPresignedFileUrl('test-bucket', 'file.txt', 3600);
+      expect(url).toBe(mockPresignedUrl);
+      expect(mockClient.presignedGetObject).toHaveBeenCalledWith('test-bucket', 'file.txt', 3600);
+
+      // Test download URL with content disposition
+      const downloadUrl = await minioService.getPresignedFileDownloadUrl(
+        'test-bucket',
+        'file.txt',
+        '测试文件.txt',
+        1800
+      );
+      expect(downloadUrl).toBe(mockPresignedUrl);
+      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'
+        }
+      );
+    });
+  });
+
+  describe('Multipart Upload Operations', () => {
+    it('should handle multipart upload workflow', async () => {
+      const mockUploadId = 'upload-123';
+      const mockPartUrls = ['url1', 'url2', 'url3'];
+      const mockStat = { size: 3072 };
+
+      // Mock multipart operations
+      vi.mocked(mockClient.initiateNewMultipartUpload).mockResolvedValue(mockUploadId);
+      vi.mocked(mockClient.presignedUrl)
+        .mockResolvedValueOnce('url1')
+        .mockResolvedValueOnce('url2')
+        .mockResolvedValueOnce('url3');
+      vi.mocked(mockClient.completeMultipartUpload).mockResolvedValue(undefined);
+      vi.mocked(mockClient.statObject).mockResolvedValue(mockStat as any);
+
+      // Create multipart upload
+      const uploadId = await minioService.createMultipartUpload('test-bucket', 'large-file.zip');
+      expect(uploadId).toBe(mockUploadId);
+      expect(mockClient.initiateNewMultipartUpload).toHaveBeenCalledWith(
+        'test-bucket',
+        'large-file.zip',
+        {}
+      );
+
+      // Generate part URLs
+      const partUrls = await minioService.generateMultipartUploadUrls(
+        'test-bucket',
+        'large-file.zip',
+        mockUploadId,
+        3
+      );
+      expect(partUrls).toEqual(mockPartUrls);
+      expect(mockClient.presignedUrl).toHaveBeenCalledTimes(3);
+
+      // Complete multipart upload
+      const parts = [
+        { ETag: 'etag1', PartNumber: 1 },
+        { ETag: 'etag2', PartNumber: 2 },
+        { ETag: 'etag3', PartNumber: 3 }
+      ];
+      const result = await minioService.completeMultipartUpload(
+        'test-bucket',
+        'large-file.zip',
+        mockUploadId,
+        parts
+      );
+      expect(result).toEqual({ size: 3072 });
+      expect(mockClient.completeMultipartUpload).toHaveBeenCalledWith(
+        'test-bucket',
+        'large-file.zip',
+        mockUploadId,
+        [{ part: 1, etag: 'etag1' }, { part: 2, etag: 'etag2' }, { part: 3, etag: 'etag3' }]
+      );
+    });
+  });
+
+  describe('Error Handling', () => {
+    it('should handle MinIO connection errors', async () => {
+      const connectionError = new Error('Connection refused');
+      vi.mocked(mockClient.bucketExists).mockRejectedValue(connectionError);
+
+      await expect(minioService.ensureBucketExists()).rejects.toThrow(connectionError);
+      expect(logger.error).toHaveBeenCalledWith(
+        'Failed to ensure bucket exists: test-bucket',
+        connectionError
+      );
+    });
+
+    it('should handle file operation errors', async () => {
+      const operationError = new Error('Operation failed');
+      vi.mocked(mockClient.putObject).mockRejectedValue(operationError);
+
+      await expect(minioService.createObject(
+        'test-bucket',
+        'test.txt',
+        Buffer.from('test'),
+        'text/plain'
+      )).rejects.toThrow(operationError);
+      expect(logger.error).toHaveBeenCalledWith(
+        'Failed to create object test-bucket/test.txt:',
+        operationError
+      );
+    });
+
+    it('should handle permission errors gracefully', async () => {
+      const permissionError = new Error('Permission denied');
+      vi.mocked(mockClient.statObject).mockRejectedValue(permissionError);
+
+      await expect(minioService.objectExists('test-bucket', 'file.txt')).rejects.toThrow(permissionError);
+      expect(logger.error).toHaveBeenCalledWith(
+        'Error checking existence of object test-bucket/file.txt:',
+        permissionError
+      );
+    });
+  });
+
+  describe('Configuration Validation', () => {
+    it('should validate MinIO configuration', () => {
+      expect(minioService.bucketName).toBe('test-bucket');
+
+      // Test URL generation with different configurations
+      const url = minioService.getFileUrl('test-bucket', 'file.txt');
+      expect(url).toBe('http://localhost:9000/test-bucket/file.txt');
+    });
+
+    it('should handle SSL configuration', async () => {
+      // Create new instance with SSL
+      vi.mocked(process.env).MINIO_USE_SSL = 'true';
+      vi.mocked(process.env).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('Performance Testing', () => {
+    it('should handle concurrent operations', async () => {
+      vi.mocked(mockClient.presignedGetObject).mockResolvedValue('https://minio.example.com/file');
+
+      // Test concurrent URL generation
+      const promises = Array(10).fill(0).map((_, i) =>
+        minioService.getPresignedFileUrl('test-bucket', `file${i}.txt`)
+      );
+
+      const results = await Promise.all(promises);
+      expect(results).toHaveLength(10);
+      expect(results.every(url => url === 'https://minio.example.com/file')).toBe(true);
+    });
+
+    it('should handle large file operations', async () => {
+      const largeBuffer = Buffer.alloc(10 * 1024 * 1024); // 10MB
+      vi.mocked(mockClient.putObject).mockResolvedValue(undefined);
+
+      await minioService.createObject('test-bucket', 'large-file.bin', largeBuffer, 'application/octet-stream');
+
+      expect(mockClient.putObject).toHaveBeenCalledWith(
+        'test-bucket',
+        'large-file.bin',
+        largeBuffer,
+        largeBuffer.length,
+        { 'Content-Type': 'application/octet-stream' }
+      );
+    });
+  });
+});

+ 459 - 0
src/server/api/files/__tests__/files.integration.test.ts

@@ -0,0 +1,459 @@
+import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
+import { createServer } from '@/server';
+import { DataSource } from 'typeorm';
+import { File } from '@/server/modules/files/file.entity';
+import { FileService } from '@/server/modules/files/file.service';
+import { MinioService } from '@/server/modules/files/minio.service';
+import { authMiddleware } from '@/server/middleware/auth.middleware';
+
+// Mock dependencies
+vi.mock('@/server/modules/files/file.service');
+vi.mock('@/server/modules/files/minio.service');
+vi.mock('@/server/middleware/auth.middleware');
+
+describe('File API Integration Tests', () => {
+  let app: any;
+  let mockFileService: FileService;
+  let mockMinioService: MinioService;
+  let mockDataSource: DataSource;
+
+  beforeEach(async () => {
+    vi.clearAllMocks();
+
+    mockDataSource = {} as DataSource;
+    mockFileService = new FileService(mockDataSource);
+    mockMinioService = new MinioService();
+
+    // Mock auth middleware to bypass authentication
+    vi.mocked(authMiddleware).mockImplementation((_, next) => next());
+
+    app = createServer();
+  });
+
+  afterEach(() => {
+    vi.clearAllMocks();
+  });
+
+  describe('POST /api/v1/files/upload-policy', () => {
+    it('should generate upload policy successfully', async () => {
+      const mockFileData = {
+        name: 'test.txt',
+        type: 'text/plain',
+        size: 1024,
+        description: 'Test file',
+        uploadUserId: 1
+      };
+
+      const mockResponse = {
+        file: {
+          id: 1,
+          ...mockFileData,
+          path: '1/test-uuid-123-test.txt',
+          uploadTime: new Date(),
+          createdAt: new Date(),
+          updatedAt: new Date()
+        },
+        uploadPolicy: {
+          host: 'https://minio.example.com',
+          key: '1/test-uuid-123-test.txt',
+          bucket: 'd8dai'
+        }
+      };
+
+      vi.mocked(mockFileService.createFile).mockResolvedValue(mockResponse);
+
+      const response = await app.request('/api/v1/files/upload-policy', {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          'Authorization': 'Bearer test-token'
+        },
+        body: JSON.stringify(mockFileData)
+      });
+
+      expect(response.status).toBe(200);
+      const result = await response.json();
+      expect(result).toEqual(mockResponse);
+      expect(mockFileService.createFile).toHaveBeenCalledWith(mockFileData);
+    });
+
+    it('should return 400 for invalid request data', async () => {
+      const invalidData = {
+        name: '', // Empty name
+        type: 'text/plain'
+      };
+
+      const response = await app.request('/api/v1/files/upload-policy', {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          'Authorization': 'Bearer test-token'
+        },
+        body: JSON.stringify(invalidData)
+      });
+
+      expect(response.status).toBe(400);
+    });
+
+    it('should handle service errors gracefully', async () => {
+      const mockFileData = {
+        name: 'test.txt',
+        type: 'text/plain',
+        uploadUserId: 1
+      };
+
+      vi.mocked(mockFileService.createFile).mockRejectedValue(new Error('Service error'));
+
+      const response = await app.request('/api/v1/files/upload-policy', {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          'Authorization': 'Bearer test-token'
+        },
+        body: JSON.stringify(mockFileData)
+      });
+
+      expect(response.status).toBe(500);
+    });
+  });
+
+  describe('GET /api/v1/files/{id}/url', () => {
+    it('should generate file access URL successfully', async () => {
+      const mockUrl = 'https://minio.example.com/presigned-url';
+      vi.mocked(mockFileService.getFileUrl).mockResolvedValue(mockUrl);
+
+      const response = await app.request('/api/v1/files/1/url', {
+        method: 'GET',
+        headers: {
+          'Authorization': 'Bearer test-token'
+        }
+      });
+
+      expect(response.status).toBe(200);
+      const result = await response.json();
+      expect(result).toEqual({ url: mockUrl });
+      expect(mockFileService.getFileUrl).toHaveBeenCalledWith(1);
+    });
+
+    it('should return 404 when file not found', async () => {
+      vi.mocked(mockFileService.getFileUrl).mockRejectedValue(new Error('文件不存在'));
+
+      const response = await app.request('/api/v1/files/999/url', {
+        method: 'GET',
+        headers: {
+          'Authorization': 'Bearer test-token'
+        }
+      });
+
+      expect(response.status).toBe(404);
+    });
+  });
+
+  describe('GET /api/v1/files/{id}/download', () => {
+    it('should generate file download URL successfully', async () => {
+      const mockDownloadInfo = {
+        url: 'https://minio.example.com/download-url',
+        filename: 'test.txt'
+      };
+      vi.mocked(mockFileService.getFileDownloadUrl).mockResolvedValue(mockDownloadInfo);
+
+      const response = await app.request('/api/v1/files/1/download', {
+        method: 'GET',
+        headers: {
+          'Authorization': 'Bearer test-token'
+        }
+      });
+
+      expect(response.status).toBe(200);
+      const result = await response.json();
+      expect(result).toEqual(mockDownloadInfo);
+      expect(mockFileService.getFileDownloadUrl).toHaveBeenCalledWith(1);
+    });
+
+    it('should return 404 when file not found for download', async () => {
+      vi.mocked(mockFileService.getFileDownloadUrl).mockRejectedValue(new Error('文件不存在'));
+
+      const response = await app.request('/api/v1/files/999/download', {
+        method: 'GET',
+        headers: {
+          'Authorization': 'Bearer test-token'
+        }
+      });
+
+      expect(response.status).toBe(404);
+    });
+  });
+
+  describe('DELETE /api/v1/files/{id}', () => {
+    it('should delete file successfully', async () => {
+      vi.mocked(mockFileService.deleteFile).mockResolvedValue(true);
+
+      const response = await app.request('/api/v1/files/1', {
+        method: 'DELETE',
+        headers: {
+          'Authorization': 'Bearer test-token'
+        }
+      });
+
+      expect(response.status).toBe(200);
+      const result = await response.json();
+      expect(result).toEqual({ success: true });
+      expect(mockFileService.deleteFile).toHaveBeenCalledWith(1);
+    });
+
+    it('should return 404 when file not found for deletion', async () => {
+      vi.mocked(mockFileService.deleteFile).mockRejectedValue(new Error('文件不存在'));
+
+      const response = await app.request('/api/v1/files/999', {
+        method: 'DELETE',
+        headers: {
+          'Authorization': 'Bearer test-token'
+        }
+      });
+
+      expect(response.status).toBe(404);
+    });
+
+    it('should handle deletion errors', async () => {
+      vi.mocked(mockFileService.deleteFile).mockRejectedValue(new Error('删除失败'));
+
+      const response = await app.request('/api/v1/files/1', {
+        method: 'DELETE',
+        headers: {
+          'Authorization': 'Bearer test-token'
+        }
+      });
+
+      expect(response.status).toBe(500);
+    });
+  });
+
+  describe('POST /api/v1/files/multipart-policy', () => {
+    it('should generate multipart upload policy successfully', async () => {
+      const mockRequestData = {
+        name: 'large-file.zip',
+        type: 'application/zip',
+        size: 1024 * 1024 * 100, // 100MB
+        uploadUserId: 1,
+        partCount: 5
+      };
+
+      const mockResponse = {
+        file: {
+          id: 1,
+          ...mockRequestData,
+          path: '1/test-uuid-123-large-file.zip',
+          uploadTime: new Date(),
+          createdAt: new Date(),
+          updatedAt: new Date()
+        },
+        uploadId: 'upload-123',
+        uploadUrls: ['url1', 'url2', 'url3', 'url4', 'url5'],
+        bucket: 'd8dai',
+        key: '1/test-uuid-123-large-file.zip'
+      };
+
+      vi.mocked(mockFileService.createMultipartUploadPolicy).mockResolvedValue(mockResponse);
+
+      const response = await app.request('/api/v1/files/multipart-policy', {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          'Authorization': 'Bearer test-token'
+        },
+        body: JSON.stringify(mockRequestData)
+      });
+
+      expect(response.status).toBe(200);
+      const result = await response.json();
+      expect(result).toEqual(mockResponse);
+      expect(mockFileService.createMultipartUploadPolicy).toHaveBeenCalledWith(
+        {
+          name: 'large-file.zip',
+          type: 'application/zip',
+          size: 104857600,
+          uploadUserId: 1
+        },
+        5
+      );
+    });
+
+    it('should validate multipart policy request data', async () => {
+      const invalidData = {
+        name: 'test.zip',
+        // Missing required fields
+      };
+
+      const response = await app.request('/api/v1/files/multipart-policy', {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          'Authorization': 'Bearer test-token'
+        },
+        body: JSON.stringify(invalidData)
+      });
+
+      expect(response.status).toBe(400);
+    });
+  });
+
+  describe('POST /api/v1/files/multipart-complete', () => {
+    it('should complete multipart upload successfully', async () => {
+      const mockCompleteData = {
+        uploadId: 'upload-123',
+        bucket: 'd8dai',
+        key: '1/test-file.zip',
+        parts: [
+          { partNumber: 1, etag: 'etag1' },
+          { partNumber: 2, etag: 'etag2' }
+        ]
+      };
+
+      const mockResponse = {
+        fileId: 1,
+        url: 'https://minio.example.com/file.zip',
+        key: '1/test-file.zip',
+        size: 2048
+      };
+
+      vi.mocked(mockFileService.completeMultipartUpload).mockResolvedValue(mockResponse);
+
+      const response = await app.request('/api/v1/files/multipart-complete', {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          'Authorization': 'Bearer test-token'
+        },
+        body: JSON.stringify(mockCompleteData)
+      });
+
+      expect(response.status).toBe(200);
+      const result = await response.json();
+      expect(result).toEqual(mockResponse);
+      expect(mockFileService.completeMultipartUpload).toHaveBeenCalledWith(mockCompleteData);
+    });
+
+    it('should validate complete multipart request data', async () => {
+      const invalidData = {
+        uploadId: 'upload-123',
+        // Missing required fields
+      };
+
+      const response = await app.request('/api/v1/files/multipart-complete', {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          'Authorization': 'Bearer test-token'
+        },
+        body: JSON.stringify(invalidData)
+      });
+
+      expect(response.status).toBe(400);
+    });
+
+    it('should handle completion errors', async () => {
+      const completeData = {
+        uploadId: 'upload-123',
+        bucket: 'd8dai',
+        key: '1/test-file.zip',
+        parts: [{ partNumber: 1, etag: 'etag1' }]
+      };
+
+      vi.mocked(mockFileService.completeMultipartUpload).mockRejectedValue(new Error('Completion failed'));
+
+      const response = await app.request('/api/v1/files/multipart-complete', {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          'Authorization': 'Bearer test-token'
+        },
+        body: JSON.stringify(completeData)
+      });
+
+      expect(response.status).toBe(500);
+    });
+  });
+
+  describe('CRUD Operations', () => {
+    it('should list files successfully', async () => {
+      const mockFiles = [
+        {
+          id: 1,
+          name: 'file1.txt',
+          type: 'text/plain',
+          size: 1024,
+          uploadUserId: 1
+        },
+        {
+          id: 2,
+          name: 'file2.txt',
+          type: 'text/plain',
+          size: 2048,
+          uploadUserId: 1
+        }
+      ];
+
+      vi.spyOn(mockFileService, 'getAll').mockResolvedValue(mockFiles as File[]);
+
+      const response = await app.request('/api/v1/files', {
+        method: 'GET',
+        headers: {
+          'Authorization': 'Bearer test-token'
+        }
+      });
+
+      expect(response.status).toBe(200);
+      const result = await response.json();
+      expect(result).toEqual(mockFiles);
+    });
+
+    it('should get file by ID successfully', async () => {
+      const mockFile = {
+        id: 1,
+        name: 'file.txt',
+        type: 'text/plain',
+        size: 1024,
+        uploadUserId: 1
+      };
+
+      vi.spyOn(mockFileService, 'getById').mockResolvedValue(mockFile as File);
+
+      const response = await app.request('/api/v1/files/1', {
+        method: 'GET',
+        headers: {
+          'Authorization': 'Bearer test-token'
+        }
+      });
+
+      expect(response.status).toBe(200);
+      const result = await response.json();
+      expect(result).toEqual(mockFile);
+    });
+
+    it('should search files successfully', async () => {
+      const mockFiles = [
+        {
+          id: 1,
+          name: 'document.pdf',
+          type: 'application/pdf',
+          size: 1024,
+          uploadUserId: 1
+        }
+      ];
+
+      vi.spyOn(mockFileService, 'search').mockResolvedValue(mockFiles as File[]);
+
+      const response = await app.request('/api/v1/files?search=document', {
+        method: 'GET',
+        headers: {
+          'Authorization': 'Bearer test-token'
+        }
+      });
+
+      expect(response.status).toBe(200);
+      const result = await response.json();
+      expect(result).toEqual(mockFiles);
+      expect(mockFileService.search).toHaveBeenCalledWith('document', ['name', 'type', 'description']);
+    });
+  });
+});

+ 329 - 0
src/server/modules/files/__tests__/file.service.test.ts

@@ -0,0 +1,329 @@
+import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
+import { DataSource } from 'typeorm';
+import { FileService } from '../file.service';
+import { File } from '../file.entity';
+import { MinioService } from '../minio.service';
+import { logger } from '@/server/utils/logger';
+
+// Mock dependencies
+vi.mock('../minio.service');
+vi.mock('@/server/utils/logger');
+vi.mock('uuid', () => ({
+  v4: () => 'test-uuid-123'
+}));
+
+describe('FileService', () => {
+  let fileService: FileService;
+  let mockDataSource: DataSource;
+  let mockMinioService: MinioService;
+
+  beforeEach(() => {
+    mockDataSource = {
+      getRepository: vi.fn()
+    } as unknown as DataSource;
+
+    mockMinioService = new MinioService();
+    (MinioService as any).mockClear();
+
+    fileService = new FileService(mockDataSource);
+  });
+
+  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()
+      };
+
+      // Mock MinioService
+      vi.mocked(mockMinioService.generateUploadPolicy).mockResolvedValue(mockUploadPolicy);
+
+      // Mock GenericCrudService methods
+      vi.spyOn(fileService, 'create').mockResolvedValue(mockSavedFile as File);
+
+      const result = await fileService.createFile(mockFileData);
+
+      expect(mockMinioService.generateUploadPolicy).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
+      };
+
+      vi.mocked(mockMinioService.generateUploadPolicy).mockRejectedValue(new Error('MinIO error'));
+
+      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;
+
+      vi.spyOn(fileService, 'getById').mockResolvedValue(mockFile);
+      vi.mocked(mockMinioService.objectExists).mockResolvedValue(true);
+      vi.mocked(mockMinioService.deleteObject).mockResolvedValue(undefined);
+      vi.spyOn(fileService, 'delete').mockResolvedValue(undefined);
+
+      const result = await fileService.deleteFile(1);
+
+      expect(fileService.getById).toHaveBeenCalledWith(1);
+      expect(mockMinioService.objectExists).toHaveBeenCalledWith('d8dai', '1/test-file.txt');
+      expect(mockMinioService.deleteObject).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;
+
+      vi.spyOn(fileService, 'getById').mockResolvedValue(mockFile);
+      vi.mocked(mockMinioService.objectExists).mockResolvedValue(false);
+      vi.spyOn(fileService, 'delete').mockResolvedValue(undefined);
+
+      const result = await fileService.deleteFile(1);
+
+      expect(mockMinioService.deleteObject).not.toHaveBeenCalled();
+      expect(fileService.delete).toHaveBeenCalledWith(1);
+      expect(result).toBe(true);
+      expect(logger.error).toHaveBeenCalled();
+    });
+
+    it('should throw error when file not found', async () => {
+      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';
+
+      vi.spyOn(fileService, 'getById').mockResolvedValue(mockFile);
+      vi.mocked(mockMinioService.getPresignedFileUrl).mockResolvedValue(mockPresignedUrl);
+
+      const result = await fileService.getFileUrl(1);
+
+      expect(fileService.getById).toHaveBeenCalledWith(1);
+      expect(mockMinioService.getPresignedFileUrl).toHaveBeenCalledWith('d8dai', '1/test-file.txt');
+      expect(result).toBe(mockPresignedUrl);
+    });
+
+    it('should throw error when file not found', async () => {
+      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';
+
+      vi.spyOn(fileService, 'getById').mockResolvedValue(mockFile);
+      vi.mocked(mockMinioService.getPresignedFileDownloadUrl).mockResolvedValue(mockPresignedUrl);
+
+      const result = await fileService.getFileDownloadUrl(1);
+
+      expect(fileService.getById).toHaveBeenCalledWith(1);
+      expect(mockMinioService.getPresignedFileDownloadUrl).toHaveBeenCalledWith(
+        'd8dai',
+        '1/test-file.txt',
+        '测试文件.txt'
+      );
+      expect(result).toEqual({
+        url: mockPresignedUrl,
+        filename: '测试文件.txt'
+      });
+    });
+
+    it('should throw error when file not found', async () => {
+      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;
+
+      vi.mocked(mockMinioService.createMultipartUpload).mockResolvedValue(mockUploadId);
+      vi.mocked(mockMinioService.generateMultipartUploadUrls).mockResolvedValue(mockUploadUrls);
+      vi.spyOn(fileService, 'create').mockResolvedValue(mockSavedFile);
+
+      const result = await fileService.createMultipartUploadPolicy(mockFileData, 3);
+
+      expect(mockMinioService.createMultipartUpload).toHaveBeenCalledWith('d8dai', '1/test-uuid-123-large-file.zip');
+      expect(mockMinioService.generateMultipartUploadUrls).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
+      };
+
+      vi.mocked(mockMinioService.createMultipartUpload).mockRejectedValue(new Error('MinIO error'));
+
+      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';
+
+      vi.mocked(mockMinioService.completeMultipartUpload).mockResolvedValue(mockCompleteResult);
+      vi.mocked(mockMinioService.getFileUrl).mockReturnValue(mockFileUrl);
+      vi.spyOn(fileService.repository, 'findOneBy').mockResolvedValue(mockFile);
+      vi.spyOn(fileService.repository, 'save').mockResolvedValue(mockFile);
+
+      const result = await fileService.completeMultipartUpload(uploadData);
+
+      expect(mockMinioService.completeMultipartUpload).toHaveBeenCalledWith(
+        'd8dai',
+        '1/test-file.txt',
+        'upload-123',
+        [{ PartNumber: 1, ETag: 'etag1' }, { PartNumber: 2, ETag: 'etag2' }]
+      );
+      expect(fileService.repository.findOneBy).toHaveBeenCalledWith({ path: '1/test-file.txt' });
+      expect(fileService.repository.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' }]
+      };
+
+      vi.mocked(mockMinioService.completeMultipartUpload).mockResolvedValue({ size: 1024 });
+      vi.spyOn(fileService.repository, 'findOneBy').mockResolvedValue(null);
+
+      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' }]
+      };
+
+      vi.mocked(mockMinioService.completeMultipartUpload).mockRejectedValue(new Error('Completion failed'));
+
+      await expect(fileService.completeMultipartUpload(uploadData)).rejects.toThrow('完成分片上传失败');
+      expect(logger.error).toHaveBeenCalled();
+    });
+  });
+});

+ 422 - 0
src/server/modules/files/__tests__/minio.service.test.ts

@@ -0,0 +1,422 @@
+import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
+import { MinioService } from '../minio.service';
+import { Client } from 'minio';
+import { logger } from '@/server/utils/logger';
+
+// Mock dependencies
+vi.mock('minio');
+vi.mock('@/server/utils/logger');
+vi.mock('node:process', () => ({
+  default: {
+    env: {
+      MINIO_HOST: 'localhost',
+      MINIO_PORT: '9000',
+      MINIO_USE_SSL: 'false',
+      MINIO_ACCESS_KEY: 'minioadmin',
+      MINIO_SECRET_KEY: 'minioadmin',
+      MINIO_BUCKET_NAME: 'test-bucket'
+    }
+  }
+}));
+
+describe('MinioService', () => {
+  let minioService: MinioService;
+  let mockClient: Client;
+
+  beforeEach(() => {
+    mockClient = new Client({} as any);
+    (Client as any).mockClear();
+
+    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
+      vi.mocked(process.env).MINIO_USE_SSL = 'true';
+      vi.mocked(process.env).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(undefined);
+      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(undefined);
+      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');
+      error.message = 'The specified key does not exist';
+      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
+      );
+    });
+  });
+});

+ 310 - 0
tests/e2e/specs/admin/files.spec.ts

@@ -0,0 +1,310 @@
+import { test, expect } from '@playwright/test';
+import { faker } from '@faker-js/faker';
+
+test.describe('Admin File Management', () => {
+  test.beforeEach(async ({ page }) => {
+    // Login to admin panel
+    await page.goto('/admin/login');
+    await page.fill('input[name="username"]', 'admin');
+    await page.fill('input[name="password"]', 'password');
+    await page.click('button[type="submit"]');
+    await page.waitForURL('/admin/dashboard');
+
+    // Navigate to files page
+    await page.click('a[href="/admin/files"]');
+    await page.waitForURL('/admin/files');
+  });
+
+  test('should display files list', async ({ page }) => {
+    // Wait for files to load
+    await page.waitForSelector('[data-testid="files-table"]');
+
+    // Check if table headers are present
+    await expect(page.locator('th:has-text("文件名")')).toBeVisible();
+    await expect(page.locator('th:has-text("类型")')).toBeVisible();
+    await expect(page.locator('th:has-text("大小")')).toBeVisible();
+    await expect(page.locator('th:has-text("上传时间")')).toBeVisible();
+
+    // Check if at least one file is displayed (or empty state)
+    const filesCount = await page.locator('[data-testid="file-row"]').count();
+    if (filesCount === 0) {
+      await expect(page.locator('text=暂无文件')).toBeVisible();
+    } else {
+      await expect(page.locator('[data-testid="file-row"]').first()).toBeVisible();
+    }
+  });
+
+  test('should upload file successfully', async ({ page }) => {
+    // Click upload button
+    await page.click('button:has-text("上传文件")');
+
+    // Wait for upload modal
+    await page.waitForSelector('[data-testid="upload-modal"]');
+
+    // Create a test file
+    const testFileName = `test-${faker.string.alphanumeric(8)}.txt`;
+    const testFileContent = 'This is a test file content';
+
+    // Upload file
+    const fileInput = page.locator('input[type="file"]');
+    await fileInput.setInputFiles({
+      name: testFileName,
+      mimeType: 'text/plain',
+      buffer: Buffer.from(testFileContent)
+    });
+
+    // Fill optional fields
+    await page.fill('input[name="description"]', 'Test file description');
+
+    // Submit upload
+    await page.click('button:has-text("开始上传")');
+
+    // Wait for upload to complete
+    await expect(page.locator('text=上传成功')).toBeVisible({ timeout: 30000 });
+
+    // Verify file appears in list
+    await page.waitForSelector(`[data-testid="file-row"]:has-text("${testFileName}")`);
+    await expect(page.locator(`text=${testFileName}`)).toBeVisible();
+  });
+
+  test('should search files', async ({ page }) => {
+    // Assume there are some files already
+    await page.waitForSelector('[data-testid="files-table"]');
+
+    // Use search functionality
+    const searchTerm = 'document';
+    await page.fill('input[placeholder="搜索文件"]', searchTerm);
+    await page.keyboard.press('Enter');
+
+    // Wait for search results
+    await page.waitForLoadState('networkidle');
+
+    // Verify search results (either show results or no results message)
+    const results = await page.locator('[data-testid="file-row"]').count();
+    if (results === 0) {
+      await expect(page.locator('text=未找到相关文件')).toBeVisible();
+    } else {
+      // Check that all visible files contain search term in name or description
+      const fileRows = page.locator('[data-testid="file-row"]');
+      for (let i = 0; i < results; i++) {
+        const rowText = await fileRows.nth(i).textContent();
+        expect(rowText?.toLowerCase()).toContain(searchTerm.toLowerCase());
+      }
+    }
+  });
+
+  test('should download file', async ({ page }) => {
+    // Wait for files to load
+    await page.waitForSelector('[data-testid="file-row"]');
+
+    // Get first file row
+    const firstFile = page.locator('[data-testid="file-row"]').first();
+    const fileName = await firstFile.locator('[data-testid="file-name"]').textContent();
+
+    // Setup download tracking
+    const downloadPromise = page.waitForEvent('download');
+
+    // Click download button
+    await firstFile.locator('button:has-text("下载")').click();
+
+    // Wait for download to start
+    const download = await downloadPromise;
+
+    // Verify download filename
+    expect(download.suggestedFilename()).toContain(fileName?.trim() || '');
+  });
+
+  test('should delete file', async ({ page }) => {
+    // Wait for files to load
+    await page.waitForSelector('[data-testid="file-row"]');
+
+    // Get first file row
+    const firstFile = page.locator('[data-testid="file-row"]').first();
+    const fileName = await firstFile.locator('[data-testid="file-name"]').textContent();
+
+    // Click delete button
+    await firstFile.locator('button:has-text("删除")').click();
+
+    // Confirm deletion in dialog
+    await page.waitForSelector('[role="dialog"]');
+    await page.click('button:has-text("确认删除")');
+
+    // Wait for deletion to complete
+    await expect(page.locator('text=删除成功')).toBeVisible();
+
+    // Verify file is removed from list
+    await expect(page.locator(`text=${fileName}`)).not.toBeVisible({ timeout: 5000 });
+  });
+
+  test('should view file details', async ({ page }) => {
+    // Wait for files to load
+    await page.waitForSelector('[data-testid="file-row"]');
+
+    // Click view details on first file
+    await page.locator('[data-testid="file-row"]').first().locator('button:has-text("查看")').click();
+
+    // Wait for details modal
+    await page.waitForSelector('[data-testid="file-details-modal"]');
+
+    // Verify details are displayed
+    await expect(page.locator('[data-testid="file-name"]')).toBeVisible();
+    await expect(page.locator('[data-testid="file-size"]')).toBeVisible();
+    await expect(page.locator('[data-testid="file-type"]')).toBeVisible();
+    await expect(page.locator('[data-testid="upload-time"]')).toBeVisible();
+
+    // Close modal
+    await page.click('button[aria-label="Close"]');
+  });
+
+  test('should handle bulk operations', async ({ page }) => {
+    // Wait for files to load
+    await page.waitForSelector('[data-testid="file-row"]');
+
+    // Select multiple files
+    const checkboxes = page.locator('input[type="checkbox"][name="file-select"]');
+    const fileCount = await checkboxes.count();
+
+    if (fileCount >= 2) {
+      // Select first two files
+      await checkboxes.nth(0).check();
+      await checkboxes.nth(1).check();
+
+      // Verify bulk actions are visible
+      await expect(page.locator('button:has-text("批量下载")')).toBeVisible();
+      await expect(page.locator('button:has-text("批量删除")')).toBeVisible();
+
+      // Test bulk delete
+      await page.click('button:has-text("批量删除")');
+      await page.waitForSelector('[role="dialog"]');
+      await page.click('button:has-text("确认删除")');
+
+      await expect(page.locator('text=删除成功')).toBeVisible();
+    }
+  });
+
+  test('should handle file upload errors', async ({ page }) => {
+    // Click upload button
+    await page.click('button:has-text("上传文件")');
+    await page.waitForSelector('[data-testid="upload-modal"]');
+
+    // Try to upload without selecting file
+    await page.click('button:has-text("开始上传")');
+
+    // Should show validation error
+    await expect(page.locator('text=请选择要上传的文件')).toBeVisible();
+
+    // Close modal
+    await page.click('button[aria-label="Close"]');
+  });
+
+  test('should paginate files list', async ({ page }) => {
+    // Wait for files to load
+    await page.waitForSelector('[data-testid="files-table"]');
+
+    // Check if pagination exists
+    const pagination = page.locator('[data-testid="pagination"]');
+    if (await pagination.isVisible()) {
+      // Test next page
+      await page.click('button:has-text("下一页")');
+      await page.waitForLoadState('networkidle');
+
+      // Test previous page
+      await page.click('button:has-text("上一页")');
+      await page.waitForLoadState('networkidle');
+
+      // Test specific page
+      const pageButtons = page.locator('[data-testid="page-button"]');
+      if (await pageButtons.count() > 0) {
+        await pageButtons.nth(1).click(); // Click second page
+        await page.waitForLoadState('networkidle');
+      }
+    }
+  });
+
+  test('should filter files by type', async ({ page }) => {
+    // Wait for files to load
+    await page.waitForSelector('[data-testid="files-table"]');
+
+    // Open filter dropdown
+    await page.click('button:has-text("筛选")');
+    await page.waitForSelector('[role="menu"]');
+
+    // Filter by image type
+    await page.click('text=图片');
+    await page.waitForLoadState('networkidle');
+
+    // Verify only images are shown (or no results message)
+    const fileRows = page.locator('[data-testid="file-row"]');
+    const rowCount = await fileRows.count();
+
+    if (rowCount > 0) {
+      for (let i = 0; i < rowCount; i++) {
+        const fileType = await fileRows.nth(i).locator('[data-testid="file-type"]').textContent();
+        expect(fileType?.toLowerCase()).toMatch(/(image|jpg|jpeg|png|gif|webp)/);
+      }
+    } else {
+      await expect(page.locator('text=未找到图片文件')).toBeVisible();
+    }
+
+    // Clear filter
+    await page.click('button:has-text("清除筛选")');
+    await page.waitForLoadState('networkidle');
+  });
+
+  test('should sort files', async ({ page }) => {
+    // Wait for files to load
+    await page.waitForSelector('[data-testid="files-table"]');
+
+    // Test sorting by name
+    await page.click('th:has-text("文件名")');
+    await page.waitForLoadState('networkidle');
+
+    // Test sorting by size
+    await page.click('th:has-text("大小")');
+    await page.waitForLoadState('networkidle');
+
+    // Test sorting by upload time
+    await page.click('th:has-text("上传时间")');
+    await page.waitForLoadState('networkidle');
+  });
+});
+
+test.describe('File Management Accessibility', () => {
+  test('should be keyboard accessible', async ({ page }) => {
+    await page.goto('/admin/files');
+    await page.waitForSelector('[data-testid="files-table"]');
+
+    // Test tab navigation
+    await page.keyboard.press('Tab');
+    await expect(page.locator('button:has-text("上传文件")')).toBeFocused();
+
+    // Test keyboard operations on file rows
+    const firstFileRow = page.locator('[data-testid="file-row"]').first();
+    await firstFileRow.focus();
+    await page.keyboard.press('Enter');
+    await expect(page.locator('[data-testid="file-details-modal"]')).toBeVisible();
+
+    // Close modal with Escape
+    await page.keyboard.press('Escape');
+    await expect(page.locator('[data-testid="file-details-modal"]')).not.toBeVisible();
+  });
+
+  test('should have proper ARIA labels', async ({ page }) => {
+    await page.goto('/admin/files');
+    await page.waitForSelector('[data-testid="files-table"]');
+
+    // Check ARIA attributes
+    await expect(page.locator('[data-testid="files-table"]')).toHaveAttribute('role', 'grid');
+    await expect(page.locator('th')).toHaveAttribute('role', 'columnheader');
+    await expect(page.locator('[data-testid="file-row"]')).toHaveAttribute('role', 'row');
+
+    // Check button accessibility
+    const buttons = page.locator('button');
+    const buttonCount = await buttons.count();
+    for (let i = 0; i < Math.min(buttonCount, 5); i++) {
+      const button = buttons.nth(i);
+      const hasAriaLabel = await button.getAttribute('aria-label');
+      expect(hasAriaLabel).toBeTruthy();
+    }
+  });
+});