Upload beautify
This commit is contained in:
310
resources/js/components/upload/__tests__/UploadWizard.test.jsx
Normal file
310
resources/js/components/upload/__tests__/UploadWizard.test.jsx
Normal file
@@ -0,0 +1,310 @@
|
||||
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 } = {}) {
|
||||
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') {
|
||||
return Promise.resolve({ data: { processing_state: statusValue, status: statusValue } })
|
||||
}
|
||||
|
||||
if (url === '/api/uploads/session-1/publish') {
|
||||
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(<UploadWizard {...props} />)
|
||||
})
|
||||
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 originalScrollIntoView
|
||||
let consoleErrorSpy
|
||||
|
||||
beforeEach(() => {
|
||||
window.URL.createObjectURL = vi.fn(() => `blob:${Math.random().toString(16).slice(2)}`)
|
||||
window.URL.revokeObjectURL = vi.fn()
|
||||
|
||||
originalImage = global.Image
|
||||
originalScrollIntoView = Element.prototype.scrollIntoView
|
||||
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
|
||||
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('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')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user