pegasus/frontend/src/uploader-controller.test.tsx

349 lines
11 KiB
TypeScript
Raw Normal View History

2026-04-11 00:02:59 -03:00
import { act, render, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const harness = vi.hoisted(() => {
const apiMock = vi.fn()
const uploadState = {
mode: 'success' as 'success' | 'error',
dispatchBeforeUnload: false,
lastBeforeUnloadEvent: undefined as BeforeUnloadEvent | undefined,
pauseUpload: false,
finishUpload: undefined as (() => void) | undefined,
}
class UploadMock {
opts: any
file: File
constructor(file: File, opts: any) {
this.file = file
this.opts = opts
}
start() {
if (uploadState.mode === 'error') {
this.opts.onError?.({ originalRequest: { status: 503, statusText: 'Service Unavailable' } })
return
}
if (uploadState.pauseUpload) {
uploadState.finishUpload = () => {
this.opts.onProgress?.(5, 10)
this.opts.onSuccess?.()
}
return
}
if (uploadState.dispatchBeforeUnload) {
const event = new Event('beforeunload', { cancelable: true }) as BeforeUnloadEvent
uploadState.lastBeforeUnloadEvent = event
window.dispatchEvent(event)
}
this.opts.onProgress?.(5, 10)
this.opts.onSuccess?.()
}
}
return { apiMock, uploadState, UploadMock }
})
vi.mock('./api', () => ({
api: harness.apiMock,
}))
vi.mock('tus-js-client', () => ({
Upload: harness.UploadMock,
}))
import useUploaderController from './uploader-controller'
function makeFile(name: string, type: string) {
return new File(['x'], name, { type })
}
function installGlobals() {
vi.stubGlobal('alert', vi.fn())
vi.stubGlobal('confirm', vi.fn(() => true))
vi.stubGlobal('prompt', vi.fn(() => 'renamed'))
vi.stubGlobal('location', { replace: vi.fn() } as any)
}
function installApi() {
harness.apiMock.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(() => {
harness.apiMock.mockReset()
harness.uploadState.mode = 'success'
harness.uploadState.dispatchBeforeUnload = false
harness.uploadState.lastBeforeUnloadEvent = undefined
harness.uploadState.pauseUpload = false
harness.uploadState.finishUpload = undefined
installGlobals()
installApi()
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('useUploaderController', () => {
it('syncs folder input attributes for desktop and mobile UAs', async () => {
vi.stubGlobal('navigator', { userAgent: 'iPhone' } as any)
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(harness.apiMock).toHaveBeenCalledWith('/api/mkdir', expect.any(Object))
expect(harness.apiMock).toHaveBeenCalledWith('/api/rename', expect.any(Object))
expect(harness.apiMock).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 () => {
harness.uploadState.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))
harness.apiMock.mockImplementationOnce(async () => {
throw new Error('mkdir failed')
})
await act(async () => {
await result.current.createSubfolder('broken folder')
})
harness.apiMock.mockImplementationOnce(async () => {
throw new Error('rename folder failed')
})
await act(async () => {
await result.current.renameFolder('videos')
})
harness.apiMock.mockImplementationOnce(async () => {
throw new Error('delete folder failed')
})
await act(async () => {
await result.current.deleteFolder('archive')
})
harness.apiMock.mockImplementationOnce(async () => {
throw new Error('rename failed')
})
await act(async () => {
await result.current.renamePath('clip.mp4')
})
harness.apiMock.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 () => {
harness.apiMock.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((globalThis as any).location.replace).toHaveBeenCalledWith('/')
expect(harness.apiMock).toHaveBeenCalledWith('/api/logout', { method: 'POST' })
})
it('blocks unload while an upload is in flight', async () => {
harness.uploadState.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 = vi.spyOn(event, 'preventDefault')
await act(async () => {
window.dispatchEvent(event)
})
expect(preventDefault).toHaveBeenCalled()
expect(event.defaultPrevented).toBe(true)
await act(async () => {
harness.uploadState.finishUpload?.()
await uploadPromise
})
expect(result.current.status).toContain('Ready')
})
})