gongxiangp2/components/upload/ProductPackageUploader.test.tsx
chenpei 2b5220fdd7 fix: 任务-详情源文件不再误套 1GB 图片上限
PSD/压缩包按单文件 10GB、合计 30GB 单独限制;提交时续上传会话,避免大文件传完再排队后会话丢失。
2026-08-17 18:48:00 +08:00

950 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { act, createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ProductPackageUploader, {
selectTaskDetailFilesWithinLimits,
} 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: () => <div data-testid="detail-image-sorter" />,
}))
jest.mock('@/components/upload/UploadForm', () => ({
__esModule: true,
default: ({
onProductModelChange,
onCategoryChange,
}: {
onProductModelChange: (value: string) => void
onCategoryChange: (value: string) => void
}) => (
<div data-testid="upload-form">
<button type="button" onClick={() => onProductModelChange('SPU-TEST')}>设置测试 SPU</button>
<button type="button" onClick={() => onCategoryChange('测试店铺')}>设置测试店铺</button>
</div>
),
}))
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<T>() {
let resolve!: (value: T | PromiseLike<T>) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((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<typeof userEvent.setup>,
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<unknown>) {
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(<ProductPackageUploader {...defaultProps} />)
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(<ProductPackageUploader {...defaultProps} />)
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(<ProductPackageUploader {...defaultProps} />)
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(<ProductPackageUploader {...defaultProps} />)
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(<ProductPackageUploader {...defaultProps} />)
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
await user.click(screen.getByRole('button', { name: '提交' }))
expect(screen.getByText('请选择需求运营人员')).toBeInTheDocument()
expect(global.fetch).toHaveBeenCalledTimes(1)
})
test('文件经过主图上传框内部按钮时应保持拖拽高亮', async () => {
render(<ProductPackageUploader {...defaultProps} />)
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(<ProductPackageUploader {...defaultProps} />)
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(<ProductPackageUploader {...defaultProps} />)
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<ReturnType<typeof thumbnailResult>>()
const secondThumbnail = createDeferred<ReturnType<typeof thumbnailResult>>()
mockPrepareTaskDetailSelectionThumbnail
.mockImplementationOnce(() => firstThumbnail.promise)
.mockImplementationOnce(() => secondThumbnail.promise)
render(<ProductPackageUploader {...defaultProps} />)
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(<ProductPackageUploader {...defaultProps} />)
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(<ProductPackageUploader {...defaultProps} />)
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(<ProductPackageUploader {...defaultProps} />)
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(<ProductPackageUploader {...defaultProps} />)
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(<ProductPackageUploader {...defaultProps} />)
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(<ProductPackageUploader {...defaultProps} />)
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(<ProductPackageUploader {...defaultProps} onSuccess={onSuccess} />)
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<never>()
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(<ProductPackageUploader {...defaultProps} />)
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(<ProductPackageUploader {...defaultProps} />)
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(<ProductPackageUploader {...defaultProps} />)
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<XMLHttpRequest>) => void
}) => {
const isA = file.name === 'a.jpg'
options.onActiveXhrChange?.(new Set([isA ? xhrA : xhrB]))
return isA ? uploadA.promise : uploadB.promise
})
const fetchMock = installUploadFlowFetch()
render(<ProductPackageUploader {...defaultProps} />)
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<XMLHttpRequest>) => 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(<ProductPackageUploader {...defaultProps} />)
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<unknown>()
const fetchMock = installUploadFlowFetch(completeResponse.promise)
render(<ProductPackageUploader {...defaultProps} />)
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<unknown>()
const fetchMock = installUploadFlowFetch(completeResponse.promise)
const { unmount } = render(<ProductPackageUploader {...defaultProps} />)
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(<ProductPackageUploader {...defaultProps} />)
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
}
})
})