import { act, createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ProductPackageUploader, {
selectTaskDetailFilesWithinLimits,
stripTrailingImageExtensions,
} from '@/components/upload/ProductPackageUploader'
import { PerformanceSurveyBrowserImageError } from '@/lib/performance-survey-image-error'
import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy'
const mockChunkUpload = jest.fn()
const mockPrepareTaskDetailImagePreview = jest.fn()
const mockPrepareTaskDetailSelectionThumbnail = jest.fn()
const mockPollTaskDetailUploadJob = jest.fn()
jest.mock('@/lib/chunk-upload', () => ({
chunkUpload: (...args: unknown[]) => mockChunkUpload(...args),
}))
jest.mock('@/lib/task-detail-image-browser', () => ({
prepareTaskDetailImagePreview: (...args: unknown[]) => mockPrepareTaskDetailImagePreview(...args),
prepareTaskDetailSelectionThumbnail: (...args: unknown[]) => mockPrepareTaskDetailSelectionThumbnail(...args),
}))
jest.mock('@/lib/task-detail-upload-status', () => ({
pollTaskDetailUploadJob: (...args: unknown[]) => mockPollTaskDetailUploadJob(...args),
TaskDetailUploadJobError: class TaskDetailUploadJobError extends Error {
jobStatus = 'failed'
},
}))
jest.mock('@/components/upload/DetailImageSorter', () => ({
__esModule: true,
default: () =>
,
}))
jest.mock('@/components/upload/UploadForm', () => ({
__esModule: true,
default: ({
onProductModelChange,
onCategoryChange,
}: {
onProductModelChange: (value: string) => void
onCategoryChange: (value: string) => void
}) => (
),
}))
jest.mock('@/components/upload/category-handlers', () => ({
addCategory: jest.fn(),
addScene: jest.fn(),
}))
const defaultProps = {
onSuccess: jest.fn(),
onCancel: jest.fn(),
categories: [],
scenes: [],
platforms: [],
productCategories: [],
onRefreshCategories: jest.fn().mockResolvedValue(undefined),
onRefreshScenes: jest.fn().mockResolvedValue(undefined),
}
function createDeferred() {
let resolve!: (value: T | PromiseLike) => void
let reject!: (reason?: unknown) => void
const promise = new Promise((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve, reject }
}
function usersResponse() {
return {
ok: true,
json: async () => [{ id: 'requester-1', username: 'operator-a', displayName: '运营甲' }],
}
}
async function fillRequiredTaskFields(
user: ReturnType,
files: File | File[],
) {
const requesterInput = await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
await user.click(requesterInput)
await user.type(requesterInput, '运营甲')
await user.click(screen.getByRole('option', { name: /运营甲/ }))
await user.click(screen.getByRole('button', { name: '设置测试 SPU' }))
await user.click(screen.getByRole('button', { name: '设置测试店铺' }))
await user.selectOptions(screen.getByLabelText(/出图方式/), '实拍')
await user.upload(screen.getByLabelText('主图'), files)
}
const capabilityError = () => new PerformanceSurveyBrowserImageError(
'BROWSER_DECODE_UNAVAILABLE',
'当前 Edge 环境无法解码这张合法图片',
)
function thumbnailResult(sourceName: string) {
return {
file: new File(['thumbnail'], `${sourceName}-thumbnail.webp`, { type: 'image/webp' }),
mimeType: 'image/webp' as const,
width: 320,
height: 240,
kind: 'preview' as const,
sourceName,
sourceIndex: 0,
sliceIndex: 0,
sliceCount: 1,
}
}
function installUploadFlowFetch(completeResponse?: Promise) {
const fetchMock = jest.fn((input: RequestInfo | URL) => {
const url = String(input)
if (url === '/api/users/list') return Promise.resolve(usersResponse())
if (url === '/api/materials/upload/product-package/init') {
return Promise.resolve({ ok: true, json: async () => ({ data: { materialId: 'material-1' } }) })
}
if (url === '/api/materials/upload/product-package/complete' && completeResponse) {
return completeResponse
}
if (url === '/api/materials/upload/product-package/abort') {
return Promise.resolve({ ok: true, json: async () => ({ success: true }) })
}
return Promise.reject(new Error(`测试未处理请求:${url}`))
})
global.fetch = fetchMock as unknown as typeof fetch
return fetchMock
}
describe('ProductPackageUploader', () => {
const originalFetch = global.fetch
beforeEach(() => {
jest.clearAllMocks()
mockChunkUpload.mockResolvedValue({ uploadId: 'upload-original-1' })
mockPrepareTaskDetailSelectionThumbnail.mockImplementation((file: File) => (
Promise.resolve(thumbnailResult(file.name))
))
mockPollTaskDetailUploadJob.mockResolvedValue({
status: 'completed',
progress: 100,
stage: '处理完成',
})
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => [
{
id: 'requester-1',
username: 'operator-a',
displayName: '运营甲',
},
],
}) as jest.Mock
let objectUrlIndex = 0
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
writable: true,
value: jest.fn(() => `blob:mock-${++objectUrlIndex}`),
})
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
writable: true,
value: jest.fn(),
})
})
afterEach(() => {
jest.restoreAllMocks()
})
afterAll(() => {
global.fetch = originalFetch
})
test('任务-详情源文件选择框支持 psb/tif/tiff', async () => {
const user = userEvent.setup()
render()
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
const input = screen.getByLabelText(/PSD源文件/) as HTMLInputElement
expect(input.accept).toBe('.psd,.psb,.tif,.tiff,.zip,.rar,.7z')
const files = [
new File(['a'], 'sample.psb', { type: 'application/octet-stream' }),
new File(['b'], 'sample.tif', { type: 'image/tiff' }),
new File(['c'], 'sample.tiff', { type: 'image/tiff' }),
]
await user.upload(input, files)
expect(screen.getByText('sample.psb')).toBeInTheDocument()
expect(screen.getByText('sample.tif')).toBeInTheDocument()
expect(screen.getByText('sample.tiff')).toBeInTheDocument()
})
test('空的任务-详情源文件会被直接拦截并提示', async () => {
const user = userEvent.setup()
render()
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
const input = screen.getByLabelText(/PSD源文件/) as HTMLInputElement
const emptyFile = new File([], 'empty.psd', { type: 'application/octet-stream' })
await user.upload(input, emptyFile)
expect(screen.getByText('以下文件为空,无法上传:empty.psd')).toBeInTheDocument()
expect(screen.queryByText('empty.psd')).not.toBeInTheDocument()
})
test('应提供任务详情出图方式选择项,且默认为必填', async () => {
render()
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
const select = screen.getByLabelText(/出图方式/) as HTMLSelectElement
expect(select.value).toBe('')
expect(select).toBeRequired()
expect(screen.getByRole('option', { name: '请选择出图方式' })).toBeInTheDocument()
expect(screen.getByRole('option', { name: '实拍' })).toBeInTheDocument()
expect(screen.getByRole('option', { name: '渲染' })).toBeInTheDocument()
expect(screen.getByRole('option', { name: 'AI' })).toBeInTheDocument()
})
test('应提供必填的需求运营搜索框并允许选择人员', async () => {
const user = userEvent.setup()
render()
const input = await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
await user.click(input)
await user.type(input, '运营甲')
await user.click(screen.getByRole('option', { name: /运营甲/ }))
expect(input).toHaveValue('运营甲 (operator-a)')
expect(screen.getByText('记录发起本次出图需求的运营人员,与素材上传人可以不同。')).toBeInTheDocument()
})
test('未选择需求运营时提交应被前端拦截', async () => {
const user = userEvent.setup()
render()
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
await user.click(screen.getByRole('button', { name: '提交' }))
expect(screen.getByText('请选择需求运营人员')).toBeInTheDocument()
expect(global.fetch).toHaveBeenCalledTimes(1)
})
test('文件经过主图上传框内部按钮时应保持拖拽高亮', async () => {
render()
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
const input = screen.getByLabelText('主图') as HTMLInputElement
const dropZone = input.parentElement as HTMLDivElement
const addButton = dropZone.querySelector('button')
expect(addButton).not.toBeNull()
Object.defineProperty(dropZone, 'getBoundingClientRect', {
configurable: true,
value: () => ({
x: 0,
y: 0,
top: 0,
right: 100,
bottom: 100,
left: 0,
width: 100,
height: 100,
toJSON: () => ({}),
}),
})
fireEvent.dragEnter(dropZone, {
clientX: 20,
clientY: 20,
dataTransfer: { files: [], types: ['Files'] },
})
expect(dropZone).toHaveClass('border-blue-500')
const internalDragLeave = createEvent.dragLeave(dropZone)
Object.defineProperties(internalDragLeave, {
clientX: { value: 50 },
clientY: { value: 50 },
relatedTarget: { value: addButton },
dataTransfer: { value: { files: [], types: ['Files'] } },
})
fireEvent(dropZone, internalDragLeave)
expect(dropZone).toHaveClass('border-blue-500')
const outerDragLeave = createEvent.dragLeave(dropZone)
Object.defineProperties(outerDragLeave, {
clientX: { value: 120 },
clientY: { value: 50 },
relatedTarget: { value: document.body },
dataTransfer: { value: { files: [], types: ['Files'] } },
})
fireEvent(dropZone, outerDragLeave)
expect(dropZone).not.toHaveClass('border-blue-500')
})
test('任务图片选择框只允许浏览器处理链明确支持的格式', async () => {
render()
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
expect((screen.getByLabelText('主图') as HTMLInputElement).accept)
.toBe('image/jpeg,image/png,image/gif,image/webp')
expect((screen.getByLabelText('SKU图') as HTMLInputElement).accept)
.toBe('image/jpeg,image/png,image/gif,image/webp')
expect((screen.getByLabelText('详情图') as HTMLInputElement).accept)
.toBe('image/jpeg,image/png,image/gif,image/webp')
})
test('选图门禁逐个保留合格文件,并覆盖单组、总图片、总条目和总大小限制', () => {
const file = (name: string, size: number) => {
const value = new File(['x'], name, { type: 'image/jpeg' })
Object.defineProperty(value, 'size', { configurable: true, value: size })
return value
}
const emptySnapshot = () => ({ main: [], sku: [], detail: [], psd: [] })
const baseLimits = {
maxImagesPerGroup: 10,
maxTotalImageCount: 10,
maxPsdFileCount: 10,
maxTotalSourceFileCount: 10,
maxTotalSourceBytes: 100,
maxSinglePsdBytes: 100,
maxTotalPsdBytes: 100,
}
const groupResult = selectTaskDetailFilesWithinLimits(
'main',
[file('a.jpg', 1), file('b.jpg', 1)],
emptySnapshot(),
{ ...baseLimits, maxImagesPerGroup: 1 },
)
expect(groupResult.acceptedFiles.map((item) => item.name)).toEqual(['a.jpg'])
expect(groupResult.errors.join('')).toContain('主图最多选择 1 张')
const psdGroupResult = selectTaskDetailFilesWithinLimits(
'psd',
[file('source-a.psd', 1), file('source-b.psd', 1)],
emptySnapshot(),
{ ...baseLimits, maxPsdFileCount: 1 },
)
expect(psdGroupResult.acceptedFiles.map((item) => item.name)).toEqual(['source-a.psd'])
expect(psdGroupResult.errors.join('')).toContain('PSD 源文件最多选择 1 个')
const totalImageResult = selectTaskDetailFilesWithinLimits(
'detail',
[file('detail.jpg', 1)],
{ ...emptySnapshot(), main: [file('existing.jpg', 1)] },
{ ...baseLimits, maxTotalImageCount: 1 },
)
expect(totalImageResult.acceptedFiles).toHaveLength(0)
expect(totalImageResult.errors.join('')).toContain('本次图片合计最多 1 张')
const totalSourceResult = selectTaskDetailFilesWithinLimits(
'main',
[file('new.jpg', 1)],
{ ...emptySnapshot(), psd: [file('existing.psd', 1)] },
{ ...baseLimits, maxTotalSourceFileCount: 1 },
)
expect(totalSourceResult.acceptedFiles).toHaveLength(0)
expect(totalSourceResult.errors.join('')).toContain('原图和源文件合计最多 1 个')
const totalBytesResult = selectTaskDetailFilesWithinLimits(
'sku',
[file('fits.jpg', 1), file('too-much.jpg', 2)],
{ ...emptySnapshot(), main: [file('existing.jpg', 2)] },
{ ...baseLimits, maxTotalSourceBytes: 4 },
)
expect(totalBytesResult.acceptedFiles.map((item) => item.name)).toEqual(['fits.jpg'])
expect(totalBytesResult.errors.join('')).toContain('本次图片总大小最多 4B')
const largePsd = file('sku-sheet.psd', 80)
const psdBytesResult = selectTaskDetailFilesWithinLimits(
'psd',
[largePsd],
{ ...emptySnapshot(), main: [file('hero.jpg', 90)] },
{ ...baseLimits, maxTotalSourceBytes: 100, maxSinglePsdBytes: 100, maxTotalPsdBytes: 100 },
)
expect(psdBytesResult.acceptedFiles.map((item) => item.name)).toEqual(['sku-sheet.psd'])
expect(psdBytesResult.errors).toEqual([])
})
test('快速连续选图使用同步快照计数,超限文件不创建 Blob URL', async () => {
const mutableLimits = TASK_DETAIL_UPLOAD_LIMITS as unknown as { maxImagesPerGroup: number }
const originalLimit = mutableLimits.maxImagesPerGroup
mutableLimits.maxImagesPerGroup = 2
try {
render()
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
const input = screen.getByLabelText('主图') as HTMLInputElement
const first = new File(['first'], 'first.jpg', { type: 'image/jpeg' })
const second = new File(['second'], 'second.jpg', { type: 'image/jpeg' })
const third = new File(['third'], 'third.jpg', { type: 'image/jpeg' })
act(() => {
fireEvent.change(input, { target: { files: [first] } })
fireEvent.change(input, { target: { files: [second, third] } })
})
await waitFor(() => expect(URL.createObjectURL).toHaveBeenCalledTimes(2))
expect(mockPrepareTaskDetailSelectionThumbnail).toHaveBeenCalledTimes(2)
expect(mockPrepareTaskDetailSelectionThumbnail.mock.calls.map(([file]) => (file as File).name))
.toEqual(['first.jpg', 'second.jpg'])
expect((URL.createObjectURL as jest.Mock).mock.calls.every(([file]) => (
file instanceof File && file.type === 'image/webp'
))).toBe(true)
expect((URL.createObjectURL as jest.Mock).mock.calls.some(([file]) => file === first || file === second || file === third))
.toBe(false)
expect(screen.getByRole('button', { name: '删除主图 first.jpg' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '删除主图 second.jpg' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '删除主图 third.jpg' })).not.toBeInTheDocument()
expect(screen.getByRole('alert')).toHaveTextContent('主图最多选择 2 张')
expect(screen.getByRole('alert')).toHaveTextContent('请删除不需要的文件,或分批提交')
expect(screen.getByRole('alert')).toHaveTextContent('其余 1 张符合限制的文件已保留')
} finally {
mutableLimits.maxImagesPerGroup = originalLimit
}
})
test('多批选图共用单一缩略图队列,不会并行解码原图', async () => {
const firstThumbnail = createDeferred>()
const secondThumbnail = createDeferred>()
mockPrepareTaskDetailSelectionThumbnail
.mockImplementationOnce(() => firstThumbnail.promise)
.mockImplementationOnce(() => secondThumbnail.promise)
render()
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
const input = screen.getByLabelText('主图') as HTMLInputElement
const first = new File(['first'], 'queue-first.jpg', { type: 'image/jpeg' })
const second = new File(['second'], 'queue-second.jpg', { type: 'image/jpeg' })
fireEvent.change(input, { target: { files: [first] } })
fireEvent.change(input, { target: { files: [second] } })
await waitFor(() => expect(mockPrepareTaskDetailSelectionThumbnail).toHaveBeenCalledTimes(1))
expect(screen.getByRole('img', { name: 'queue-first.jpg:正在生成缩略图' })).toBeInTheDocument()
expect(screen.getByRole('img', { name: 'queue-second.jpg:正在生成缩略图' })).toBeInTheDocument()
await act(async () => {
firstThumbnail.resolve(thumbnailResult(first.name))
await Promise.resolve()
})
await waitFor(() => expect(mockPrepareTaskDetailSelectionThumbnail).toHaveBeenCalledTimes(2))
await act(async () => {
secondThumbnail.resolve(thumbnailResult(second.name))
await Promise.resolve()
})
await waitFor(() => expect(URL.createObjectURL).toHaveBeenCalledTimes(2))
})
test('浏览器能力不足时显示文件占位,不把原始 File 退回 img', async () => {
const user = userEvent.setup()
mockPrepareTaskDetailSelectionThumbnail.mockRejectedValueOnce(capabilityError())
const { container } = render()
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
const source = new File(['source'], 'no-thumbnail.jpg', { type: 'image/jpeg' })
await user.upload(screen.getByLabelText('主图'), source)
expect(await screen.findByRole('img', { name: 'no-thumbnail.jpg:预览不可用' })).toBeInTheDocument()
expect(screen.getByText('浏览器无法生成缩略图')).toBeInTheDocument()
expect(URL.createObjectURL).not.toHaveBeenCalled()
expect(container.querySelector('img')).toBeNull()
})
test('移除正在生成缩略图的文件会立即 abort,且不创建 Blob URL', async () => {
const user = userEvent.setup()
let thumbnailSignal: AbortSignal | undefined
mockPrepareTaskDetailSelectionThumbnail.mockImplementationOnce((
_file: File,
options?: { signal?: AbortSignal },
) => new Promise((_, reject) => {
thumbnailSignal = options?.signal
thumbnailSignal?.addEventListener('abort', () => reject(new DOMException('缩略图已取消', 'AbortError')), { once: true })
}))
render()
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
await user.upload(
screen.getByLabelText('主图'),
new File(['source'], 'remove-loading.jpg', { type: 'image/jpeg' }),
)
await waitFor(() => expect(thumbnailSignal).toBeDefined())
await user.click(screen.getByRole('button', { name: '删除主图 remove-loading.jpg' }))
expect(thumbnailSignal?.aborted).toBe(true)
expect(screen.queryByRole('button', { name: '删除主图 remove-loading.jpg' })).not.toBeInTheDocument()
expect(URL.createObjectURL).not.toHaveBeenCalled()
})
test('组件卸载会 abort 正在生成的缩略图且不遗留 Blob URL', async () => {
const user = userEvent.setup()
let thumbnailSignal: AbortSignal | undefined
mockPrepareTaskDetailSelectionThumbnail.mockImplementationOnce((
_file: File,
options?: { signal?: AbortSignal },
) => new Promise((_, reject) => {
thumbnailSignal = options?.signal
thumbnailSignal?.addEventListener('abort', () => reject(new DOMException('缩略图已取消', 'AbortError')), { once: true })
}))
const { unmount } = render()
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
await user.upload(
screen.getByLabelText('主图'),
new File(['source'], 'unmount-loading.jpg', { type: 'image/jpeg' }),
)
await waitFor(() => expect(thumbnailSignal).toBeDefined())
unmount()
expect(thumbnailSignal?.aborted).toBe(true)
expect(URL.createObjectURL).not.toHaveBeenCalled()
expect(URL.revokeObjectURL).not.toHaveBeenCalled()
})
test('开始提交会终止选图缩略图队列,再进入正式预览处理', async () => {
const user = userEvent.setup()
jest.spyOn(console, 'warn').mockImplementation(() => undefined)
let thumbnailSignal: AbortSignal | undefined
mockPrepareTaskDetailSelectionThumbnail.mockImplementationOnce((
_file: File,
options?: { signal?: AbortSignal },
) => new Promise((_, reject) => {
thumbnailSignal = options?.signal
thumbnailSignal?.addEventListener('abort', () => reject(new DOMException('缩略图已取消', 'AbortError')), { once: true })
}))
mockPrepareTaskDetailImagePreview.mockRejectedValueOnce(capabilityError())
installUploadFlowFetch(Promise.resolve({
ok: true,
json: async () => ({ data: { jobId: 'job-after-thumbnail-stop' } }),
}))
render()
await fillRequiredTaskFields(
user,
new File(['source'], 'submit-stops-thumbnail.jpg', { type: 'image/jpeg' }),
)
await waitFor(() => expect(thumbnailSignal).toBeDefined())
await user.click(screen.getByRole('button', { name: '提交' }))
expect(thumbnailSignal?.aborted).toBe(true)
await waitFor(() => expect(mockPrepareTaskDetailImagePreview).toHaveBeenCalledTimes(1))
expect(screen.getByRole('img', { name: 'submit-stops-thumbnail.jpg:预览不可用' })).toBeInTheDocument()
})
test('已生成的缩略图在删除和卸载时只回收对应 Blob URL', async () => {
const user = userEvent.setup()
const firstRender = render()
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
await user.upload(
screen.getByLabelText('主图'),
new File(['first'], 'remove-ready.jpg', { type: 'image/jpeg' }),
)
await waitFor(() => expect(URL.createObjectURL).toHaveBeenCalledTimes(1))
await user.click(screen.getByRole('button', { name: '删除主图 remove-ready.jpg' }))
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1)
expect(URL.revokeObjectURL).toHaveBeenLastCalledWith('blob:mock-1')
firstRender.unmount()
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1)
const secondRender = render()
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
await user.upload(
screen.getByLabelText('主图'),
new File(['second'], 'unmount-ready.jpg', { type: 'image/jpeg' }),
)
await waitFor(() => expect(URL.createObjectURL).toHaveBeenCalledTimes(2))
secondRender.unmount()
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(2)
expect(URL.revokeObjectURL).toHaveBeenLastCalledWith('blob:mock-2')
})
test('上传成功关闭前回收所有选图缩略图 Blob URL', async () => {
const user = userEvent.setup()
const onSuccess = jest.fn()
mockPrepareTaskDetailImagePreview.mockResolvedValueOnce([thumbnailResult('success.jpg')])
installUploadFlowFetch(Promise.resolve({
ok: true,
json: async () => ({ data: { jobId: 'job-success' } }),
}))
render()
await fillRequiredTaskFields(
user,
new File(['success'], 'success.jpg', { type: 'image/jpeg' }),
)
await waitFor(() => expect(URL.createObjectURL).toHaveBeenCalledTimes(1))
await user.click(screen.getByRole('button', { name: '提交' }))
await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1), { timeout: 3_000 })
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1)
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-1')
})
test('取消上传会 abort 正在运行的浏览器图片预处理', async () => {
const user = userEvent.setup()
jest.spyOn(console, 'error').mockImplementation(() => undefined)
const previewDeferred = createDeferred()
let previewSignal: AbortSignal | undefined
mockPrepareTaskDetailImagePreview.mockImplementationOnce((
_file: File,
_group: string,
_onProgress: unknown,
options?: { signal?: AbortSignal },
) => {
previewSignal = options?.signal
previewSignal?.addEventListener('abort', () => {
previewDeferred.reject(new DOMException('图片处理已取消', 'AbortError'))
}, { once: true })
return previewDeferred.promise
})
const fetchMock = installUploadFlowFetch()
render()
await fillRequiredTaskFields(
user,
new File(['image'], 'cancel-processing.jpg', { type: 'image/jpeg' }),
)
await user.click(screen.getByRole('button', { name: '提交' }))
await waitFor(() => expect(previewSignal).toBeDefined())
await user.click(screen.getByRole('button', { name: '取消上传' }))
expect(previewSignal?.aborted).toBe(true)
expect(await screen.findByText('已取消上传')).toBeInTheDocument()
expect(mockChunkUpload).not.toHaveBeenCalled()
expect(fetchMock).toHaveBeenCalledWith(
'/api/materials/upload/product-package/abort',
expect.objectContaining({ method: 'POST' }),
)
})
test('Edge 浏览器预览处理失败时继续上传原图并显式请求服务器兜底', async () => {
const user = userEvent.setup()
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
mockPrepareTaskDetailImagePreview.mockRejectedValueOnce(capabilityError())
;(global.fetch as jest.Mock)
.mockResolvedValueOnce({
ok: true,
json: async () => [{ id: 'requester-1', username: 'operator-a', displayName: '运营甲' }],
})
.mockResolvedValueOnce({ ok: true, json: async () => ({ data: { materialId: 'material-1' } }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ data: { jobId: 'job-1' } }) })
render()
await fillRequiredTaskFields(
user,
new File(['image'], 'edge-photo.jpg', { type: 'image/jpeg' }),
)
await user.click(screen.getByRole('button', { name: '提交' }))
await waitFor(() => expect(mockPollTaskDetailUploadJob).toHaveBeenCalledWith(
'job-1',
expect.objectContaining({ signal: expect.any(AbortSignal) }),
))
expect(mockChunkUpload).toHaveBeenCalledTimes(1)
expect(mockChunkUpload.mock.calls[0][0]).toMatchObject({ name: 'edge-photo.jpg' })
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('edge-photo.jpg'),
'当前 Edge 环境无法解码这张合法图片',
)
const completeRequest = (global.fetch as jest.Mock).mock.calls.find(
([url]) => url === '/api/materials/upload/product-package/complete',
)
expect(completeRequest).toBeDefined()
expect(JSON.parse(completeRequest[1].body)).toMatchObject({
materialId: 'material-1',
allowServerPreviewFallback: true,
assets: {
main: [expect.objectContaining({
fileName: 'edge-photo.jpg',
uploadId: 'upload-original-1',
})],
},
})
})
test('任务图片超过 60M 像素时立即阻断,不上传原图也不创建后台任务', async () => {
const user = userEvent.setup()
jest.spyOn(console, 'error').mockImplementation(() => undefined)
mockPrepareTaskDetailImagePreview.mockRejectedValueOnce(
new PerformanceSurveyBrowserImageError(
'SOURCE_PIXEL_LIMIT',
'图片「70m.jpg」像素总量超过 60,000,000,请先压缩或切图',
),
)
const fetchMock = installUploadFlowFetch()
render()
await fillRequiredTaskFields(
user,
new File(['image'], '70m.jpg', { type: 'image/jpeg' }),
)
await user.click(screen.getByRole('button', { name: '提交' }))
expect(await screen.findByText('图片「70m.jpg」像素总量超过 60,000,000,请先压缩或切图')).toBeInTheDocument()
expect(mockChunkUpload).not.toHaveBeenCalled()
expect(mockPollTaskDetailUploadJob).not.toHaveBeenCalled()
expect(fetchMock.mock.calls.some(([url]) => url === '/api/materials/upload/product-package/complete')).toBe(false)
expect(fetchMock).toHaveBeenCalledWith(
'/api/materials/upload/product-package/abort',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ materialId: 'material-1', uploadIds: [] }),
}),
)
})
test('并发首错后等待另一上传成功,再用完整 uploadId 列表统一回滚', async () => {
const user = userEvent.setup()
jest.spyOn(console, 'error').mockImplementation(() => undefined)
jest.spyOn(console, 'warn').mockImplementation(() => undefined)
mockPrepareTaskDetailImagePreview.mockRejectedValue(capabilityError())
const uploadA = createDeferred<{ uploadId: string }>()
const uploadB = createDeferred<{ uploadId: string }>()
const xhrA = { abort: jest.fn() } as unknown as XMLHttpRequest
const xhrB = { abort: jest.fn() } as unknown as XMLHttpRequest
mockChunkUpload.mockImplementation((file: File, _metadata: unknown, options: {
onActiveXhrChange?: (xhrs: Set) => void
}) => {
const isA = file.name === 'a.jpg'
options.onActiveXhrChange?.(new Set([isA ? xhrA : xhrB]))
return isA ? uploadA.promise : uploadB.promise
})
const fetchMock = installUploadFlowFetch()
render()
await fillRequiredTaskFields(user, [
new File(['a'], 'a.jpg', { type: 'image/jpeg' }),
new File(['b'], 'b.jpg', { type: 'image/jpeg' }),
])
await user.click(screen.getByRole('button', { name: '提交' }))
await waitFor(() => expect(mockChunkUpload).toHaveBeenCalledTimes(2))
await act(async () => {
uploadA.reject(new Error('A 上传失败'))
await Promise.resolve()
})
await waitFor(() => {
expect(xhrA.abort).toHaveBeenCalled()
expect(xhrB.abort).toHaveBeenCalled()
})
await act(async () => {
uploadB.resolve({ uploadId: 'upload-b' })
await Promise.resolve()
})
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/materials/upload/product-package/abort',
expect.objectContaining({
body: JSON.stringify({ materialId: 'material-1', uploadIds: ['upload-b'] }),
}),
))
expect(fetchMock.mock.calls.filter(([url]) => url === '/api/materials/upload/product-package/abort')).toHaveLength(1)
})
test('并发首错后另一上传也失败时停止重试并只执行一次素材回滚', async () => {
const user = userEvent.setup()
jest.spyOn(console, 'error').mockImplementation(() => undefined)
jest.spyOn(console, 'warn').mockImplementation(() => undefined)
mockPrepareTaskDetailImagePreview.mockRejectedValue(capabilityError())
const uploadA = createDeferred<{ uploadId: string }>()
const uploadB = createDeferred<{ uploadId: string }>()
const xhrA = { abort: jest.fn() } as unknown as XMLHttpRequest
const xhrB = { abort: jest.fn() } as unknown as XMLHttpRequest
let bOptions: { isCancelled?: () => boolean } | undefined
mockChunkUpload.mockImplementation((file: File, _metadata: unknown, options: {
isCancelled?: () => boolean
onActiveXhrChange?: (xhrs: Set) => void
}) => {
const isA = file.name === 'a.jpg'
if (!isA) bOptions = options
options.onActiveXhrChange?.(new Set([isA ? xhrA : xhrB]))
return isA ? uploadA.promise : uploadB.promise
})
const fetchMock = installUploadFlowFetch()
render()
await fillRequiredTaskFields(user, [
new File(['a'], 'a.jpg', { type: 'image/jpeg' }),
new File(['b'], 'b.jpg', { type: 'image/jpeg' }),
])
await user.click(screen.getByRole('button', { name: '提交' }))
await waitFor(() => expect(mockChunkUpload).toHaveBeenCalledTimes(2))
await act(async () => {
uploadA.reject(new Error('A 上传失败'))
await Promise.resolve()
})
await waitFor(() => expect(bOptions?.isCancelled?.()).toBe(true))
expect(xhrA.abort).toHaveBeenCalled()
expect(xhrB.abort).toHaveBeenCalled()
await act(async () => {
uploadB.reject(new Error('B 随后失败'))
await Promise.resolve()
})
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/materials/upload/product-package/abort',
expect.objectContaining({
body: JSON.stringify({ materialId: 'material-1', uploadIds: [] }),
}),
))
expect(fetchMock.mock.calls.filter(([url]) => url === '/api/materials/upload/product-package/abort')).toHaveLength(1)
})
test('complete 返回 202 前取消时标记后台已接单但不再启动轮询', async () => {
const user = userEvent.setup()
jest.spyOn(console, 'error').mockImplementation(() => undefined)
jest.spyOn(console, 'warn').mockImplementation(() => undefined)
mockPrepareTaskDetailImagePreview.mockRejectedValueOnce(capabilityError())
const completeResponse = createDeferred()
const fetchMock = installUploadFlowFetch(completeResponse.promise)
render()
await fillRequiredTaskFields(
user,
new File(['image'], 'cancel-before-202.jpg', { type: 'image/jpeg' }),
)
await user.click(screen.getByRole('button', { name: '提交' }))
await waitFor(() => expect(fetchMock.mock.calls.some(
([url]) => url === '/api/materials/upload/product-package/complete',
)).toBe(true))
await user.click(screen.getByRole('button', { name: '取消上传' }))
await act(async () => {
completeResponse.resolve({ ok: true, json: async () => ({ data: { jobId: 'job-after-cancel' } }) })
await Promise.resolve()
})
expect(await screen.findByText('已停止等待,后台任务仍在服务器处理中,可稍后查看素材状态')).toBeInTheDocument()
expect(mockPollTaskDetailUploadJob).not.toHaveBeenCalled()
expect(fetchMock.mock.calls.some(([url]) => url === '/api/materials/upload/product-package/abort')).toBe(false)
})
test('complete 返回 202 前卸载组件时不创建后台轮询', async () => {
const user = userEvent.setup()
jest.spyOn(console, 'error').mockImplementation(() => undefined)
jest.spyOn(console, 'warn').mockImplementation(() => undefined)
mockPrepareTaskDetailImagePreview.mockRejectedValueOnce(capabilityError())
const completeResponse = createDeferred()
const fetchMock = installUploadFlowFetch(completeResponse.promise)
const { unmount } = render()
await fillRequiredTaskFields(
user,
new File(['image'], 'unmount-before-202.jpg', { type: 'image/jpeg' }),
)
await user.click(screen.getByRole('button', { name: '提交' }))
await waitFor(() => expect(fetchMock.mock.calls.some(
([url]) => url === '/api/materials/upload/product-package/complete',
)).toBe(true))
unmount()
await act(async () => {
completeResponse.resolve({ ok: true, json: async () => ({ data: { jobId: 'job-after-unmount' } }) })
await Promise.resolve()
await Promise.resolve()
})
expect(mockPollTaskDetailUploadJob).not.toHaveBeenCalled()
expect(fetchMock.mock.calls.some(([url]) => url === '/api/materials/upload/product-package/abort')).toBe(false)
})
test('浏览器生成的预览超过服务端总量前停止上传并回滚空素材', async () => {
const user = userEvent.setup()
jest.spyOn(console, 'error').mockImplementation(() => undefined)
const mutableLimits = TASK_DETAIL_UPLOAD_LIMITS as unknown as { maxTotalPreviewCount: number }
const originalLimit = mutableLimits.maxTotalPreviewCount
mutableLimits.maxTotalPreviewCount = 1
mockPrepareTaskDetailImagePreview.mockResolvedValueOnce([
{
file: new File(['preview-1'], 'preview-1.webp', { type: 'image/webp' }),
width: 800,
height: 600,
kind: 'preview',
sourceName: 'photo.jpg',
sourceIndex: 0,
sliceIndex: 0,
sliceCount: 1,
},
{
file: new File(['preview-2'], 'preview-2.webp', { type: 'image/webp' }),
width: 800,
height: 600,
kind: 'preview',
sourceName: 'photo.jpg',
sourceIndex: 0,
sliceIndex: 0,
sliceCount: 1,
},
])
;(global.fetch as jest.Mock)
.mockResolvedValueOnce({
ok: true,
json: async () => [{ id: 'requester-1', username: 'operator-a', displayName: '运营甲' }],
})
.mockResolvedValueOnce({ ok: true, json: async () => ({ data: { materialId: 'material-1' } }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ success: true }) })
try {
render()
const requesterInput = await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
await user.click(requesterInput)
await user.type(requesterInput, '运营甲')
await user.click(screen.getByRole('option', { name: /运营甲/ }))
await user.click(screen.getByRole('button', { name: '设置测试 SPU' }))
await user.click(screen.getByRole('button', { name: '设置测试店铺' }))
await user.selectOptions(screen.getByLabelText(/出图方式/), '实拍')
await user.upload(
screen.getByLabelText('主图'),
new File(['image'], 'photo.jpg', { type: 'image/jpeg' }),
)
await user.click(screen.getByRole('button', { name: '提交' }))
expect(await screen.findByText('本次图片生成的预览切片过多或过大,请减少详情长图数量后分批提交')).toBeInTheDocument()
expect(mockChunkUpload).not.toHaveBeenCalled()
expect(global.fetch).toHaveBeenCalledWith(
'/api/materials/upload/product-package/abort',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ materialId: 'material-1', uploadIds: [] }),
}),
)
} finally {
mutableLimits.maxTotalPreviewCount = originalLimit
}
})
})
describe('stripTrailingImageExtensions(SKU 条码预填的文件名剥离)', () => {
it.each([
['6901234567890.jpg', '6901234567890'],
['6901234567890.png', '6901234567890'],
['6901234567890.jpg.jpg', '6901234567890'],
['6901234567890.png.png', '6901234567890'],
['6901234567890.jpg.png', '6901234567890'],
['6901234567890.png.jpg', '6901234567890'],
['6901234567890.JPG.PNG', '6901234567890'],
['6901234567890.jpeg.webp', '6901234567890'],
['6901234567890.jpg.png.webp.tiff', '6901234567890'],
['6901234567890', '6901234567890'],
['690.123.456.jpg', '690.123.456'],
['条码- A001.png', '条码- A001'],
])('%s → %s', (input, expected) => {
expect(stripTrailingImageExtensions(input)).toBe(expected)
})
})