366 lines
12 KiB
TypeScript
366 lines
12 KiB
TypeScript
import { act, render, renderHook, waitFor } from '@testing-library/react'
|
|
import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'
|
|
|
|
const mockApi = jest.fn() as jest.MockedFunction<(path: string, init?: RequestInit) => Promise<unknown>>
|
|
const mockUploadState = {
|
|
mode: 'success' as 'success' | 'error',
|
|
dispatchBeforeUnload: false,
|
|
lastBeforeUnloadEvent: undefined as BeforeUnloadEvent | undefined,
|
|
pauseUpload: false,
|
|
finishUpload: undefined as (() => void) | undefined,
|
|
}
|
|
|
|
const mockTusUpload = class MockTusUpload {
|
|
opts: any
|
|
file: File
|
|
|
|
constructor(file: File, opts: any) {
|
|
this.file = file
|
|
this.opts = opts
|
|
}
|
|
|
|
start() {
|
|
if (mockUploadState.mode === 'error') {
|
|
this.opts.onError?.({ originalRequest: { status: 503, statusText: 'Service Unavailable' } })
|
|
return
|
|
}
|
|
if (mockUploadState.pauseUpload) {
|
|
mockUploadState.finishUpload = () => {
|
|
this.opts.onProgress?.(5, 10)
|
|
this.opts.onSuccess?.()
|
|
}
|
|
return
|
|
}
|
|
if (mockUploadState.dispatchBeforeUnload) {
|
|
const event = new Event('beforeunload', { cancelable: true }) as BeforeUnloadEvent
|
|
mockUploadState.lastBeforeUnloadEvent = event
|
|
window.dispatchEvent(event)
|
|
}
|
|
this.opts.onProgress?.(5, 10)
|
|
this.opts.onSuccess?.()
|
|
}
|
|
}
|
|
|
|
jest.mock('./api', () => ({
|
|
api: mockApi,
|
|
}))
|
|
|
|
jest.mock('tus-js-client', () => ({
|
|
Upload: mockTusUpload,
|
|
}))
|
|
|
|
import useUploaderController from './uploader-controller'
|
|
|
|
const originalAlertDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'alert')
|
|
const originalConfirmDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'confirm')
|
|
const originalPromptDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'prompt')
|
|
const originalLocationDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'location')
|
|
const originalNavigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
|
|
|
|
function makeFile(name: string, type: string) {
|
|
return new File(['x'], name, { type })
|
|
}
|
|
|
|
function installGlobals() {
|
|
Object.defineProperty(globalThis, 'alert', { configurable: true, value: jest.fn() })
|
|
Object.defineProperty(globalThis, 'confirm', { configurable: true, value: jest.fn(() => true) })
|
|
Object.defineProperty(globalThis, 'prompt', { configurable: true, value: jest.fn(() => 'renamed') })
|
|
}
|
|
|
|
function restoreGlobal(name: string, descriptor: PropertyDescriptor | undefined) {
|
|
if (descriptor) {
|
|
Object.defineProperty(globalThis, name, descriptor)
|
|
return
|
|
}
|
|
Reflect.deleteProperty(globalThis as Record<string, unknown>, name)
|
|
}
|
|
|
|
function restoreGlobals() {
|
|
restoreGlobal('alert', originalAlertDescriptor)
|
|
restoreGlobal('confirm', originalConfirmDescriptor)
|
|
restoreGlobal('prompt', originalPromptDescriptor)
|
|
restoreGlobal('location', originalLocationDescriptor)
|
|
restoreGlobal('navigator', originalNavigatorDescriptor)
|
|
}
|
|
|
|
function installApi() {
|
|
mockApi.mockImplementation(async (path: string) => {
|
|
if (path === '/api/whoami') {
|
|
return { username: 'brad', roots: ['alpha', 'beta'] }
|
|
}
|
|
if (path.startsWith('/api/list?')) {
|
|
const params = new URLSearchParams(path.split('?')[1])
|
|
if (params.get('path')) {
|
|
return [
|
|
{ name: 'nested', path: 'nested', is_dir: true, size: 0, mtime: 0 },
|
|
{ name: 'nested.mp4', path: 'nested/nested.mp4', is_dir: false, size: 4096, mtime: 1713000000 },
|
|
]
|
|
}
|
|
return [
|
|
{ name: 'videos', path: 'videos', is_dir: true, size: 0, mtime: 0 },
|
|
{ name: 'archive', path: 'archive', is_dir: true, size: 0, mtime: 0 },
|
|
{ name: 'clip.mp4', path: 'clip.mp4', is_dir: false, size: 2048, mtime: 1713000000 },
|
|
]
|
|
}
|
|
if (path === '/api/mkdir' || path === '/api/rename' || path.startsWith('/api/file?') || path === '/api/logout') {
|
|
return {}
|
|
}
|
|
throw new Error(`unexpected api path: ${path}`)
|
|
})
|
|
}
|
|
|
|
beforeEach(() => {
|
|
mockApi.mockReset()
|
|
mockUploadState.mode = 'success'
|
|
mockUploadState.dispatchBeforeUnload = false
|
|
mockUploadState.lastBeforeUnloadEvent = undefined
|
|
mockUploadState.pauseUpload = false
|
|
mockUploadState.finishUpload = undefined
|
|
installGlobals()
|
|
installApi()
|
|
})
|
|
|
|
afterEach(() => {
|
|
jest.restoreAllMocks()
|
|
restoreGlobals()
|
|
})
|
|
|
|
describe('useUploaderController', () => {
|
|
it('syncs folder input attributes for desktop and mobile UAs', async () => {
|
|
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { userAgent: 'iPhone' } })
|
|
|
|
function Harness() {
|
|
const controller = useUploaderController()
|
|
return <input data-testid="folder" ref={controller.folderInputRef} {...({ webkitdirectory: '', directory: '' } as any)} />
|
|
}
|
|
|
|
const { getByTestId } = render(<Harness />)
|
|
const input = getByTestId('folder') as HTMLInputElement
|
|
|
|
await waitFor(() => {
|
|
expect(input.hasAttribute('webkitdirectory')).toBe(false)
|
|
expect(input.hasAttribute('directory')).toBe(false)
|
|
})
|
|
})
|
|
|
|
it('returns early before the profile loads', async () => {
|
|
const { result } = renderHook(() => useUploaderController())
|
|
|
|
await waitFor(() => expect(result.current.libs).toEqual(['alpha', 'beta']))
|
|
|
|
act(() => {
|
|
result.current.setMe(undefined)
|
|
})
|
|
|
|
await waitFor(() => expect(result.current.me).toBeUndefined())
|
|
await act(async () => {
|
|
await result.current.doUpload()
|
|
})
|
|
await waitFor(() => expect(result.current.status).toBe('Not signed in'))
|
|
})
|
|
|
|
it('rejects uploads that still need video descriptions', async () => {
|
|
const { result } = renderHook(() => useUploaderController())
|
|
|
|
await waitFor(() => expect(result.current.libs).toEqual(['alpha', 'beta']))
|
|
|
|
act(() => {
|
|
result.current.setLib('alpha')
|
|
result.current.handleChoose([makeFile('clip.mp4', 'video/mp4')] as any)
|
|
})
|
|
await waitFor(() => expect(result.current.sel).toHaveLength(1))
|
|
|
|
await act(async () => {
|
|
await result.current.doUpload()
|
|
})
|
|
|
|
expect((globalThis as any).alert).toHaveBeenCalledWith(
|
|
expect.stringContaining('Please add a short description for all videos')
|
|
)
|
|
})
|
|
|
|
it('loads data, supports folder ops, and uploads successfully', async () => {
|
|
const { result } = renderHook(() => useUploaderController())
|
|
|
|
await waitFor(() => expect(result.current.libs).toEqual(['alpha', 'beta']))
|
|
|
|
act(() => {
|
|
result.current.setLib('alpha')
|
|
})
|
|
await waitFor(() => expect(result.current.rootDirs).toEqual(['archive', 'videos']))
|
|
expect(result.current.destPath).toBe('/alpha')
|
|
|
|
act(() => {
|
|
result.current.handleChoose([makeFile('clip.mp4', 'video/mp4'), makeFile('photo.jpg', 'image/jpeg')] as any)
|
|
})
|
|
await waitFor(() => expect(result.current.sel).toHaveLength(2))
|
|
|
|
act(() => {
|
|
result.current.setBulkDesc('family trip')
|
|
})
|
|
await waitFor(() => expect(result.current.bulkDesc).toBe('family trip'))
|
|
|
|
act(() => {
|
|
result.current.applyDescToAllVideos()
|
|
})
|
|
await waitFor(() => expect(result.current.sel[0].desc).toBe('family trip'))
|
|
|
|
act(() => {
|
|
result.current.setGlobalDate('2026-04-11')
|
|
})
|
|
await waitFor(() => expect(result.current.sel[0].date).toBe('2026-04-11'))
|
|
|
|
await act(async () => {
|
|
await result.current.createSubfolder('new folder')
|
|
})
|
|
await act(async () => {
|
|
await result.current.renameFolder('videos')
|
|
})
|
|
await act(async () => {
|
|
await result.current.deleteFolder('archive')
|
|
})
|
|
await act(async () => {
|
|
await result.current.renamePath('clip.mp4')
|
|
})
|
|
await act(async () => {
|
|
await result.current.deletePath('clip.mp4', false)
|
|
})
|
|
|
|
await act(async () => {
|
|
await result.current.refresh('alpha', 'videos')
|
|
})
|
|
expect(result.current.sortedRows[0].name).toBe('nested')
|
|
expect(result.current.existingNames.has('nested.mp4')).toBe(true)
|
|
|
|
await act(async () => {
|
|
await result.current.doUpload()
|
|
})
|
|
|
|
expect(mockApi).toHaveBeenCalledWith('/api/mkdir', expect.any(Object))
|
|
expect(mockApi).toHaveBeenCalledWith('/api/rename', expect.any(Object))
|
|
expect(mockApi).toHaveBeenCalledWith(expect.stringContaining('/api/file?'), expect.any(Object))
|
|
expect(result.current.sel).toEqual([])
|
|
expect(result.current.status).toContain('Ready')
|
|
})
|
|
|
|
it('surfaces upload failures and the not-signed-in guard', async () => {
|
|
mockUploadState.mode = 'error'
|
|
const { result } = renderHook(() => useUploaderController())
|
|
|
|
await waitFor(() => expect(result.current.libs).toEqual(['alpha', 'beta']))
|
|
|
|
await act(async () => {
|
|
await result.current.doUpload()
|
|
})
|
|
expect((globalThis as any).alert).toHaveBeenCalledWith('Please select a Library to upload into.')
|
|
|
|
act(() => {
|
|
result.current.setLib('alpha')
|
|
result.current.setSel([
|
|
{ file: makeFile('clip.mp4', 'video/mp4'), desc: 'clip', date: '2026-04-10', finalName: '2026.04.10.clip.clip.mp4', progress: 0 },
|
|
])
|
|
})
|
|
|
|
await waitFor(() => expect(result.current.sel).toHaveLength(1))
|
|
|
|
mockApi.mockImplementationOnce(async () => {
|
|
throw new Error('mkdir failed')
|
|
})
|
|
await act(async () => {
|
|
await result.current.createSubfolder('broken folder')
|
|
})
|
|
|
|
mockApi.mockImplementationOnce(async () => {
|
|
throw new Error('rename folder failed')
|
|
})
|
|
await act(async () => {
|
|
await result.current.renameFolder('videos')
|
|
})
|
|
|
|
mockApi.mockImplementationOnce(async () => {
|
|
throw new Error('delete folder failed')
|
|
})
|
|
await act(async () => {
|
|
await result.current.deleteFolder('archive')
|
|
})
|
|
|
|
mockApi.mockImplementationOnce(async () => {
|
|
throw new Error('rename failed')
|
|
})
|
|
await act(async () => {
|
|
await result.current.renamePath('clip.mp4')
|
|
})
|
|
|
|
mockApi.mockImplementationOnce(async () => {
|
|
throw new Error('delete failed')
|
|
})
|
|
await act(async () => {
|
|
await result.current.deletePath('clip.mp4', false)
|
|
})
|
|
|
|
await act(async () => {
|
|
await result.current.doUpload().catch(() => {})
|
|
})
|
|
|
|
expect((globalThis as any).alert).toHaveBeenCalledWith(expect.stringContaining('Upload failed'))
|
|
})
|
|
|
|
it('surfaces profile errors and logs out on missing mappings', async () => {
|
|
mockApi.mockImplementation(async (path: string) => {
|
|
if (path === '/api/whoami') {
|
|
throw new Error('no mapping found')
|
|
}
|
|
if (path === '/api/logout') {
|
|
return {}
|
|
}
|
|
if (path.startsWith('/api/list?') || path === '/api/mkdir' || path === '/api/rename' || path.startsWith('/api/file?')) {
|
|
return {}
|
|
}
|
|
throw new Error(`unexpected api path: ${path}`)
|
|
})
|
|
|
|
const { result } = renderHook(() => useUploaderController())
|
|
|
|
await waitFor(() => expect(result.current.status).toMatch(/^Profile error:/))
|
|
expect((globalThis as any).alert).toHaveBeenCalledWith(
|
|
'Your account is not linked to any upload library yet. Please contact the admin to be granted access.'
|
|
)
|
|
expect(mockApi).toHaveBeenCalledWith('/api/logout', { method: 'POST' })
|
|
})
|
|
|
|
it('blocks unload while an upload is in flight', async () => {
|
|
mockUploadState.pauseUpload = true
|
|
const { result } = renderHook(() => useUploaderController())
|
|
|
|
await waitFor(() => expect(result.current.libs).toEqual(['alpha', 'beta']))
|
|
|
|
act(() => {
|
|
result.current.setLib('alpha')
|
|
result.current.setSel([
|
|
{ file: makeFile('clip.mp4', 'video/mp4'), desc: 'clip', date: '2026-04-10', finalName: '2026.04.10.clip.clip.mp4', progress: 0 },
|
|
])
|
|
})
|
|
|
|
await waitFor(() => expect(result.current.sel).toHaveLength(1))
|
|
|
|
const uploadPromise = result.current.doUpload()
|
|
await waitFor(() => expect(result.current.uploading).toBe(true))
|
|
|
|
const event = new Event('beforeunload', { cancelable: true }) as BeforeUnloadEvent
|
|
const preventDefault = jest.spyOn(event, 'preventDefault')
|
|
await act(async () => {
|
|
window.dispatchEvent(event)
|
|
})
|
|
|
|
expect(preventDefault).toHaveBeenCalled()
|
|
expect(event.defaultPrevented).toBe(true)
|
|
|
|
await act(async () => {
|
|
mockUploadState.finishUpload?.()
|
|
await uploadPromise
|
|
})
|
|
|
|
expect(result.current.status).toContain('Ready')
|
|
})
|
|
})
|