Remove dead admin UI code, redesign dashboard followers/following and upload experiences, and add schema audit tooling with repair migrations for forum and upload drift.
340 lines
12 KiB
JavaScript
340 lines
12 KiB
JavaScript
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(<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 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')
|
|
})
|
|
})
|
|
})
|