Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
220 lines
7.1 KiB
TypeScript
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()
|
|
})
|
|
})
|