import { chunkUpload, getAllPendingUploads } from '@/lib/chunk-upload' import { getOptimalConcurrency } from '@/lib/chunk-upload-config' jest.mock('@/lib/chunk-upload-config', () => ({ getOptimalChunkSize: jest.fn().mockResolvedValue(1024 * 1024), getOptimalConcurrency: jest.fn().mockResolvedValue(1), getChunkTimeout: jest.fn(() => 30_000), })) type MockRequest = { result?: T error?: unknown onsuccess?: ((event?: { target: { result: T } }) => void) | null onerror?: ((event?: { target: { error: unknown } }) => void) | null onupgradeneeded?: ((event?: { target: { result: unknown } }) => void) | null } function createAsyncRequest(executor: () => T): MockRequest { const request: MockRequest = {} setTimeout(() => { try { request.result = executor() request.onsuccess?.({ target: { result: request.result as T } }) } catch (error) { request.error = error request.onerror?.({ target: { error } }) } }, 0) return request } function createIndexedDbMock(): IDBFactory { const records = new Map() let initialized = false const objectStore = { put: (value: { fileId: string }) => createAsyncRequest(() => { records.set(value.fileId, value) return value.fileId }), get: (key: string) => createAsyncRequest(() => records.get(key)), delete: (key: string) => createAsyncRequest(() => { records.delete(key) return undefined }), count: () => createAsyncRequest(() => records.size), getAll: () => createAsyncRequest(() => Array.from(records.values())), clear: () => createAsyncRequest(() => { records.clear() return undefined }), } const db = { objectStoreNames: { contains: () => initialized, }, createObjectStore: () => { initialized = true return objectStore }, transaction: () => ({ objectStore: () => objectStore, }), } return { open: jest.fn(() => { const request: MockRequest = {} setTimeout(() => { request.result = db if (!initialized) { request.onupgradeneeded?.({ target: { result: db } }) initialized = true } request.onsuccess?.({ target: { result: db } }) }, 0) return request as unknown as IDBOpenDBRequest }), } as unknown as IDBFactory } class MockXMLHttpRequest { static instances: MockXMLHttpRequest[] = [] static defaultStatus = 413 timeout = 0 status = MockXMLHttpRequest.defaultStatus responseText = '' upload = { onloadstart: null as (() => void) | null, onprogress: null as ((event: ProgressEvent) => void) | null, } onload: (() => void) | null = null onerror: (() => void) | null = null onabort: (() => void) | null = null ontimeout: (() => void) | null = null headers: Record = {} method = '' url = '' constructor() { MockXMLHttpRequest.instances.push(this) } open(method: string, url: string) { this.method = method this.url = url } setRequestHeader(name: string, value: string) { this.headers[name] = value } send() { setTimeout(() => { this.upload.onloadstart?.() this.onload?.() }, 0) } } describe('chunkUpload', () => { beforeEach(() => { jest.clearAllMocks() MockXMLHttpRequest.instances = [] MockXMLHttpRequest.defaultStatus = 413 Object.defineProperty(global, 'indexedDB', { configurable: true, writable: true, value: createIndexedDbMock(), }) Object.defineProperty(global, 'fetch', { configurable: true, writable: true, value: jest.fn(), }) Object.defineProperty(global, 'XMLHttpRequest', { configurable: true, writable: true, value: MockXMLHttpRequest, }) }) test('空文件会在初始化前被直接拦截', async () => { const emptyFile = new File([], 'empty.psd', { type: 'application/octet-stream' }) await expect(chunkUpload(emptyFile, { materialType: '任务-详情' })).rejects.toThrow('为空') expect(global.fetch).not.toHaveBeenCalled() await expect(getAllPendingUploads()).resolves.toEqual([]) }) test('upload/init 返回 400 时不再无限重试', async () => { ;(global.fetch as jest.Mock).mockResolvedValue({ ok: false, status: 400, json: async () => ({ error: '上传文件不能为空' }), }) const file = new File(['hello'], 'source.psb', { type: 'application/octet-stream' }) await expect( chunkUpload( file, { materialType: '任务-详情' }, { maxRetries: 5, retryDelay: 0 }, ), ).rejects.toThrow('上传文件不能为空') expect(global.fetch).toHaveBeenCalledTimes(1) await expect(getAllPendingUploads()).resolves.toEqual([]) }) test('upload/chunk 返回 413 时直接失败并清理会话', async () => { ;(global.fetch as jest.Mock) .mockResolvedValueOnce({ ok: true, json: async () => ({ uploadId: 'upload-too-large' }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ message: '清理成功' }), }) const file = new File(['hello'], 'source.psb', { type: 'application/octet-stream' }) await expect( chunkUpload( file, { materialType: '任务-详情' }, { maxRetries: 5, retryDelay: 0 }, ), ).rejects.toThrow('上传分片超过入口服务器限制') expect(MockXMLHttpRequest.instances).toHaveLength(1) expect(global.fetch).toHaveBeenCalledTimes(2) expect(global.fetch).toHaveBeenNthCalledWith( 2, '/api/materials/upload/cleanup', expect.objectContaining({ method: 'POST', body: JSON.stringify({ uploadId: 'upload-too-large' }), }), ) await expect(getAllPendingUploads()).resolves.toEqual([]) }) test('显式指定分片并发时不再读取网络自动并发配置', async () => { ;(global.fetch as jest.Mock) .mockResolvedValueOnce({ ok: true, json: async () => ({ uploadId: 'upload-explicit-concurrency' }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ message: '清理成功' }), }) const file = new File(['hello'], 'source.psb', { type: 'application/octet-stream' }) await expect( chunkUpload( file, { materialType: '任务-详情' }, { maxRetries: 1, retryDelay: 0, chunkConcurrency: 2 }, ), ).rejects.toThrow('上传分片超过入口服务器限制') expect(getOptimalConcurrency).not.toHaveBeenCalled() expect(MockXMLHttpRequest.instances).toHaveLength(1) }) test('断点续传沿用服务端既有分片布局,不受公网内网切换影响', async () => { ;(global.fetch as jest.Mock) .mockResolvedValueOnce({ ok: true, json: async () => ({ uploadId: 'upload-resume-layout' }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ message: '清理成功' }), }) const file = new File(['hello'], 'source.psb', { type: 'application/octet-stream' }) MockXMLHttpRequest.defaultStatus = 500 await expect(chunkUpload( file, { materialType: '任务-详情' }, { chunkSize: 2, chunkConcurrency: 1, maxRetries: 1, retryDelay: 0 }, )).rejects.toThrow('失败') await expect(getAllPendingUploads()).resolves.toEqual([ expect.objectContaining({ uploadId: 'upload-resume-layout', chunkSize: 2, totalChunks: 3, }), ]) MockXMLHttpRequest.defaultStatus = 413 await expect(chunkUpload( file, { materialType: '任务-详情' }, // 模拟网络探测从公网2字节布局切到内网4字节布局。 { chunkSize: 4, chunkConcurrency: 1, maxRetries: 1, retryDelay: 0 }, )).rejects.toThrow('上传分片超过入口服务器限制') const resumedRequest = MockXMLHttpRequest.instances[1] expect(resumedRequest.headers['X-Chunk-Size']).toBe('2') expect(resumedRequest.headers['X-Chunk-Index']).toBe('0') expect(global.fetch).toHaveBeenCalledTimes(2) }) test('特殊批次跳过 complete 时保留断点,建档确认后才删除', async () => { ;(global.fetch as jest.Mock).mockResolvedValueOnce({ ok: true, json: async () => ({ uploadId: 'upload-retained-special-batch' }), }) MockXMLHttpRequest.defaultStatus = 200 const file = new File(['hello'], 'brand-vi.pdf', { type: 'application/pdf' }) const result = await chunkUpload( file, { materialType: '品牌VI灵感' }, { chunkSize: 5, chunkConcurrency: 1, skipComplete: true, retainStateOnSkipComplete: true, }, ) await expect(getAllPendingUploads()).resolves.toEqual([ expect.objectContaining({ uploadId: 'upload-retained-special-batch', uploadedChunks: new Set([0]), }), ]) await result.confirmMaterialCreated() await expect(getAllPendingUploads()).resolves.toEqual([]) }) test('同一文件并发选择两次时拒绝第二条,避免共用 uploadId 覆盖分片', async () => { ;(global.fetch as jest.Mock).mockResolvedValue({ ok: false, status: 400, json: async () => ({ error: '测试结束首条上传' }), }) const file = new File(['hello'], 'duplicate.psb', { type: 'application/octet-stream', lastModified: 123456, }) const firstUpload = chunkUpload( file, { materialType: '任务-详情' }, { maxRetries: 1, retryDelay: 0 }, ) await expect(chunkUpload( file, { materialType: '任务-详情' }, { maxRetries: 1, retryDelay: 0 }, )).rejects.toThrow('正在上传,请勿在同一批次重复选择') await expect(firstUpload).rejects.toThrow('测试结束首条上传') expect(global.fetch).toHaveBeenCalledTimes(1) }) test('普通 complete 响应丢失时保留断点且不调用 destructive cleanup', async () => { ;(global.fetch as jest.Mock) .mockResolvedValueOnce({ ok: true, json: async () => ({ uploadId: 'upload-response-lost' }), }) .mockRejectedValueOnce(new TypeError('network connection lost')) MockXMLHttpRequest.defaultStatus = 200 const file = new File(['hello'], 'source.psb', { type: 'application/octet-stream' }) await expect(chunkUpload( file, { materialType: '3D场景' }, { chunkSize: 5, chunkConcurrency: 1, maxRetries: 1, retryDelay: 0, }, )).rejects.toThrow('已保留上传断点') await expect(getAllPendingUploads()).resolves.toEqual([ expect.objectContaining({ uploadId: 'upload-response-lost', uploadedChunks: new Set([0]), }), ]) expect(global.fetch).toHaveBeenCalledTimes(2) expect(global.fetch).not.toHaveBeenCalledWith( '/api/materials/upload/cleanup', expect.anything(), ) }) })