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> 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, 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 } const { getByTestId } = render() 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') }) })