gongxiangp2/components/upload/special-batch-uploaders.video-preview.test.ts
chenpei 7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00

220 lines
7.1 KiB
TypeScript

/** @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<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((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<any>()
;(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<any>()
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()
})
})