files.integration.test.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652
  1. import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
  2. import { testClient } from 'hono/testing';
  3. import { FileService } from '@/server/modules/files/file.service';
  4. import { authMiddleware } from '@/server/middleware/auth.middleware';
  5. import { fileApiRoutes } from '@/server/api';
  6. import { ConcreteCrudService } from '@/server/utils/concrete-crud.service';
  7. vi.mock('@/server/modules/files/file.service');
  8. vi.mock('@/server/middleware/auth.middleware');
  9. vi.mock('@/server/data-source');
  10. vi.mock('@/server/utils/concrete-crud.service');
  11. describe('File API Integration Tests', () => {
  12. let client: ReturnType<typeof testClient<typeof fileApiRoutes>>['api']['v1'];
  13. const user1 = {
  14. id: 1,
  15. username: 'testuser',
  16. password: 'password123',
  17. phone: null,
  18. email: null,
  19. nickname: null,
  20. name: null,
  21. avatarFileId: null,
  22. avatarFile: null,
  23. isDisabled: 0,
  24. isDeleted: 0,
  25. roles: [],
  26. createdAt: new Date(),
  27. updatedAt: new Date()
  28. };
  29. const user1Response = {
  30. ...user1,
  31. createdAt: (user1.createdAt).toISOString(),
  32. updatedAt: (user1.updatedAt).toISOString()
  33. }
  34. beforeEach(async () => {
  35. vi.clearAllMocks();
  36. // Mock auth middleware to bypass authentication
  37. vi.mocked(authMiddleware).mockImplementation(async (c, next) => {
  38. const authHeader = c.req.header('Authorization');
  39. if (!authHeader) {
  40. return c.json({ message: 'Authorization header missing' }, 401);
  41. }
  42. c.set('user', user1)
  43. await next();
  44. });
  45. client = testClient(fileApiRoutes).api.v1;
  46. });
  47. afterEach(() => {
  48. vi.clearAllMocks();
  49. });
  50. describe('POST /api/v1/files/upload-policy', () => {
  51. it('should generate upload policy successfully', async () => {
  52. const mockFileData = {
  53. name: 'test.txt',
  54. type: 'text/plain',
  55. size: 1024,
  56. path: '/uploads/test.txt',
  57. description: 'Test file',
  58. uploadUserId: 1
  59. };
  60. const mockResponse = {
  61. file: {
  62. id: 1,
  63. ...mockFileData,
  64. path: '1/test-uuid-123-test.txt',
  65. uploadTime: (new Date()).toISOString(),
  66. createdAt: (new Date()).toISOString(),
  67. updatedAt: (new Date()).toISOString(),
  68. fullUrl: 'https://minio.example.com/d8dai/1/test-uuid-123-test.txt',
  69. uploadUser: user1Response,
  70. lastUpdated: null
  71. },
  72. uploadPolicy: {
  73. 'x-amz-algorithm': 'AWS4-HMAC-SHA256',
  74. 'x-amz-credential': 'test-credential',
  75. 'x-amz-date': '20250101T120000Z',
  76. 'x-amz-security-token': 'test-token',
  77. policy: 'test-policy',
  78. 'x-amz-signature': 'test-signature',
  79. host: 'https://minio.example.com',
  80. key: '1/test-uuid-123-test.txt',
  81. bucket: 'd8dai'
  82. }
  83. };
  84. const mockCreateFile = vi.fn().mockResolvedValue(mockResponse);
  85. vi.mocked(FileService).mockImplementation(() => ({
  86. createFile: mockCreateFile
  87. } as unknown as FileService));
  88. const response = await client.files['upload-policy'].$post({
  89. json: mockFileData
  90. },
  91. {
  92. headers: {
  93. 'Authorization': 'Bearer test-token'
  94. }
  95. });
  96. if (response.status !== 200) {
  97. const error = await response.json();
  98. console.debug('Error response:', JSON.stringify(error, null, 2));
  99. console.debug('Response status:', response.status);
  100. }
  101. expect(response.status).toBe(200);
  102. const result = await response.json();
  103. expect(result).toEqual(mockResponse);
  104. expect(mockCreateFile).toHaveBeenCalledWith({
  105. ...mockFileData,
  106. uploadTime: expect.any(Date),
  107. uploadUserId: 1
  108. });
  109. });
  110. it('should return 400 for invalid request data', async () => {
  111. const invalidData = {
  112. name: '', // Empty name
  113. type: 'text/plain'
  114. };
  115. const response = await client.files['upload-policy'].$post({
  116. json: invalidData as any
  117. },
  118. {
  119. headers: {
  120. 'Authorization': 'Bearer test-token'
  121. }
  122. });
  123. expect(response.status).toBe(400);
  124. });
  125. it('should handle service errors gracefully', async () => {
  126. const mockFileData = {
  127. name: 'test.txt',
  128. type: 'text/plain',
  129. path: '/uploads/test.txt',
  130. uploadUserId: 1
  131. };
  132. const mockCreateFile = vi.fn().mockRejectedValue(new Error('Service error'));
  133. vi.mocked(FileService).mockImplementation(() => ({
  134. createFile: mockCreateFile
  135. } as unknown as FileService));
  136. const response = await client.files['upload-policy'].$post({
  137. json: mockFileData as any
  138. },
  139. {
  140. headers: {
  141. 'Authorization': 'Bearer test-token'
  142. }
  143. });
  144. expect(response.status).toBe(500);
  145. });
  146. });
  147. describe('GET /api/v1/files/{id}/url', () => {
  148. it('should generate file access URL successfully', async () => {
  149. const mockUrl = 'https://minio.example.com/presigned-url';
  150. const mockGetFileUrl = vi.fn().mockResolvedValue(mockUrl);
  151. vi.mocked(FileService).mockImplementation(() => ({
  152. getFileUrl: mockGetFileUrl
  153. } as unknown as FileService));
  154. const response = await client.files[':id']['url'].$get({
  155. param: { id: 1 }
  156. },
  157. {
  158. headers: {
  159. 'Authorization': 'Bearer test-token'
  160. }
  161. });
  162. expect(response.status).toBe(200);
  163. const result = await response.json();
  164. expect(result).toEqual({ url: mockUrl });
  165. });
  166. it('should return 404 when file not found', async () => {
  167. const mockGetFileUrl = vi.fn().mockRejectedValue(new Error('文件不存在'));
  168. vi.mocked(FileService).mockImplementation(() => ({
  169. getFileUrl: mockGetFileUrl
  170. } as unknown as FileService));
  171. const response = await client.files[':id']['url'].$get({
  172. param: { id: 999 }
  173. },
  174. {
  175. headers: {
  176. 'Authorization': 'Bearer test-token'
  177. }
  178. });
  179. expect(response.status).toBe(404);
  180. });
  181. });
  182. describe('GET /api/v1/files/{id}/download', () => {
  183. it('should generate file download URL successfully', async () => {
  184. const mockDownloadInfo = {
  185. url: 'https://minio.example.com/download-url',
  186. filename: 'test.txt'
  187. };
  188. const mockGetFileDownloadUrl = vi.fn().mockResolvedValue(mockDownloadInfo);
  189. vi.mocked(FileService).mockImplementation(() => ({
  190. getFileDownloadUrl: mockGetFileDownloadUrl
  191. } as unknown as FileService));
  192. const response = await client.files[':id']['download'].$get({
  193. param: { id: 1 }
  194. },
  195. {
  196. headers: {
  197. 'Authorization': 'Bearer test-token'
  198. }
  199. });
  200. expect(response.status).toBe(200);
  201. const result = await response.json();
  202. expect(result).toEqual(mockDownloadInfo);
  203. expect(mockGetFileDownloadUrl).toHaveBeenCalledWith(1);
  204. });
  205. it('should return 404 when file not found for download', async () => {
  206. const mockGetFileDownloadUrl = vi.fn().mockRejectedValue(new Error('文件不存在'));
  207. vi.mocked(FileService).mockImplementation(() => ({
  208. getFileDownloadUrl: mockGetFileDownloadUrl
  209. } as unknown as FileService));
  210. const response = await client.files[':id']['download'].$get({
  211. param: { id: 999 }
  212. },
  213. {
  214. headers: {
  215. 'Authorization': 'Bearer test-token'
  216. }
  217. });
  218. expect(response.status).toBe(404);
  219. });
  220. });
  221. describe('DELETE /api/v1/files/{id}', () => {
  222. it('should delete file successfully', async () => {
  223. const mockDeleteFile = vi.fn().mockResolvedValue(true);
  224. vi.mocked(FileService).mockImplementation(() => ({
  225. deleteFile: mockDeleteFile
  226. } as unknown as FileService));
  227. const response = await client.files[':id'].$delete({
  228. param: { id: 1 }
  229. },
  230. {
  231. headers: {
  232. 'Authorization': 'Bearer test-token'
  233. }
  234. });
  235. expect(response.status).toBe(200);
  236. const result = await response.json();
  237. expect(result).toEqual({ success: true, message: '文件删除成功' });
  238. expect(mockDeleteFile).toHaveBeenCalledWith(1);
  239. });
  240. it('should return 404 when file not found for deletion', async () => {
  241. const mockDeleteFile = vi.fn().mockRejectedValue(new Error('文件不存在'));
  242. vi.mocked(FileService).mockImplementation(() => ({
  243. deleteFile: mockDeleteFile
  244. } as unknown as FileService));
  245. const response = await client.files[':id'].$delete({
  246. param: { id: 999 }
  247. },
  248. {
  249. headers: {
  250. 'Authorization': 'Bearer test-token'
  251. }
  252. });
  253. expect(response.status).toBe(404);
  254. });
  255. it('should handle deletion errors', async () => {
  256. const mockDeleteFile = vi.fn().mockRejectedValue(new Error('删除失败'));
  257. vi.mocked(FileService).mockImplementation(() => ({
  258. deleteFile: mockDeleteFile
  259. } as unknown as FileService));
  260. const response = await client.files[':id'].$delete({
  261. param: { id: 1 }
  262. },
  263. {
  264. headers: {
  265. 'Authorization': 'Bearer test-token'
  266. }
  267. });
  268. expect(response.status).toBe(500);
  269. });
  270. });
  271. describe('POST /api/v1/files/multipart-policy', () => {
  272. it('should generate multipart upload policy successfully', async () => {
  273. const mockRequestData = {
  274. fileKey: 'large-file.zip',
  275. totalSize: 1024 * 1024 * 100, // 100MB
  276. partSize: 1024 * 1024 * 20, // 20MB
  277. name: 'large-file.zip',
  278. type: 'application/zip',
  279. uploadUserId: 1
  280. };
  281. const mockServiceResponse = {
  282. file: {
  283. id: 1,
  284. name: 'large-file.zip',
  285. type: 'application/zip',
  286. size: 104857600,
  287. uploadUserId: 1,
  288. path: '1/test-uuid-123-large-file.zip',
  289. description: null,
  290. uploadTime: new Date(),
  291. lastUpdated: null,
  292. createdAt: new Date(),
  293. updatedAt: new Date(),
  294. fullUrl: Promise.resolve('https://minio.example.com/d8dai/1/test-uuid-123-large-file.zip')
  295. },
  296. uploadId: 'upload-123',
  297. uploadUrls: ['url1', 'url2', 'url3', 'url4', 'url5'],
  298. bucket: 'd8dai',
  299. key: '1/test-uuid-123-large-file.zip'
  300. };
  301. const mockCreateMultipartUploadPolicy = vi.fn().mockResolvedValue(mockServiceResponse);
  302. vi.mocked(FileService).mockImplementation(() => ({
  303. createMultipartUploadPolicy: mockCreateMultipartUploadPolicy
  304. } as unknown as FileService));
  305. const response = await client.files['multipart-policy'].$post({
  306. json: mockRequestData
  307. },
  308. {
  309. headers: {
  310. 'Authorization': 'Bearer test-token'
  311. }
  312. });
  313. expect(response.status).toBe(200);
  314. const result = await response.json();
  315. expect(result).toEqual({
  316. uploadId: 'upload-123',
  317. bucket: 'd8dai',
  318. key: '1/test-uuid-123-large-file.zip',
  319. host: 'http://undefined:undefined',
  320. partUrls: ['url1', 'url2', 'url3', 'url4', 'url5']
  321. });
  322. expect(mockCreateMultipartUploadPolicy).toHaveBeenCalledWith(
  323. {
  324. fileKey: 'large-file.zip',
  325. totalSize: 104857600,
  326. partSize: 20971520,
  327. name: 'large-file.zip',
  328. type: 'application/zip',
  329. uploadUserId: 1
  330. },
  331. 5
  332. );
  333. });
  334. it('should validate multipart policy request data', async () => {
  335. const invalidData = {
  336. name: 'test.zip'
  337. // Missing required fields: fileKey, totalSize, partSize
  338. };
  339. const response = await client.files['multipart-policy'].$post({
  340. json: invalidData as any
  341. },
  342. {
  343. headers: {
  344. 'Authorization': 'Bearer test-token'
  345. }
  346. });
  347. expect(response.status).toBe(400);
  348. });
  349. });
  350. describe('POST /api/v1/files/multipart-complete', () => {
  351. it('should complete multipart upload successfully', async () => {
  352. const mockCompleteData = {
  353. uploadId: 'upload-123',
  354. bucket: 'd8dai',
  355. key: '1/test-file.zip',
  356. parts: [
  357. { partNumber: 1, etag: 'etag1' },
  358. { partNumber: 2, etag: 'etag2' }
  359. ]
  360. };
  361. const mockResponse = {
  362. fileId: 1,
  363. url: 'https://minio.example.com/file.zip',
  364. key: '1/test-file.zip',
  365. size: 2048,
  366. host: 'http://undefined:undefined',
  367. bucket: 'd8dai'
  368. };
  369. const mockCompleteMultipartUpload = vi.fn().mockResolvedValue(mockResponse);
  370. vi.mocked(FileService).mockImplementation(() => ({
  371. completeMultipartUpload: mockCompleteMultipartUpload
  372. } as unknown as FileService));
  373. const response = await client.files['multipart-complete'].$post({
  374. json: mockCompleteData
  375. },
  376. {
  377. headers: {
  378. 'Authorization': 'Bearer test-token'
  379. }
  380. });
  381. expect(response.status).toBe(200);
  382. const result = await response.json();
  383. expect(result).toEqual(mockResponse);
  384. expect(mockCompleteMultipartUpload).toHaveBeenCalledWith(mockCompleteData);
  385. });
  386. it('should validate complete multipart request data', async () => {
  387. const invalidData = {
  388. uploadId: 'upload-123',
  389. // Missing required fields: bucket, key, parts
  390. };
  391. const response = await client.files['multipart-complete'].$post({
  392. json: invalidData as any
  393. },
  394. {
  395. headers: {
  396. 'Authorization': 'Bearer test-token'
  397. }
  398. });
  399. expect(response.status).toBe(400);
  400. });
  401. it('should handle completion errors', async () => {
  402. const completeData = {
  403. uploadId: 'upload-123',
  404. bucket: 'd8dai',
  405. key: '1/test-file.zip',
  406. parts: [{ partNumber: 1, etag: 'etag1' }]
  407. };
  408. const mockCompleteMultipartUpload = vi.fn().mockRejectedValue(new Error('Completion failed'));
  409. vi.mocked(FileService).mockImplementation(() => ({
  410. completeMultipartUpload: mockCompleteMultipartUpload
  411. } as unknown as FileService));
  412. const response = await client.files['multipart-complete'].$post({
  413. json: completeData
  414. },
  415. {
  416. headers: {
  417. 'Authorization': 'Bearer test-token'
  418. }
  419. });
  420. expect(response.status).toBe(500);
  421. });
  422. });
  423. describe('CRUD Operations', () => {
  424. it('should list files successfully', async () => {
  425. const mockFiles = [
  426. {
  427. id: 1,
  428. name: 'file1.txt',
  429. type: 'text/plain',
  430. size: 1024,
  431. path: '/uploads/file1.txt',
  432. fullUrl: 'https://minio.example.com/d8dai/uploads/file1.txt',
  433. description: null,
  434. uploadUserId: 1,
  435. uploadUser: user1Response,
  436. uploadTime: new Date(),
  437. lastUpdated: null,
  438. createdAt: new Date(),
  439. updatedAt: new Date()
  440. },
  441. {
  442. id: 2,
  443. name: 'file2.txt',
  444. type: 'text/plain',
  445. size: 2048,
  446. path: '/uploads/file2.txt',
  447. fullUrl: 'https://minio.example.com/d8dai/uploads/file2.txt',
  448. description: null,
  449. uploadUserId: 1,
  450. uploadUser: user1Response,
  451. uploadTime: new Date(),
  452. lastUpdated: null,
  453. createdAt: new Date(),
  454. updatedAt: new Date()
  455. }
  456. ];
  457. // 设置ConcreteCrudService的mock返回数据
  458. vi.mocked(ConcreteCrudService).mockImplementation(() => ({
  459. getList: vi.fn().mockResolvedValue([mockFiles, mockFiles.length])
  460. } as unknown as ConcreteCrudService<any>));
  461. const response = await client.files.$get({
  462. query: {}
  463. },
  464. {
  465. headers: {
  466. 'Authorization': 'Bearer test-token'
  467. }
  468. });
  469. if (response.status !== 200) {
  470. const error = await response.json();
  471. console.debug('Error response:', JSON.stringify(error, null, 2));
  472. console.debug('Response status:', response.status);
  473. }
  474. expect(response.status).toBe(200);
  475. const result = await response.json();
  476. expect(result).toEqual({
  477. data: mockFiles.map(file => ({
  478. ...file,
  479. createdAt: file.createdAt.toISOString(),
  480. updatedAt: file.updatedAt.toISOString(),
  481. uploadTime: file.uploadTime.toISOString()
  482. })),
  483. pagination: {
  484. current: 1,
  485. pageSize: 10,
  486. total: mockFiles.length
  487. }
  488. });
  489. });
  490. it('should get file by ID successfully', async () => {
  491. const mockFile = {
  492. id: 1,
  493. name: 'file.txt',
  494. type: 'text/plain',
  495. size: 1024,
  496. path: '/uploads/file.txt',
  497. fullUrl: 'https://minio.example.com/d8dai/uploads/file.txt',
  498. description: null,
  499. uploadUserId: 1,
  500. uploadUser: user1Response,
  501. uploadTime: new Date(),
  502. lastUpdated: null,
  503. createdAt: new Date(),
  504. updatedAt: new Date()
  505. };
  506. // 设置ConcreteCrudService的mock返回数据
  507. vi.mocked(ConcreteCrudService).mockImplementation(() => ({
  508. getById: vi.fn().mockResolvedValue(mockFile)
  509. } as unknown as ConcreteCrudService<any>));
  510. const response = await client.files[':id'].$get({
  511. param: { id: 1 }
  512. },
  513. {
  514. headers: {
  515. 'Authorization': 'Bearer test-token'
  516. }
  517. });
  518. if (response.status !== 200) {
  519. const error = await response.json();
  520. console.debug('Error response:', JSON.stringify(error, null, 2));
  521. console.debug('Response status:', response.status);
  522. }
  523. expect(response.status).toBe(200);
  524. const result = await response.json();
  525. expect(result).toEqual({
  526. ...mockFile,
  527. createdAt: mockFile.createdAt.toISOString(),
  528. updatedAt: mockFile.updatedAt.toISOString(),
  529. uploadTime: mockFile.uploadTime.toISOString()
  530. });
  531. });
  532. it('should search files successfully', async () => {
  533. const mockFiles = [
  534. {
  535. id: 1,
  536. name: 'document.pdf',
  537. type: 'application/pdf',
  538. size: 1024,
  539. path: '/uploads/document.pdf',
  540. fullUrl: 'https://minio.example.com/d8dai/uploads/document.pdf',
  541. description: null,
  542. uploadUserId: 1,
  543. uploadUser: user1Response,
  544. uploadTime: new Date(),
  545. lastUpdated: null,
  546. createdAt: new Date(),
  547. updatedAt: new Date()
  548. }
  549. ];
  550. // 设置ConcreteCrudService的mock返回数据
  551. vi.mocked(ConcreteCrudService).mockImplementation(() => ({
  552. getList: vi.fn().mockResolvedValue([mockFiles, mockFiles.length])
  553. } as unknown as ConcreteCrudService<any>));
  554. const response = await client.files.$get({
  555. query: { keyword: 'document' }
  556. },
  557. {
  558. headers: {
  559. 'Authorization': 'Bearer test-token'
  560. }
  561. });
  562. expect(response.status).toBe(200);
  563. const result = await response.json();
  564. expect(result).toEqual({
  565. data: mockFiles.map(file => ({
  566. ...file,
  567. createdAt: file.createdAt.toISOString(),
  568. updatedAt: file.updatedAt.toISOString(),
  569. uploadTime: file.uploadTime.toISOString()
  570. })),
  571. pagination: {
  572. current: 1,
  573. pageSize: 10,
  574. total: mockFiles.length
  575. }
  576. });
  577. expect(vi.mocked(ConcreteCrudService).mock.results[0].value.getList).toHaveBeenCalledWith(1, 10, 'document', ['name', 'type', 'description'], undefined, ['uploadUser'], { id: 'DESC' }, undefined);
  578. });
  579. });
  580. });