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) }) })