gongxiangp2/components/upload/ProductPackageUploader.test.tsx

598 lines
23 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 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 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),
}))
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 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' })
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
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
writable: true,
value: jest.fn(() => 'blob:mock'),
})
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('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
}
})
})