/** @jest-environment jsdom */ import { chunkUpload } from '@/lib/chunk-upload' import { completeBatchUpload, uploadBatch3D, uploadBatchBrandVI, uploadBatchMusic, } from './special-batch-uploaders' jest.mock('@/lib/chunk-upload', () => ({ chunkUpload: jest.fn(), })) type FileItem = { file: File; productModel: string } type BatchResult = { productModel: string; status: string } type RunBatch = (files: FileItem[]) => Promise function flushMicrotasks(count = 10) { return (async () => { for (let index = 0; index < count; index += 1) { await Promise.resolve() } })() } async function assertBoundedFilePool( runBatch: RunBatch, expectedStatus = 'processing', expectedConcurrency = 3, ) { const files = Array.from({ length: 10 }, (_, index) => ({ file: new File([`file-${index}`], `file-${index}.bin`, { type: 'application/octet-stream' }), productModel: `SPU-${index}`, })) let chunkCallCount = 0 ;(chunkUpload as jest.Mock).mockImplementation(async () => ({ uploadId: `upload-${chunkCallCount++}`, confirmMaterialCreated: jest.fn().mockResolvedValue(undefined), })) let activeCompleteRequests = 0 let maxActiveCompleteRequests = 0 let completeCallCount = 0 const materialCreateUploadIds: string[] = [] const pendingCompleteResponses: Array<{ uploadId: string resolve: (response: unknown) => void }> = [] const fetchMock = jest.fn((url: string, init?: RequestInit) => { if (url === '/api/materials/upload/complete') { const body = JSON.parse(String(init?.body)) as { uploadId: string } completeCallCount += 1 activeCompleteRequests += 1 maxActiveCompleteRequests = Math.max(maxActiveCompleteRequests, activeCompleteRequests) return new Promise((resolve) => { pendingCompleteResponses.push({ uploadId: body.uploadId, resolve: (response) => { activeCompleteRequests -= 1 resolve(response) }, }) }) } if (url === '/api/materials/upload') { materialCreateUploadIds.push(String((init?.body as FormData).get('uploadId'))) return Promise.resolve({ ok: true, status: 200, json: async () => ({ material: { id: 'material-id' } }), }) } return Promise.reject(new Error(`unexpected URL: ${url}`)) }) global.fetch = fetchMock as unknown as typeof fetch const batchPromise = runBatch(files) await flushMicrotasks(20) expect(chunkUpload).toHaveBeenCalledTimes(expectedConcurrency) expect(completeCallCount).toBe(expectedConcurrency) expect(maxActiveCompleteRequests).toBeLessThanOrEqual(expectedConcurrency) let safety = 0 while ((completeCallCount < files.length || pendingCompleteResponses.length > 0) && safety < 100) { const pending = pendingCompleteResponses.shift() if (pending) { pending.resolve({ ok: true, status: 200, json: async () => ({ existingFilePath: `uploads/${pending.uploadId}.bin`, fileName: `${pending.uploadId}.bin`, }), }) } await flushMicrotasks(10) safety += 1 } expect(completeCallCount).toBe(files.length) const results = await batchPromise expect(results).toHaveLength(files.length) expect(results.map((result) => result.productModel)).toEqual(files.map((file) => file.productModel)) expect(results.every((result) => result.status === expectedStatus)).toBe(true) expect(activeCompleteRequests).toBe(0) expect(maxActiveCompleteRequests).toBe(expectedConcurrency) expect(materialCreateUploadIds).toEqual( Array.from({ length: files.length }, (_, index) => `upload-${index}`), ) } describe('特殊批量上传的文件级并发池', () => { const originalFetch = global.fetch let consoleLog: jest.SpyInstance let consoleWarn: jest.SpyInstance let consoleError: jest.SpyInstance beforeEach(() => { jest.clearAllMocks() consoleLog = jest.spyOn(console, 'log').mockImplementation(() => undefined) consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => undefined) consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined) }) afterEach(() => { global.fetch = originalFetch consoleLog.mockRestore() consoleWarn.mockRestore() consoleError.mockRestore() }) test('音乐 10 个文件最多同时处理 3 个 complete 请求', async () => { await assertBoundedFilePool((files) => uploadBatchMusic(files, '', '', jest.fn())) }) test('品牌 VI 10 个文件严格逐个处理,避免 PDF 解码与服务端排队叠加', async () => { await assertBoundedFilePool( (files) => uploadBatchBrandVI(files, '', jest.fn()), 'processing', 1, ) }) test('3D 场景 10 个文件也最多同时处理 3 个 complete 请求', async () => { await assertBoundedFilePool( (files) => uploadBatch3D(files, '', '', '', '', jest.fn()), 'success', ) }) test('complete 遇到 503 时用同一 uploadId 重试并恢复', async () => { const completeBodies: Array<{ uploadId: string; originalFileName: string }> = [] const fetchMock = jest.fn((url: string, init?: RequestInit) => { if (url === '/api/materials/upload/complete') { completeBodies.push(JSON.parse(String(init?.body))) if (completeBodies.length === 1) { return Promise.resolve({ ok: false, status: 503, headers: { get: () => '0' }, text: async () => 'temporarily busy', }) } return Promise.resolve({ ok: true, status: 200, json: async () => ({ existingFilePath: 'uploads/recovered.mp3', fileName: 'recovered.mp3', }), }) } return Promise.resolve({ ok: true, status: 200, json: async () => ({ material: { id: 'material-recovered' } }), }) }) global.fetch = fetchMock as unknown as typeof fetch const result = await completeBatchUpload('upload-same-id', { materialType: '音乐', productModel: 'SPU-RECOVER', fileName: 'song.mp3', }) expect(result.material.id).toBe('material-recovered') expect(completeBodies).toHaveLength(2) expect(completeBodies[0].uploadId).toBe('upload-same-id') expect(completeBodies[1].uploadId).toBe('upload-same-id') expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ '/api/materials/upload/complete', '/api/materials/upload/complete', '/api/materials/upload', ]) }) test('complete 遇到磁盘空间不足 507 时不重试放大压力', async () => { const fetchMock = jest.fn().mockResolvedValue({ ok: false, status: 507, headers: { get: () => null }, text: async () => 'disk full', }) global.fetch = fetchMock as unknown as typeof fetch await expect( completeBatchUpload('upload-storage-low', { materialType: '音乐', productModel: 'SPU-LOW', fileName: 'song.mp3', }), ).rejects.toThrow('分片合并失败: 507') expect(fetchMock).toHaveBeenCalledTimes(1) }) test('第二阶段响应丢失时只查 owner-scoped 状态,不盲目再次 POST', async () => { const fetchMock = jest.fn((url: string, init?: RequestInit) => { if (url === '/api/materials/upload/complete') { return Promise.resolve({ ok: true, status: 200, json: async () => ({ existingFilePath: 'uploads/temp/upload-response-lost/merged-song.mp3', fileName: 'song.mp3', }), }) } if (url === '/api/materials/upload' && init?.method === 'POST') { const formData = init.body as FormData expect(formData.get('uploadId')).toBe('upload-response-lost') return Promise.reject(new TypeError('connection closed after request')) } if (url === '/api/materials/upload?uploadId=upload-response-lost') { return Promise.resolve({ ok: true, status: 200, json: async () => ({ status: 'completed', material: { id: 'material-already-created' }, }), }) } return Promise.reject(new Error(`unexpected URL: ${url}`)) }) global.fetch = fetchMock as unknown as typeof fetch const result = await completeBatchUpload('upload-response-lost', { materialType: '音乐', productModel: 'SPU-LOST', fileName: 'song.mp3', }) expect(result.material.id).toBe('material-already-created') expect(fetchMock.mock.calls.filter(([url, init]) => ( url === '/api/materials/upload' && (init as RequestInit | undefined)?.method === 'POST' ))).toHaveLength(1) expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ '/api/materials/upload/complete', '/api/materials/upload', '/api/materials/upload?uploadId=upload-response-lost', ]) }) test('临时目录已删的断点重放通过状态确认,不再创建第二份素材', async () => { const fetchMock = jest.fn((url: string) => { if (url === '/api/materials/upload/complete') { return Promise.resolve({ ok: false, status: 404, text: async () => 'session missing', }) } if (url === '/api/materials/upload?uploadId=upload-temp-deleted') { return Promise.resolve({ ok: true, status: 200, json: async () => ({ status: 'completed', material: { id: 'material-existing' }, }), }) } return Promise.reject(new Error(`unexpected URL: ${url}`)) }) global.fetch = fetchMock as unknown as typeof fetch const result = await completeBatchUpload('upload-temp-deleted', { materialType: '品牌VI灵感', productModel: 'SPU-EXISTING', fileName: 'brand.pdf', }) expect(result.material.id).toBe('material-existing') expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ '/api/materials/upload/complete', '/api/materials/upload?uploadId=upload-temp-deleted', ]) }) })