minio.service.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422
  1. import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
  2. import { MinioService } from '../minio.service';
  3. import { Client } from 'minio';
  4. import { logger } from '@/server/utils/logger';
  5. // Mock dependencies
  6. vi.mock('minio');
  7. vi.mock('@/server/utils/logger');
  8. vi.mock('node:process', () => ({
  9. default: {
  10. env: {
  11. MINIO_HOST: 'localhost',
  12. MINIO_PORT: '9000',
  13. MINIO_USE_SSL: 'false',
  14. MINIO_ACCESS_KEY: 'minioadmin',
  15. MINIO_SECRET_KEY: 'minioadmin',
  16. MINIO_BUCKET_NAME: 'test-bucket'
  17. }
  18. }
  19. }));
  20. describe('MinioService', () => {
  21. let minioService: MinioService;
  22. let mockClient: Client;
  23. beforeEach(() => {
  24. mockClient = new Client({} as any);
  25. (Client as any).mockClear();
  26. minioService = new MinioService();
  27. });
  28. afterEach(() => {
  29. vi.clearAllMocks();
  30. });
  31. describe('constructor', () => {
  32. it('should initialize with correct configuration', () => {
  33. expect(Client).toHaveBeenCalledWith({
  34. endPoint: 'localhost',
  35. port: 9000,
  36. useSSL: false,
  37. accessKey: 'minioadmin',
  38. secretKey: 'minioadmin'
  39. });
  40. expect(minioService.bucketName).toBe('test-bucket');
  41. });
  42. });
  43. describe('setPublicReadPolicy', () => {
  44. it('should set public read policy successfully', async () => {
  45. const mockPolicy = JSON.stringify({
  46. Version: '2012-10-17',
  47. Statement: [
  48. {
  49. Effect: 'Allow',
  50. Principal: { AWS: '*' },
  51. Action: ['s3:GetObject'],
  52. Resource: ['arn:aws:s3:::test-bucket/*']
  53. },
  54. {
  55. Effect: 'Allow',
  56. Principal: { AWS: '*' },
  57. Action: ['s3:ListBucket'],
  58. Resource: ['arn:aws:s3:::test-bucket']
  59. }
  60. ]
  61. });
  62. vi.mocked(mockClient.setBucketPolicy).mockResolvedValue(undefined);
  63. await minioService.setPublicReadPolicy();
  64. expect(mockClient.setBucketPolicy).toHaveBeenCalledWith('test-bucket', mockPolicy);
  65. expect(logger.db).toHaveBeenCalledWith('Bucket policy set to public read for: test-bucket');
  66. });
  67. it('should handle errors when setting policy', async () => {
  68. const error = new Error('Policy error');
  69. vi.mocked(mockClient.setBucketPolicy).mockRejectedValue(error);
  70. await expect(minioService.setPublicReadPolicy()).rejects.toThrow(error);
  71. expect(logger.error).toHaveBeenCalledWith('Failed to set bucket policy for test-bucket:', error);
  72. });
  73. });
  74. describe('ensureBucketExists', () => {
  75. it('should create bucket if not exists', async () => {
  76. vi.mocked(mockClient.bucketExists).mockResolvedValue(false);
  77. vi.mocked(mockClient.makeBucket).mockResolvedValue(undefined);
  78. vi.spyOn(minioService, 'setPublicReadPolicy').mockResolvedValue(undefined);
  79. const result = await minioService.ensureBucketExists();
  80. expect(mockClient.bucketExists).toHaveBeenCalledWith('test-bucket');
  81. expect(mockClient.makeBucket).toHaveBeenCalledWith('test-bucket');
  82. expect(minioService.setPublicReadPolicy).toHaveBeenCalledWith('test-bucket');
  83. expect(result).toBe(true);
  84. expect(logger.db).toHaveBeenCalledWith('Created new bucket: test-bucket');
  85. });
  86. it('should return true if bucket already exists', async () => {
  87. vi.mocked(mockClient.bucketExists).mockResolvedValue(true);
  88. const result = await minioService.ensureBucketExists();
  89. expect(mockClient.bucketExists).toHaveBeenCalledWith('test-bucket');
  90. expect(mockClient.makeBucket).not.toHaveBeenCalled();
  91. expect(result).toBe(true);
  92. });
  93. it('should handle errors during bucket check', async () => {
  94. const error = new Error('Bucket check failed');
  95. vi.mocked(mockClient.bucketExists).mockRejectedValue(error);
  96. await expect(minioService.ensureBucketExists()).rejects.toThrow(error);
  97. expect(logger.error).toHaveBeenCalledWith('Failed to ensure bucket exists: test-bucket', error);
  98. });
  99. });
  100. describe('generateUploadPolicy', () => {
  101. it('should generate upload policy successfully', async () => {
  102. const fileKey = 'test-file.txt';
  103. const mockPolicy = {
  104. setBucket: vi.fn(),
  105. setKey: vi.fn(),
  106. setExpires: vi.fn()
  107. };
  108. const mockFormData = {
  109. 'x-amz-algorithm': 'AWS4-HMAC-SHA256',
  110. 'x-amz-credential': 'credential',
  111. 'x-amz-date': '20250101T120000Z',
  112. policy: 'policy-string',
  113. 'x-amz-signature': 'signature'
  114. };
  115. vi.spyOn(minioService, 'ensureBucketExists').mockResolvedValue(true);
  116. vi.mocked(mockClient.newPostPolicy).mockReturnValue(mockPolicy as any);
  117. vi.mocked(mockClient.presignedPostPolicy).mockResolvedValue({
  118. postURL: 'https://minio.example.com',
  119. formData: mockFormData
  120. });
  121. const result = await minioService.generateUploadPolicy(fileKey);
  122. expect(minioService.ensureBucketExists).toHaveBeenCalled();
  123. expect(mockClient.newPostPolicy).toHaveBeenCalled();
  124. expect(mockPolicy.setBucket).toHaveBeenCalledWith('test-bucket');
  125. expect(mockPolicy.setKey).toHaveBeenCalledWith(fileKey);
  126. expect(mockPolicy.setExpires).toHaveBeenCalledWith(expect.any(Date));
  127. expect(mockClient.presignedPostPolicy).toHaveBeenCalledWith(mockPolicy);
  128. expect(result).toEqual({
  129. 'x-amz-algorithm': 'AWS4-HMAC-SHA256',
  130. 'x-amz-credential': 'credential',
  131. 'x-amz-date': '20250101T120000Z',
  132. 'x-amz-security-token': undefined,
  133. policy: 'policy-string',
  134. 'x-amz-signature': 'signature',
  135. host: 'https://minio.example.com',
  136. key: fileKey,
  137. bucket: 'test-bucket'
  138. });
  139. });
  140. });
  141. describe('getFileUrl', () => {
  142. it('should generate correct file URL without SSL', () => {
  143. const url = minioService.getFileUrl('test-bucket', 'file.txt');
  144. expect(url).toBe('http://localhost:9000/test-bucket/file.txt');
  145. });
  146. it('should generate correct file URL with SSL', async () => {
  147. // Create new instance with SSL
  148. vi.mocked(process.env).MINIO_USE_SSL = 'true';
  149. vi.mocked(process.env).MINIO_PORT = '443';
  150. const sslService = new MinioService();
  151. const url = sslService.getFileUrl('test-bucket', 'file.txt');
  152. expect(url).toBe('https://localhost:443/test-bucket/file.txt');
  153. });
  154. });
  155. describe('getPresignedFileUrl', () => {
  156. it('should generate presigned URL successfully', async () => {
  157. const mockUrl = 'https://minio.example.com/presigned-url';
  158. vi.mocked(mockClient.presignedGetObject).mockResolvedValue(mockUrl);
  159. const result = await minioService.getPresignedFileUrl('test-bucket', 'file.txt', 3600);
  160. expect(mockClient.presignedGetObject).toHaveBeenCalledWith('test-bucket', 'file.txt', 3600);
  161. expect(result).toBe(mockUrl);
  162. expect(logger.db).toHaveBeenCalledWith(
  163. 'Generated presigned URL for test-bucket/file.txt, expires in 3600s'
  164. );
  165. });
  166. it('should handle errors during URL generation', async () => {
  167. const error = new Error('URL generation failed');
  168. vi.mocked(mockClient.presignedGetObject).mockRejectedValue(error);
  169. await expect(minioService.getPresignedFileUrl('test-bucket', 'file.txt')).rejects.toThrow(error);
  170. expect(logger.error).toHaveBeenCalledWith(
  171. 'Failed to generate presigned URL for test-bucket/file.txt:',
  172. error
  173. );
  174. });
  175. });
  176. describe('getPresignedFileDownloadUrl', () => {
  177. it('should generate download URL with content disposition', async () => {
  178. const mockUrl = 'https://minio.example.com/download-url';
  179. vi.mocked(mockClient.presignedGetObject).mockResolvedValue(mockUrl);
  180. const result = await minioService.getPresignedFileDownloadUrl(
  181. 'test-bucket',
  182. 'file.txt',
  183. '测试文件.txt',
  184. 1800
  185. );
  186. expect(mockClient.presignedGetObject).toHaveBeenCalledWith(
  187. 'test-bucket',
  188. 'file.txt',
  189. 1800,
  190. {
  191. 'response-content-disposition': 'attachment; filename="%E6%B5%8B%E8%AF%95%E6%96%87%E4%BB%B6.txt"',
  192. 'response-content-type': 'application/octet-stream'
  193. }
  194. );
  195. expect(result).toBe(mockUrl);
  196. expect(logger.db).toHaveBeenCalledWith(
  197. 'Generated presigned download URL for test-bucket/file.txt, filename: 测试文件.txt'
  198. );
  199. });
  200. });
  201. describe('createMultipartUpload', () => {
  202. it('should create multipart upload successfully', async () => {
  203. const mockUploadId = 'upload-123';
  204. vi.mocked(mockClient.initiateNewMultipartUpload).mockResolvedValue(mockUploadId);
  205. const result = await minioService.createMultipartUpload('test-bucket', 'large-file.zip');
  206. expect(mockClient.initiateNewMultipartUpload).toHaveBeenCalledWith(
  207. 'test-bucket',
  208. 'large-file.zip',
  209. {}
  210. );
  211. expect(result).toBe(mockUploadId);
  212. expect(logger.db).toHaveBeenCalledWith(
  213. 'Created multipart upload for large-file.zip with ID: upload-123'
  214. );
  215. });
  216. it('should handle errors during multipart upload creation', async () => {
  217. const error = new Error('Upload creation failed');
  218. vi.mocked(mockClient.initiateNewMultipartUpload).mockRejectedValue(error);
  219. await expect(minioService.createMultipartUpload('test-bucket', 'file.zip')).rejects.toThrow(error);
  220. expect(logger.error).toHaveBeenCalledWith(
  221. 'Failed to create multipart upload for file.zip:',
  222. error
  223. );
  224. });
  225. });
  226. describe('generateMultipartUploadUrls', () => {
  227. it('should generate multipart upload URLs', async () => {
  228. const mockUrls = ['url1', 'url2', 'url3'];
  229. vi.mocked(mockClient.presignedUrl)
  230. .mockResolvedValueOnce('url1')
  231. .mockResolvedValueOnce('url2')
  232. .mockResolvedValueOnce('url3');
  233. const result = await minioService.generateMultipartUploadUrls(
  234. 'test-bucket',
  235. 'large-file.zip',
  236. 'upload-123',
  237. 3
  238. );
  239. expect(mockClient.presignedUrl).toHaveBeenCalledTimes(3);
  240. expect(mockClient.presignedUrl).toHaveBeenNthCalledWith(
  241. 1,
  242. 'put',
  243. 'test-bucket',
  244. 'large-file.zip',
  245. 3600,
  246. { uploadId: 'upload-123', partNumber: '1' }
  247. );
  248. expect(result).toEqual(mockUrls);
  249. });
  250. });
  251. describe('completeMultipartUpload', () => {
  252. it('should complete multipart upload successfully', async () => {
  253. const parts = [
  254. { ETag: 'etag1', PartNumber: 1 },
  255. { ETag: 'etag2', PartNumber: 2 }
  256. ];
  257. const mockStat = { size: 2048 };
  258. vi.mocked(mockClient.completeMultipartUpload).mockResolvedValue(undefined);
  259. vi.mocked(mockClient.statObject).mockResolvedValue(mockStat as any);
  260. const result = await minioService.completeMultipartUpload(
  261. 'test-bucket',
  262. 'large-file.zip',
  263. 'upload-123',
  264. parts
  265. );
  266. expect(mockClient.completeMultipartUpload).toHaveBeenCalledWith(
  267. 'test-bucket',
  268. 'large-file.zip',
  269. 'upload-123',
  270. [{ part: 1, etag: 'etag1' }, { part: 2, etag: 'etag2' }]
  271. );
  272. expect(mockClient.statObject).toHaveBeenCalledWith('test-bucket', 'large-file.zip');
  273. expect(result).toEqual({ size: 2048 });
  274. expect(logger.db).toHaveBeenCalledWith(
  275. 'Completed multipart upload for large-file.zip with ID: upload-123'
  276. );
  277. });
  278. it('should handle errors during completion', async () => {
  279. const error = new Error('Completion failed');
  280. vi.mocked(mockClient.completeMultipartUpload).mockRejectedValue(error);
  281. await expect(minioService.completeMultipartUpload(
  282. 'test-bucket',
  283. 'file.zip',
  284. 'upload-123',
  285. [{ ETag: 'etag1', PartNumber: 1 }]
  286. )).rejects.toThrow(error);
  287. expect(logger.error).toHaveBeenCalledWith(
  288. 'Failed to complete multipart upload for file.zip:',
  289. error
  290. );
  291. });
  292. });
  293. describe('createObject', () => {
  294. it('should create object successfully', async () => {
  295. const fileContent = Buffer.from('test content');
  296. const mockUrl = 'http://localhost:9000/test-bucket/file.txt';
  297. vi.spyOn(minioService, 'ensureBucketExists').mockResolvedValue(true);
  298. vi.mocked(mockClient.putObject).mockResolvedValue(undefined);
  299. vi.spyOn(minioService, 'getFileUrl').mockReturnValue(mockUrl);
  300. const result = await minioService.createObject(
  301. 'test-bucket',
  302. 'file.txt',
  303. fileContent,
  304. 'text/plain'
  305. );
  306. expect(minioService.ensureBucketExists).toHaveBeenCalledWith('test-bucket');
  307. expect(mockClient.putObject).toHaveBeenCalledWith(
  308. 'test-bucket',
  309. 'file.txt',
  310. fileContent,
  311. fileContent.length,
  312. { 'Content-Type': 'text/plain' }
  313. );
  314. expect(result).toBe(mockUrl);
  315. expect(logger.db).toHaveBeenCalledWith('Created object: test-bucket/file.txt');
  316. });
  317. });
  318. describe('objectExists', () => {
  319. it('should return true when object exists', async () => {
  320. vi.mocked(mockClient.statObject).mockResolvedValue({} as any);
  321. const result = await minioService.objectExists('test-bucket', 'file.txt');
  322. expect(result).toBe(true);
  323. });
  324. it('should return false when object not found', async () => {
  325. const error = new Error('Object not found');
  326. error.message = 'The specified key does not exist';
  327. vi.mocked(mockClient.statObject).mockRejectedValue(error);
  328. const result = await minioService.objectExists('test-bucket', 'nonexistent.txt');
  329. expect(result).toBe(false);
  330. });
  331. it('should rethrow other errors', async () => {
  332. const error = new Error('Permission denied');
  333. vi.mocked(mockClient.statObject).mockRejectedValue(error);
  334. await expect(minioService.objectExists('test-bucket', 'file.txt')).rejects.toThrow(error);
  335. expect(logger.error).toHaveBeenCalledWith(
  336. 'Error checking existence of object test-bucket/file.txt:',
  337. error
  338. );
  339. });
  340. });
  341. describe('deleteObject', () => {
  342. it('should delete object successfully', async () => {
  343. vi.mocked(mockClient.removeObject).mockResolvedValue(undefined);
  344. await minioService.deleteObject('test-bucket', 'file.txt');
  345. expect(mockClient.removeObject).toHaveBeenCalledWith('test-bucket', 'file.txt');
  346. expect(logger.db).toHaveBeenCalledWith('Deleted object: test-bucket/file.txt');
  347. });
  348. it('should handle errors during deletion', async () => {
  349. const error = new Error('Deletion failed');
  350. vi.mocked(mockClient.removeObject).mockRejectedValue(error);
  351. await expect(minioService.deleteObject('test-bucket', 'file.txt')).rejects.toThrow(error);
  352. expect(logger.error).toHaveBeenCalledWith(
  353. 'Failed to delete object test-bucket/file.txt:',
  354. error
  355. );
  356. });
  357. });
  358. });