/** @jest-environment jsdom */ import { chunkUpload } from '@/lib/chunk-upload' import { completeBatchUpload, uploadBatchBuyerShow, uploadBatchVideo, uploadBatchXiaohongshu, } from './special-batch-uploaders' import { compressPreviewImage, compressVideoPreviewImage } from './image-processor' jest.mock('@/lib/chunk-upload', () => ({ chunkUpload: jest.fn(), })) jest.mock('./image-processor', () => ({ compressPreviewImage: jest.fn(), compressVideoPreviewImage: jest.fn(), })) function createDeferred() { let resolve!: (value: T) => void const promise = new Promise((resolvePromise) => { resolve = resolvePromise }) return { promise, resolve } } describe('uploadBatchVideo preview image contract', () => { const originalFetch = global.fetch afterEach(() => { global.fetch = originalFetch jest.clearAllMocks() }) test('将每条视频的选填预览图传给完成接口', async () => { const sourcePreview = new File(['source-preview'], 'cover.png', { type: 'image/png' }) const compressedPreview = new File(['compressed-preview'], 'cover.jpg', { type: 'image/jpeg' }) ;(compressVideoPreviewImage as jest.Mock).mockResolvedValue(compressedPreview) ;(chunkUpload as jest.Mock).mockResolvedValue({ uploadId: 'upload-video-1' }) const fetchMock = jest.fn() .mockResolvedValueOnce({ ok: true, json: async () => ({ existingFilePath: 'uploads/temp/upload-video-1/merged-video.mp4', fileName: 'video.mp4', }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ material: { id: 'material-video-1' } }), }) global.fetch = fetchMock as unknown as typeof fetch const results = await uploadBatchVideo( [{ file: new File(['video'], 'video.mp4', { type: 'video/mp4' }), productModel: 'SPU-VIDEO-001', previewImage: sourcePreview, }], '', '', '', jest.fn(), ) expect(results[0].status).toBe('processing') expect(compressVideoPreviewImage).toHaveBeenCalledWith(sourcePreview) const uploadFormData = fetchMock.mock.calls[1][1].body as FormData expect(uploadFormData.get('previewImage')).toBeInstanceOf(File) }) test('逐条完成视频处理后才开始下一条,避免并发解码预览图', async () => { const firstUploadResponse = createDeferred() ;(chunkUpload as jest.Mock) .mockResolvedValueOnce({ uploadId: 'upload-video-1' }) .mockResolvedValueOnce({ uploadId: 'upload-video-2' }) const fetchMock = jest.fn() .mockResolvedValueOnce({ ok: true, json: async () => ({ existingFilePath: 'uploads/video-1.mp4', fileName: 'video-1.mp4' }), }) .mockReturnValueOnce(firstUploadResponse.promise) .mockResolvedValueOnce({ ok: true, json: async () => ({ existingFilePath: 'uploads/video-2.mp4', fileName: 'video-2.mp4' }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ material: { id: 'material-2' } }) }) global.fetch = fetchMock as unknown as typeof fetch const uploadPromise = uploadBatchVideo( [ { file: new File(['video-1'], 'video-1.mp4'), productModel: 'SPU-1' }, { file: new File(['video-2'], 'video-2.mp4'), productModel: 'SPU-2' }, ], '', '', '', jest.fn(), ) for (let index = 0; index < 10 && fetchMock.mock.calls.length < 2; index += 1) { await Promise.resolve() } expect(chunkUpload).toHaveBeenCalledTimes(1) firstUploadResponse.resolve({ ok: true, json: async () => ({ material: { id: 'material-1' } }) }) const results = await uploadPromise expect(chunkUpload).toHaveBeenCalledTimes(2) expect(results.map((result) => result.status)).toEqual(['processing', 'processing']) }) test('已选择预览图但 base64 无法解析时中止创建素材', async () => { const fetchMock = jest.fn().mockResolvedValueOnce({ ok: true, json: async () => ({ existingFilePath: 'uploads/video.mp4', fileName: 'video.mp4' }), }) global.fetch = fetchMock as unknown as typeof fetch await expect(completeBatchUpload('upload-video-invalid-preview', { materialType: '淘系-逛逛视频', productModel: 'SPU-INVALID', fileName: 'video.mp4', previewImage: 'invalid-preview-data', })).rejects.toThrow('预览图数据无法解析') expect(fetchMock).toHaveBeenCalledTimes(1) }) }) describe('image-only batch upload memory boundary', () => { const originalFetch = global.fetch beforeEach(() => { ;(compressPreviewImage as jest.Mock).mockImplementation(async (file: File) => file) }) afterEach(() => { global.fetch = originalFetch jest.clearAllMocks() }) test('买家秀必须等上一批请求完成后才提交下一批', async () => { const firstResponse = createDeferred() const fetchMock = jest.fn() .mockReturnValueOnce(firstResponse.promise) .mockResolvedValueOnce({ ok: true, json: async () => ({ material: { id: 'buyer-2' } }), }) global.fetch = fetchMock as unknown as typeof fetch const progress: number[] = [] const uploadPromise = uploadBatchBuyerShow( [ { productModel: 'BUYER-1', images: [new File(['one'], 'one.jpg', { type: 'image/jpeg' })] }, { productModel: 'BUYER-2', images: [new File(['two'], 'two.jpg', { type: 'image/jpeg' })] }, ], '', '', '', '', (value) => progress.push(value), ) for (let index = 0; index < 10 && fetchMock.mock.calls.length < 1; index += 1) { await Promise.resolve() } expect(fetchMock).toHaveBeenCalledTimes(1) firstResponse.resolve({ ok: true, json: async () => ({ material: { id: 'buyer-1' } }), }) const results = await uploadPromise expect(fetchMock).toHaveBeenCalledTimes(2) expect(results.map((item) => item.productModel)).toEqual(['BUYER-1', 'BUYER-2']) expect(results.map((item) => item.status)).toEqual(['processing', 'processing']) expect(progress).toEqual([50, 100]) }) test('小红书某一批失败后仍按顺序继续下一批', async () => { const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined) const fetchMock = jest.fn() .mockResolvedValueOnce({ ok: false, status: 413, json: async () => ({ error: '图片批次过大' }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ material: { id: 'redbook-2' } }), }) global.fetch = fetchMock as unknown as typeof fetch const results = await uploadBatchXiaohongshu( [ { productModel: 'RED-1', images: [new File(['one'], 'one.jpg', { type: 'image/jpeg' })] }, { productModel: 'RED-2', images: [new File(['two'], 'two.jpg', { type: 'image/jpeg' })] }, ], '', '', '', '', jest.fn(), ) expect(fetchMock).toHaveBeenCalledTimes(2) expect(results.map((item) => item.productModel)).toEqual(['RED-1', 'RED-2']) expect(results.map((item) => item.status)).toEqual(['failed', 'processing']) consoleError.mockRestore() }) })