import React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act } from 'react' import { cleanup, render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import UploadWizard from '../UploadWizard' function installAxiosStubs({ statusValue = 'ready', initError = null, holdChunk = false, finishError = null } = {}) { window.axios = { post: vi.fn((url, payload, config = {}) => { if (url === '/api/uploads/init') { if (initError) return Promise.reject(initError) return Promise.resolve({ data: { session_id: 'session-1', upload_token: 'token-1', }, }) } if (url === '/api/uploads/chunk') { if (holdChunk) { return new Promise((resolve, reject) => { if (config?.signal?.aborted) { reject({ name: 'CanceledError', code: 'ERR_CANCELED' }) return } config?.signal?.addEventListener?.('abort', () => reject({ name: 'CanceledError', code: 'ERR_CANCELED' })) setTimeout(() => resolve({ data: { received_bytes: 1024, progress: 55 } }), 20) }) } const offset = Number(payload?.get?.('offset') || 0) const chunkSize = Number(payload?.get?.('chunk_size') || 0) const totalSize = Number(payload?.get?.('total_size') || 1) const received = Math.min(totalSize, offset + chunkSize) return Promise.resolve({ data: { received_bytes: received, progress: Math.round((received / totalSize) * 100), }, }) } if (url === '/api/uploads/finish') { if (finishError) return Promise.reject(finishError) return Promise.resolve({ data: { processing_state: statusValue, status: statusValue } }) } if (/^\/api\/uploads\/[^/]+\/publish$/.test(url)) { return Promise.resolve({ data: { success: true, status: 'published' } }) } if (url === '/api/uploads/cancel') { return Promise.resolve({ data: { success: true, status: 'cancelled' } }) } return Promise.reject(new Error(`Unhandled POST ${url}`)) }), get: vi.fn((url) => { if (url === '/api/uploads/status/session-1') { return Promise.resolve({ data: { id: 'session-1', processing_state: statusValue, status: statusValue, }, }) } return Promise.reject(new Error(`Unhandled GET ${url}`)) }), } } async function flushUi() { await act(async () => { await new Promise((resolve) => window.setTimeout(resolve, 0)) }) } async function renderWizard(props = {}) { await act(async () => { render() }) await flushUi() } async function uploadPrimary(file) { await act(async () => { const input = screen.getByLabelText('Upload file input') await userEvent.upload(input, file) }) await flushUi() } async function uploadScreenshot(file) { await act(async () => { const input = await screen.findByLabelText('Screenshot file input') await userEvent.upload(input, file) }) await flushUi() } async function completeStep1ToReady() { await uploadPrimary(new File(['img'], 'ready.png', { type: 'image/png' })) await act(async () => { await userEvent.click(await screen.findByRole('button', { name: /start upload/i })) }) await waitFor(() => { expect(screen.getByRole('button', { name: /continue to publish/i })).not.toBeNull() }) } describe('UploadWizard step flow', () => { let originalImage let originalScrollTo let originalScrollIntoView let consoleErrorSpy beforeEach(() => { window.URL.createObjectURL = vi.fn(() => `blob:${Math.random().toString(16).slice(2)}`) window.URL.revokeObjectURL = vi.fn() originalImage = global.Image originalScrollTo = window.scrollTo originalScrollIntoView = Element.prototype.scrollIntoView window.scrollTo = vi.fn() Element.prototype.scrollIntoView = vi.fn() consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { const text = args.map((arg) => String(arg)).join(' ') if (text.includes('not configured to support act')) return if (text.includes('not wrapped in act')) return console.warn(...args) }) global.Image = class MockImage { set src(_value) { this.naturalWidth = 1920 this.naturalHeight = 1080 setTimeout(() => { if (typeof this.onload === 'function') this.onload() }, 0) } } }) afterEach(() => { global.Image = originalImage window.scrollTo = originalScrollTo Element.prototype.scrollIntoView = originalScrollIntoView consoleErrorSpy?.mockRestore() cleanup() vi.restoreAllMocks() }) it('renders 3-step stepper', () => { installAxiosStubs() return renderWizard({ initialDraftId: 301 }).then(() => { expect(screen.getByRole('navigation', { name: /upload steps/i })).not.toBeNull() expect(screen.getByRole('button', { name: /1 upload/i })).not.toBeNull() expect(screen.getByRole('button', { name: /2 details/i })).not.toBeNull() expect(screen.getByRole('button', { name: /3 publish/i })).not.toBeNull() }) }) it('marks locked steps with aria-disabled and blocks click', async () => { installAxiosStubs() await renderWizard({ initialDraftId: 307 }) const stepper = screen.getByRole('navigation', { name: /upload steps/i }) const detailsStep = within(stepper).getByRole('button', { name: /2 details/i }) const publishStep = within(stepper).getByRole('button', { name: /3 publish/i }) expect(detailsStep.getAttribute('aria-disabled')).toBe('true') expect(publishStep.getAttribute('aria-disabled')).toBe('true') await act(async () => { await userEvent.click(detailsStep) }) expect(screen.getByRole('heading', { level: 2, name: /upload your artwork/i })).not.toBeNull() expect(screen.queryByText(/add details/i)).toBeNull() }) it('keeps step 2 hidden until step 1 upload is ready', async () => { installAxiosStubs({ statusValue: 'processing' }) await renderWizard({ initialDraftId: 302 }) expect(screen.queryByText(/artwork details/i)).toBeNull() await uploadPrimary(new File(['img'], 'x.png', { type: 'image/png' })) await act(async () => { await userEvent.click(await screen.findByRole('button', { name: /start upload/i })) }) await waitFor(() => { expect(screen.queryByRole('button', { name: /continue to publish/i })).toBeNull() }) expect(screen.queryByText(/artwork details/i)).toBeNull() }) it('requires archive screenshot before start upload enables', async () => { installAxiosStubs() await renderWizard({ initialDraftId: 303 }) await uploadPrimary(new File(['zip'], 'bundle.zip', { type: 'application/zip' })) const start = await screen.findByRole('button', { name: /start upload/i }) await waitFor(() => { expect(start.disabled).toBe(true) }) await uploadScreenshot(new File(['shot'], 'screen.png', { type: 'image/png' })) await waitFor(() => { expect(start.disabled).toBe(false) }) }) it('allows navigation back to completed previous step', async () => { installAxiosStubs({ statusValue: 'ready' }) await renderWizard({ initialDraftId: 304 }) await completeStep1ToReady() expect(await screen.findByText(/artwork details/i)).not.toBeNull() const stepper = screen.getByRole('navigation', { name: /upload steps/i }) await act(async () => { await userEvent.click(within(stepper).getByRole('button', { name: /upload/i })) }) expect(await screen.findByText(/upload your artwork file/i)).not.toBeNull() }) it('triggers scroll-to-top behavior on step change', async () => { installAxiosStubs({ statusValue: 'ready' }) await renderWizard({ initialDraftId: 308 }) const scrollSpy = Element.prototype.scrollIntoView const initialCalls = scrollSpy.mock.calls.length await completeStep1ToReady() await waitFor(() => { expect(scrollSpy.mock.calls.length).toBeGreaterThan(initialCalls) }) }) it('shows publish only on step 3 and only after ready_to_publish path', async () => { installAxiosStubs({ statusValue: 'ready' }) await renderWizard({ initialDraftId: 305, contentTypes: [{ id: 1, name: 'Art', categories: [{ id: 10, name: 'Root', children: [{ id: 11, name: 'Sub' }] }] }] }) await completeStep1ToReady() expect(await screen.findByText(/artwork details/i)).not.toBeNull() expect(screen.queryByRole('button', { name: /^publish$/i })).toBeNull() await act(async () => { await userEvent.type(screen.getByRole('textbox', { name: /title/i }), 'My Art') await userEvent.selectOptions(screen.getByRole('combobox', { name: /root category/i }), '10') await userEvent.selectOptions(screen.getByRole('combobox', { name: /subcategory/i }), '11') await userEvent.click(screen.getByLabelText(/i confirm i own the rights to this content/i)) await userEvent.click(screen.getByRole('button', { name: /continue to publish/i })) }) await waitFor(() => { const publish = screen.getByRole('button', { name: /^publish$/i }) expect(publish.disabled).toBe(false) }) await act(async () => { await userEvent.click(screen.getByRole('button', { name: /^publish$/i })) }) await waitFor(() => { expect(screen.getByText(/your artwork is live/i)).not.toBeNull() }) expect(window.axios.post).not.toHaveBeenCalledWith('/api/uploads/session-1/publish', expect.anything(), expect.anything()) }) it('keeps mobile sticky action bar visible class', async () => { installAxiosStubs() await renderWizard({ initialDraftId: 306 }) const bar = screen.getByTestId('wizard-action-bar') expect((bar.className || '').includes('sticky')).toBe(true) expect((bar.className || '').includes('bottom-0')).toBe(true) }) it('shows mapped duplicate hash toast when finish returns duplicate_hash', async () => { installAxiosStubs({ finishError: { response: { status: 409, data: { reason: 'duplicate_hash', message: 'Duplicate upload is not allowed. This file already exists.', }, }, }, }) await renderWizard({ initialDraftId: 310 }) await uploadPrimary(new File(['img'], 'duplicate.png', { type: 'image/png' })) await act(async () => { await userEvent.click(await screen.findByRole('button', { name: /start upload/i })) }) const toast = await screen.findByRole('alert') expect(toast.textContent).toMatch(/already exists in skinbase/i) }) it('locks step 1 file input after upload and unlocks after reset', async () => { installAxiosStubs({ statusValue: 'ready' }) await renderWizard({ initialDraftId: 309 }) await completeStep1ToReady() const stepper = screen.getByRole('navigation', { name: /upload steps/i }) await act(async () => { await userEvent.click(within(stepper).getByRole('button', { name: /upload/i })) }) await waitFor(() => { const dropzoneButton = screen.getByTestId('upload-dropzone') expect(dropzoneButton.getAttribute('aria-disabled')).toBe('true') }) expect(screen.getByText(/file is locked after upload\. reset to change\./i)).not.toBeNull() await act(async () => { await userEvent.click(screen.getByRole('button', { name: /reset upload/i })) }) await waitFor(() => { const unlockedDropzone = screen.getByTestId('upload-dropzone') expect(unlockedDropzone.getAttribute('aria-disabled')).toBe('false') }) }) })