Răsfoiți Sursa

[ui-sb] add test for file upload hooks (#4138)

Ram Prasad Agarwal 5 luni în urmă
părinte
comite
0a3e78b2d8

+ 741 - 0
desktop/core/src/desktop/js/utils/hooks/useFileUpload/useChunkUpload.test.tsx

@@ -0,0 +1,741 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { renderHook, waitFor, act } from '@testing-library/react';
+import useChunkUpload from './useChunkUpload';
+import { FileStatus, RegularFile, ChunkedFile, ChunkedFilesInProgress } from './types';
+import { TaskServerResponse, TaskStatus } from '../../../reactComponents/TaskServer/types';
+import { GET_TASKS_URL } from '../../../reactComponents/TaskServer/constants';
+import { DEFAULT_CONCURRENT_MAX_CONNECTIONS } from '../../constants/storageBrowser';
+import useQueueProcessor from '../useQueueProcessor/useQueueProcessor';
+
+const mockFile: RegularFile = {
+  uuid: 'file-1',
+  filePath: '/upload/',
+  file: new File(['chunked'], 'chunked.txt', { type: 'text/plain' }),
+  status: FileStatus.Pending,
+  error: undefined,
+  progress: 0
+};
+
+const mockFile2: RegularFile = {
+  uuid: 'file-2',
+  filePath: '/upload/',
+  file: new File(['chunked2'], 'chunked2.txt', { type: 'text/plain' }),
+  status: FileStatus.Pending,
+  error: undefined,
+  progress: 0
+};
+
+const mockLargeFile: RegularFile = {
+  uuid: 'large-file-1',
+  filePath: '/upload/',
+  file: new File([new ArrayBuffer(1024 * 1024 * 10)], 'large.txt', { type: 'text/plain' }),
+  status: FileStatus.Pending,
+  error: undefined,
+  progress: 0
+};
+
+const mockEmptyFile: RegularFile = {
+  uuid: 'empty-file-1',
+  filePath: '/upload/',
+  file: new File([''], 'empty.txt', { type: 'text/plain' }),
+  status: FileStatus.Pending,
+  error: undefined,
+  progress: 0
+};
+
+const mockChunk: ChunkedFile = {
+  ...mockFile,
+  chunkNumber: 0,
+  totalChunks: 1,
+  totalSize: mockFile.file.size,
+  chunkStartOffset: 0,
+  chunkEndOffset: mockFile.file.size,
+  filePath: '/upload/',
+  fileName: 'chunked.txt',
+  file: new Blob(['chunked']),
+  progress: 0
+};
+
+const mockEnqueue = jest.fn();
+const mockDequeue = jest.fn();
+let mockQueueCallback: (file: ChunkedFile) => void = jest.fn();
+
+jest.mock('../useQueueProcessor/useQueueProcessor', () => ({
+  __esModule: true,
+  default: jest.fn(callback => {
+    mockQueueCallback = callback;
+    return {
+      enqueue: mockEnqueue,
+      dequeue: mockDequeue
+    };
+  })
+}));
+
+const mockSave = jest.fn();
+jest.mock('../useSaveData/useSaveData', () => ({
+  __esModule: true,
+  default: jest.fn(() => ({
+    save: mockSave
+  }))
+}));
+
+const mockLoadData = jest.fn();
+jest.mock('../useLoadData/useLoadData', () => ({
+  __esModule: true,
+  default: jest.fn((url, options) => {
+    mockLoadData(url, options);
+    return {};
+  })
+}));
+
+const mockIsSpaceAvailableInServer = jest.fn().mockImplementation(() => Promise.resolve(true));
+const mockIsAllChunksOfFileUploaded = jest.fn().mockImplementation(() => true);
+
+jest.mock('./utils', () => {
+  const actualUtils = jest.requireActual('./utils');
+  return {
+    __esModule: true,
+    ...actualUtils,
+    isSpaceAvailableInServer: () => mockIsSpaceAvailableInServer(),
+    isAllChunksOfFileUploaded: (filesInProgress: ChunkedFilesInProgress, chunk: ChunkedFile) => {
+      const result = mockIsAllChunksOfFileUploaded(filesInProgress, chunk);
+      return result;
+    }
+  };
+});
+
+describe('useChunkUpload', () => {
+  const mockUpdateFileVariables = jest.fn();
+  const mockOnComplete = jest.fn();
+
+  beforeEach(() => {
+    jest.clearAllMocks();
+    mockIsSpaceAvailableInServer.mockResolvedValue(true);
+    mockIsAllChunksOfFileUploaded.mockReturnValue(true);
+  });
+
+  it('should call enqueue files when addFiles is called', () => {
+    const { result } = renderHook(() =>
+      useChunkUpload({
+        updateFileVariables: mockUpdateFileVariables,
+        onComplete: mockOnComplete
+      })
+    );
+
+    result.current.addFiles([mockFile]);
+
+    expect(mockEnqueue).toHaveBeenCalledWith([
+      {
+        uuid: mockFile.uuid,
+        fileName: mockFile.file.name,
+        totalSize: mockFile.file.size,
+        chunkNumber: 0,
+        totalChunks: 1,
+        chunkStartOffset: 0,
+        chunkEndOffset: mockFile.file.size,
+        filePath: mockFile.filePath,
+        file: new Blob([mockFile.file]),
+        status: FileStatus.Pending,
+        progress: 0,
+        error: undefined
+      }
+    ]);
+  });
+
+  it('should call dequeue when cancelFile is called', () => {
+    const { result } = renderHook(() =>
+      useChunkUpload({
+        updateFileVariables: mockUpdateFileVariables,
+        onComplete: mockOnComplete
+      })
+    );
+
+    result.current.cancelFile(mockFile.uuid);
+
+    expect(mockDequeue).toHaveBeenCalledWith(mockFile.uuid, 'uuid');
+  });
+
+  it('should handle multiple files upload', () => {
+    const { result } = renderHook(() =>
+      useChunkUpload({
+        updateFileVariables: mockUpdateFileVariables,
+        onComplete: mockOnComplete
+      })
+    );
+
+    result.current.addFiles([mockFile, mockFile2]);
+
+    expect(mockEnqueue).toHaveBeenCalledTimes(2);
+  });
+
+  it('should handle empty files', () => {
+    const { result } = renderHook(() =>
+      useChunkUpload({
+        updateFileVariables: mockUpdateFileVariables,
+        onComplete: mockOnComplete
+      })
+    );
+
+    result.current.addFiles([mockEmptyFile]);
+
+    expect(mockEnqueue).toHaveBeenCalledWith([
+      {
+        uuid: mockEmptyFile.uuid,
+        fileName: mockEmptyFile.file.name,
+        totalSize: mockEmptyFile.file.size,
+        chunkNumber: 0,
+        totalChunks: 1,
+        chunkStartOffset: 0,
+        chunkEndOffset: 0,
+        filePath: mockEmptyFile.filePath,
+        file: new Blob(['']),
+        status: FileStatus.Pending,
+        progress: 0,
+        error: undefined
+      }
+    ]);
+  });
+
+  it('should create multiple chunks for large file', () => {
+    const { result } = renderHook(() =>
+      useChunkUpload({
+        updateFileVariables: mockUpdateFileVariables,
+        onComplete: mockOnComplete
+      })
+    );
+
+    result.current.addFiles([mockLargeFile]);
+
+    expect(mockEnqueue).toHaveBeenCalledWith([
+      {
+        uuid: mockLargeFile.uuid,
+        filePath: '/upload/',
+        fileName: mockLargeFile.file.name,
+        totalSize: mockLargeFile.file.size,
+        totalChunks: 2,
+        chunkNumber: 0,
+        chunkStartOffset: 0,
+        chunkEndOffset: 5242880,
+        file: new Blob([new ArrayBuffer(5242880)]),
+        status: FileStatus.Pending,
+        progress: 0,
+        error: undefined
+      },
+      {
+        uuid: mockLargeFile.uuid,
+        filePath: '/upload/',
+        fileName: mockLargeFile.file.name,
+        totalSize: mockLargeFile.file.size,
+        totalChunks: 2,
+        chunkNumber: 1,
+        chunkStartOffset: 5242880,
+        chunkEndOffset: 10485760,
+        file: new Blob([new ArrayBuffer(5242880)]),
+        status: FileStatus.Pending,
+        progress: 0,
+        error: undefined
+      }
+    ]);
+  });
+
+  it('should update file variables on chunk upload error', async () => {
+    mockSave.mockImplementationOnce((_, { onError }) => {
+      onError(new Error('chunk upload failed'));
+    });
+
+    const { result } = renderHook(() =>
+      useChunkUpload({
+        updateFileVariables: mockUpdateFileVariables,
+        onComplete: mockOnComplete
+      })
+    );
+
+    result.current.addFiles([mockFile]);
+
+    mockQueueCallback(mockChunk);
+
+    await waitFor(() => {
+      expect(mockUpdateFileVariables).toHaveBeenCalledWith(mockFile.uuid, {
+        status: FileStatus.Failed,
+        error: new Error('chunk upload failed')
+      });
+    });
+  });
+
+  it('should fail if there is not enough space on the server', async () => {
+    mockIsSpaceAvailableInServer.mockResolvedValue(false);
+
+    const { result } = renderHook(() =>
+      useChunkUpload({
+        updateFileVariables: mockUpdateFileVariables,
+        onComplete: mockOnComplete
+      })
+    );
+
+    result.current.addFiles([mockFile]);
+
+    mockQueueCallback(mockChunk);
+
+    await waitFor(() => {
+      expect(mockUpdateFileVariables.mock.calls).toEqual(
+        expect.arrayContaining([
+          [mockFile.uuid, { status: FileStatus.Uploading }],
+          [
+            mockFile.uuid,
+            {
+              status: FileStatus.Failed,
+              error: new Error('Upload server ran out of space. Try again later.')
+            }
+          ]
+        ])
+      );
+    });
+    expect(mockSave).not.toHaveBeenCalled();
+  });
+
+  it('should handle successful chunk upload and progress', async () => {
+    mockSave
+      .mockImplementationOnce((_, { onSuccess, postOptions }) => {
+        if (postOptions?.onUploadProgress) {
+          postOptions.onUploadProgress({ loaded: 50, total: 100 });
+          postOptions.onUploadProgress({ loaded: 100, total: 100 });
+        }
+        onSuccess();
+      })
+      .mockImplementationOnce((_, { onSuccess }) => onSuccess()); // Completion
+
+    const { result } = renderHook(() =>
+      useChunkUpload({
+        updateFileVariables: mockUpdateFileVariables,
+        onComplete: mockOnComplete
+      })
+    );
+
+    result.current.addFiles([mockFile]);
+
+    await act(async () => {
+      mockQueueCallback(mockChunk);
+    });
+
+    await waitFor(() => {
+      expect(mockUpdateFileVariables).toHaveBeenCalledWith(mockFile.uuid, {
+        status: FileStatus.Uploading
+      });
+      expect(mockUpdateFileVariables).toHaveBeenCalledWith(mockFile.uuid, {
+        progress: 50
+      });
+      expect(mockUpdateFileVariables).toHaveBeenCalledWith(mockFile.uuid, {
+        progress: 100
+      });
+      expect(mockSave).toHaveBeenCalledTimes(2); // Once for chunk, once for completion
+    });
+  });
+
+  it('should handle chunk completion error', async () => {
+    mockIsAllChunksOfFileUploaded.mockReturnValue(true);
+    mockSave
+      .mockImplementationOnce((_, { onSuccess }) => onSuccess()) // Chunk upload
+      .mockImplementationOnce((_, { onError }) => onError(new Error('completion failed'))); // Completion
+
+    const { result } = renderHook(() =>
+      useChunkUpload({
+        updateFileVariables: mockUpdateFileVariables,
+        onComplete: mockOnComplete
+      })
+    );
+
+    result.current.addFiles([mockFile]);
+
+    mockQueueCallback(mockChunk);
+
+    await waitFor(() => {
+      expect(mockUpdateFileVariables).toHaveBeenCalledWith(mockFile.uuid, {
+        status: FileStatus.Failed,
+        error: new Error('completion failed')
+      });
+    });
+  });
+
+  it('should poll task server for final status updates', async () => {
+    const { result } = renderHook(() =>
+      useChunkUpload({
+        updateFileVariables: mockUpdateFileVariables,
+        onComplete: mockOnComplete
+      })
+    );
+
+    expect(mockLoadData).toHaveBeenCalledWith(
+      GET_TASKS_URL,
+      expect.objectContaining({
+        pollInterval: 5000,
+        skip: true,
+        onSuccess: expect.any(Function)
+      })
+    );
+
+    mockIsAllChunksOfFileUploaded.mockReturnValue(true);
+    mockSave
+      .mockImplementationOnce((_, { onSuccess }) => onSuccess()) // Chunk upload
+      .mockImplementationOnce((_, { onSuccess }) => onSuccess()); // Completion
+
+    await act(async () => {
+      result.current.addFiles([mockFile]);
+      mockQueueCallback(mockChunk);
+    });
+
+    await waitFor(() => {
+      expect(mockLoadData).toHaveBeenCalledWith(
+        GET_TASKS_URL,
+        expect.objectContaining({
+          pollInterval: 5000,
+          skip: false,
+          onSuccess: expect.any(Function)
+        })
+      );
+    });
+  });
+
+  it('should handle task server success response', async () => {
+    let taskServerCallback: ((response: TaskServerResponse[]) => void) | undefined;
+    mockLoadData.mockImplementation((url, options) => {
+      taskServerCallback = options.onSuccess;
+    });
+
+    const { result } = renderHook(() =>
+      useChunkUpload({
+        updateFileVariables: mockUpdateFileVariables,
+        onComplete: mockOnComplete
+      })
+    );
+
+    mockIsAllChunksOfFileUploaded.mockReturnValue(true);
+    mockSave
+      .mockImplementationOnce((_, { onSuccess }) => onSuccess()) // Chunk upload
+      .mockImplementationOnce((_, { onSuccess }) => onSuccess()); // Completion
+
+    await act(async () => {
+      result.current.addFiles([mockFile]);
+      mockQueueCallback(mockChunk);
+    });
+
+    const mockResponse = [
+      { taskId: mockFile.uuid, status: TaskStatus.Success, dateDone: new Date().toISOString() }
+    ];
+
+    await act(async () => {
+      if (taskServerCallback) {
+        taskServerCallback(mockResponse);
+      }
+    });
+
+    expect(mockUpdateFileVariables).toHaveBeenCalledWith(mockFile.uuid, {
+      status: FileStatus.Uploaded
+    });
+    expect(mockOnComplete).toHaveBeenCalled();
+  });
+
+  it('should handle task server failure response', async () => {
+    let taskServerCallback: ((response: TaskServerResponse[]) => void) | undefined;
+    mockLoadData.mockImplementation((url, options) => {
+      taskServerCallback = options.onSuccess;
+    });
+
+    const { result } = renderHook(() =>
+      useChunkUpload({
+        updateFileVariables: mockUpdateFileVariables,
+        onComplete: mockOnComplete
+      })
+    );
+
+    mockIsAllChunksOfFileUploaded.mockReturnValue(true);
+    mockSave
+      .mockImplementationOnce((_, { onSuccess }) => onSuccess()) // Chunk upload
+      .mockImplementationOnce((_, { onSuccess }) => onSuccess()); // Completion
+
+    await act(async () => {
+      result.current.addFiles([mockFile]);
+      mockQueueCallback(mockChunk);
+    });
+
+    const mockResponse = [
+      { taskId: mockFile.uuid, status: TaskStatus.Failure, dateDone: new Date().toISOString() }
+    ];
+
+    await act(async () => {
+      if (taskServerCallback) {
+        taskServerCallback(mockResponse);
+      }
+    });
+
+    expect(mockUpdateFileVariables).toHaveBeenCalledWith(mockFile.uuid, {
+      status: FileStatus.Failed
+    });
+    expect(mockOnComplete).toHaveBeenCalled();
+  });
+
+  it('should handle task server pending response', async () => {
+    let taskServerCallback: ((response: TaskServerResponse[]) => void) | undefined;
+    mockLoadData.mockImplementation((url, options) => {
+      taskServerCallback = options.onSuccess;
+    });
+
+    const mockOnCompleteForPending = jest.fn();
+
+    const { result } = renderHook(() =>
+      useChunkUpload({
+        updateFileVariables: mockUpdateFileVariables,
+        onComplete: mockOnCompleteForPending
+      })
+    );
+
+    mockIsAllChunksOfFileUploaded.mockReturnValue(true);
+    mockSave
+      .mockImplementationOnce((_, { onSuccess }) => onSuccess()) // Chunk upload
+      .mockImplementationOnce((_, { onSuccess }) => onSuccess()); // Completion
+
+    await act(async () => {
+      result.current.addFiles([mockFile]);
+      mockQueueCallback(mockChunk);
+    });
+
+    const mockResponse = [
+      { taskId: mockFile.uuid, status: TaskStatus.Running, dateDone: new Date().toISOString() }
+    ];
+
+    await act(async () => {
+      if (taskServerCallback) {
+        taskServerCallback(mockResponse);
+      }
+    });
+
+    expect(mockOnCompleteForPending).not.toHaveBeenCalled();
+  });
+
+  it('should return correct isLoading state', async () => {
+    const { result } = renderHook(() =>
+      useChunkUpload({
+        updateFileVariables: mockUpdateFileVariables,
+        onComplete: mockOnComplete
+      })
+    );
+
+    expect(result.current.isLoading).toBe(false);
+
+    result.current.addFiles([mockFile]);
+
+    expect(result.current.isLoading).toBe(false);
+  });
+
+  it('should handle concurrent uploads with custom concurrentProcess', () => {
+    renderHook(() =>
+      useChunkUpload({
+        concurrentProcess: 3,
+        updateFileVariables: mockUpdateFileVariables,
+        onComplete: mockOnComplete
+      })
+    );
+
+    expect(useQueueProcessor).toHaveBeenCalledWith(expect.any(Function), { concurrentProcess: 3 });
+  });
+
+  it('should use default concurrent process when not specified', () => {
+    renderHook(() =>
+      useChunkUpload({
+        updateFileVariables: mockUpdateFileVariables,
+        onComplete: mockOnComplete
+      })
+    );
+
+    expect(useQueueProcessor).toHaveBeenCalledWith(expect.any(Function), {
+      concurrentProcess: DEFAULT_CONCURRENT_MAX_CONNECTIONS
+    });
+  });
+
+  it('should handle progress updates correctly', async () => {
+    const mockProgressEvent = { loaded: 75, total: 100 };
+
+    mockSave.mockImplementationOnce((_, { onSuccess, postOptions }) => {
+      if (postOptions?.onUploadProgress) {
+        postOptions.onUploadProgress(mockProgressEvent);
+      }
+      onSuccess();
+    });
+
+    const { result } = renderHook(() =>
+      useChunkUpload({
+        updateFileVariables: mockUpdateFileVariables,
+        onComplete: mockOnComplete
+      })
+    );
+
+    result.current.addFiles([mockFile]);
+
+    await act(async () => {
+      mockQueueCallback(mockChunk);
+    });
+
+    await waitFor(() => {
+      expect(mockUpdateFileVariables).toHaveBeenCalledWith(mockFile.uuid, {
+        status: FileStatus.Uploading
+      });
+      expect(mockUpdateFileVariables).toHaveBeenCalledWith(
+        mockFile.uuid,
+        expect.objectContaining({
+          progress: 75
+        })
+      );
+    });
+  });
+
+  it('should handle multiple task server responses', async () => {
+    let taskServerCallback: ((response: TaskServerResponse[]) => void) | undefined;
+    mockLoadData.mockImplementation((url, options) => {
+      taskServerCallback = options.onSuccess;
+    });
+
+    renderHook(() =>
+      useChunkUpload({
+        updateFileVariables: mockUpdateFileVariables,
+        onComplete: mockOnComplete
+      })
+    );
+
+    const mockResponse = [
+      { taskId: 'file-1', status: TaskStatus.Success, dateDone: new Date().toISOString() },
+      { taskId: 'file-2', status: TaskStatus.Running, dateDone: new Date().toISOString() },
+      { taskId: 'file-3', status: TaskStatus.Failure, dateDone: new Date().toISOString() }
+    ];
+
+    await act(async () => {
+      if (taskServerCallback) {
+        taskServerCallback(mockResponse);
+      }
+    });
+
+    expect(mockUpdateFileVariables).not.toHaveBeenCalledWith('file-1', expect.anything());
+    expect(mockUpdateFileVariables).not.toHaveBeenCalledWith('file-3', expect.anything());
+    expect(mockUpdateFileVariables).not.toHaveBeenCalledWith('file-2', expect.anything());
+  });
+
+  it('should call onComplete when all files finish uploading', async () => {
+    let taskServerCallback: ((response: TaskServerResponse[]) => void) | undefined;
+    mockLoadData.mockImplementation((url, options) => {
+      taskServerCallback = options.onSuccess;
+    });
+
+    const mockOnCompleteForTest = jest.fn();
+
+    const { result } = renderHook(() =>
+      useChunkUpload({
+        updateFileVariables: mockUpdateFileVariables,
+        onComplete: mockOnCompleteForTest
+      })
+    );
+
+    mockIsAllChunksOfFileUploaded.mockReturnValue(true);
+    mockSave
+      .mockImplementationOnce((_, { onSuccess }) => onSuccess()) // Chunk upload
+      .mockImplementationOnce((_, { onSuccess }) => onSuccess()); // Completion
+
+    await act(async () => {
+      result.current.addFiles([mockFile]);
+      mockQueueCallback(mockChunk);
+    });
+
+    expect(mockOnCompleteForTest).not.toHaveBeenCalled();
+
+    const mockResponse = [
+      { taskId: mockFile.uuid, status: TaskStatus.Success, dateDone: new Date().toISOString() }
+    ];
+
+    await act(async () => {
+      if (taskServerCallback) {
+        taskServerCallback(mockResponse);
+      }
+    });
+
+    expect(mockOnCompleteForTest).toHaveBeenCalledTimes(1);
+    expect(mockUpdateFileVariables).toHaveBeenCalledWith(mockFile.uuid, {
+      status: FileStatus.Uploaded
+    });
+  });
+
+  it('should call onComplete only after all multiple files finish uploading', async () => {
+    let taskServerCallback: ((response: TaskServerResponse[]) => void) | undefined;
+    mockLoadData.mockImplementation((url, options) => {
+      taskServerCallback = options.onSuccess;
+    });
+
+    const mockOnCompleteForMultipleFiles = jest.fn();
+
+    const { result } = renderHook(() =>
+      useChunkUpload({
+        updateFileVariables: mockUpdateFileVariables,
+        onComplete: mockOnCompleteForMultipleFiles
+      })
+    );
+
+    mockIsAllChunksOfFileUploaded.mockReturnValue(true);
+    mockSave.mockImplementation((_, { onSuccess }) => onSuccess());
+
+    const mockChunk2: ChunkedFile = {
+      ...mockFile2,
+      chunkNumber: 0,
+      totalChunks: 1,
+      totalSize: mockFile2.file.size,
+      chunkStartOffset: 0,
+      chunkEndOffset: mockFile2.file.size,
+      filePath: '/upload/',
+      fileName: 'chunked2.txt',
+      file: new Blob(['chunked2']),
+      progress: 0
+    };
+
+    await act(async () => {
+      result.current.addFiles([mockFile, mockFile2]);
+      mockQueueCallback(mockChunk);
+      mockQueueCallback(mockChunk2);
+    });
+
+    expect(mockOnCompleteForMultipleFiles).not.toHaveBeenCalled();
+
+    const mockResponseFirstFile = [
+      { taskId: mockFile.uuid, status: TaskStatus.Success, dateDone: new Date().toISOString() }
+    ];
+
+    await act(async () => {
+      if (taskServerCallback) {
+        taskServerCallback(mockResponseFirstFile);
+      }
+    });
+
+    expect(mockOnCompleteForMultipleFiles).not.toHaveBeenCalled();
+
+    const mockResponseSecondFile = [
+      { taskId: mockFile2.uuid, status: TaskStatus.Success, dateDone: new Date().toISOString() }
+    ];
+
+    await act(async () => {
+      if (taskServerCallback) {
+        taskServerCallback(mockResponseSecondFile);
+      }
+    });
+
+    expect(mockOnCompleteForMultipleFiles).toHaveBeenCalledTimes(1);
+  });
+});

+ 1 - 1
desktop/core/src/desktop/js/utils/hooks/useFileUpload/useChunkUpload.ts

@@ -24,6 +24,7 @@ import {
 } from '../../constants/storageBrowser';
 import useLoadData from '../useLoadData/useLoadData';
 import { TaskServerResponse, TaskStatus } from '../../../reactComponents/TaskServer/types';
+import { GET_TASKS_URL } from '../../../reactComponents/TaskServer/constants';
 import {
   getChunksCompletePayload,
   getItemProgress,
@@ -42,7 +43,6 @@ import {
   FileStatus,
   ChunkedFilesInProgress
 } from './types';
-import { GET_TASKS_URL } from 'reactComponents/TaskServer/constants';
 
 interface UseChunkUploadResponse {
   addFiles: (item: RegularFile[]) => void;

+ 232 - 0
desktop/core/src/desktop/js/utils/hooks/useFileUpload/useFileUpload.test.tsx

@@ -0,0 +1,232 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { renderHook, act } from '@testing-library/react';
+import useFileUpload from './useFileUpload';
+import { FileStatus, FileVariables, RegularFile } from './types';
+import useRegularUpload from './useRegularUpload';
+import useChunkUpload from './useChunkUpload';
+
+const mockAddRegularFiles = jest.fn();
+const mockCancelRegularFile = jest.fn();
+const mockAddChunkFile = jest.fn();
+const mockCancelChunkFile = jest.fn();
+
+jest.mock('./useRegularUpload', () => ({
+  __esModule: true,
+  default: jest.fn()
+}));
+
+jest.mock('./useChunkUpload', () => ({
+  __esModule: true,
+  default: jest.fn()
+}));
+
+jest.mock('../../../config/hueConfig', () => ({
+  getLastKnownConfig: () => ({
+    storage_browser: {
+      concurrent_max_connection: 5
+    }
+  })
+}));
+
+describe('useFileUpload', () => {
+  const mockOnComplete = jest.fn();
+
+  const mockFiles: RegularFile[] = [
+    {
+      uuid: 'file-1',
+      filePath: '/path/to/test1.txt',
+      file: new File(['test content'], 'test1.txt', { type: 'text/plain' }),
+      status: FileStatus.Pending,
+      progress: 0
+    },
+    {
+      uuid: 'file-2',
+      filePath: '/path/to/test2.txt',
+      file: new File(['test content 2'], 'test2.txt', { type: 'text/plain' }),
+      status: FileStatus.Pending,
+      progress: 0
+    }
+  ];
+
+  const mockUseRegularUpload = jest.mocked(useRegularUpload);
+  const mockUseChunkUpload = jest.mocked(useChunkUpload);
+
+  beforeEach(() => {
+    jest.clearAllMocks();
+
+    mockUseRegularUpload.mockReturnValue({
+      addFiles: mockAddRegularFiles,
+      cancelFile: mockCancelRegularFile,
+      isLoading: false
+    });
+
+    mockUseChunkUpload.mockReturnValue({
+      addFiles: mockAddChunkFile,
+      cancelFile: mockCancelChunkFile,
+      isLoading: false
+    });
+  });
+
+  it('should add files to the upload queue with regular upload', () => {
+    const { result } = renderHook(() =>
+      useFileUpload({ isChunkUpload: false, onComplete: mockOnComplete })
+    );
+
+    act(() => {
+      result.current.addFiles(mockFiles);
+    });
+
+    expect(result.current.uploadQueue).toEqual(mockFiles);
+    expect(mockAddRegularFiles).toHaveBeenCalledWith(mockFiles);
+    expect(mockAddChunkFile).not.toHaveBeenCalled();
+  });
+
+  it('should add files to the upload queue with chunk upload', () => {
+    const { result } = renderHook(() =>
+      useFileUpload({ isChunkUpload: true, onComplete: mockOnComplete })
+    );
+
+    act(() => {
+      result.current.addFiles(mockFiles);
+    });
+
+    expect(result.current.uploadQueue).toEqual(mockFiles);
+    expect(mockAddChunkFile).toHaveBeenCalledWith(mockFiles);
+    expect(mockAddRegularFiles).not.toHaveBeenCalled();
+  });
+
+  it('should cancel a pending file using regular upload', () => {
+    const { result } = renderHook(() =>
+      useFileUpload({ isChunkUpload: false, onComplete: mockOnComplete })
+    );
+
+    act(() => {
+      result.current.addFiles(mockFiles);
+    });
+
+    act(() => {
+      result.current.cancelFile(mockFiles[0]);
+    });
+
+    expect(mockCancelRegularFile).toHaveBeenCalledWith('file-1');
+    expect(result.current.uploadQueue[0].status).toBe(FileStatus.Cancelled);
+  });
+
+  it('should cancel a pending file using chunk upload', () => {
+    const { result } = renderHook(() =>
+      useFileUpload({ isChunkUpload: true, onComplete: mockOnComplete })
+    );
+
+    act(() => {
+      result.current.addFiles(mockFiles);
+    });
+
+    act(() => {
+      result.current.cancelFile(mockFiles[0]);
+    });
+
+    expect(mockCancelChunkFile).toHaveBeenCalledWith('file-1');
+    expect(result.current.uploadQueue[0].status).toBe(FileStatus.Cancelled);
+  });
+
+  it('should return isLoading as true if regular upload method is loading', () => {
+    mockUseRegularUpload.mockReturnValue({
+      addFiles: mockAddRegularFiles,
+      cancelFile: mockCancelRegularFile,
+      isLoading: true
+    });
+
+    const { result } = renderHook(() =>
+      useFileUpload({ isChunkUpload: false, onComplete: mockOnComplete })
+    );
+
+    expect(result.current.isLoading).toBe(true);
+  });
+
+  it('should return isLoading as true if chunk upload method is loading', () => {
+    mockUseChunkUpload.mockReturnValue({
+      addFiles: mockAddChunkFile,
+      cancelFile: mockCancelChunkFile,
+      isLoading: true
+    });
+
+    const { result } = renderHook(() =>
+      useFileUpload({ isChunkUpload: true, onComplete: mockOnComplete })
+    );
+
+    expect(result.current.isLoading).toBe(true);
+  });
+
+  it('should update queue with correct status sent from the queues', () => {
+    let capturedUpdateFileVariables: (uuid: string, variables: FileVariables) => void;
+
+    mockUseRegularUpload.mockImplementation(options => {
+      capturedUpdateFileVariables = options.updateFileVariables;
+      return {
+        addFiles: mockAddRegularFiles,
+        cancelFile: mockCancelRegularFile,
+        isLoading: false
+      };
+    });
+
+    const { result } = renderHook(() =>
+      useFileUpload({ isChunkUpload: false, onComplete: mockOnComplete })
+    );
+
+    act(() => {
+      result.current.addFiles(mockFiles);
+    });
+
+    expect(result.current.uploadQueue[0].status).toBe(FileStatus.Pending);
+    expect(result.current.uploadQueue[0].progress).toBe(0);
+
+    act(() => {
+      capturedUpdateFileVariables('file-1', {
+        status: FileStatus.Uploading,
+        progress: 50
+      });
+    });
+
+    expect(result.current.uploadQueue[0].status).toBe(FileStatus.Uploading);
+    expect(result.current.uploadQueue[0].progress).toBe(50);
+
+    act(() => {
+      capturedUpdateFileVariables('file-1', {
+        status: FileStatus.Uploaded,
+        progress: 100
+      });
+    });
+
+    expect(result.current.uploadQueue[0].status).toBe(FileStatus.Uploaded);
+    expect(result.current.uploadQueue[0].progress).toBe(100);
+
+    expect(result.current.uploadQueue[1].status).toBe(FileStatus.Pending);
+    expect(result.current.uploadQueue[1].progress).toBe(0);
+
+    const testError = new Error('Upload failed');
+    act(() => {
+      capturedUpdateFileVariables('file-2', {
+        status: FileStatus.Failed,
+        error: testError
+      });
+    });
+
+    expect(result.current.uploadQueue[1].status).toBe(FileStatus.Failed);
+    expect(result.current.uploadQueue[1].error).toBe(testError);
+  });
+});

+ 216 - 0
desktop/core/src/desktop/js/utils/hooks/useFileUpload/useRegularUpload.test.tsx

@@ -0,0 +1,216 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { renderHook, waitFor } from '@testing-library/react';
+import useRegularUpload from './useRegularUpload';
+import { FileStatus, RegularFile } from './types';
+
+const mockFile: RegularFile = {
+  uuid: 'abc-123',
+  filePath: '/uploads/',
+  file: new File(['hello'], 'file.txt', { type: 'text/plain' }),
+  status: FileStatus.Pending,
+  error: undefined,
+  progress: 0
+};
+
+const mockEnqueue = jest.fn();
+const mockDequeue = jest.fn();
+const mockIsLoading = jest.fn().mockReturnValue(false);
+
+let mockProcessCallback: ((item: RegularFile) => Promise<void>) | null = null;
+let mockOnSuccessCallback: (() => void) | null = null;
+
+jest.mock('../useQueueProcessor/useQueueProcessor', () => ({
+  __esModule: true,
+  default: jest.fn((processCallback, options) => {
+    mockProcessCallback = processCallback;
+    mockOnSuccessCallback = options.onSuccess;
+    return {
+      enqueue: mockEnqueue,
+      dequeue: mockDequeue,
+      isLoading: mockIsLoading()
+    };
+  })
+}));
+
+const mockSave = jest.fn();
+jest.mock('../useSaveData/useSaveData', () => ({
+  __esModule: true,
+  default: jest.fn(() => ({
+    save: mockSave
+  }))
+}));
+
+describe('useRegularUpload', () => {
+  const mockUpdateFileVariables = jest.fn();
+  const mockOnComplete = jest.fn();
+
+  beforeEach(() => {
+    jest.clearAllMocks();
+    mockProcessCallback = null;
+    mockOnSuccessCallback = null;
+  });
+
+  it('should enqueue files when addFiles is called', async () => {
+    const { result } = renderHook(() =>
+      useRegularUpload({
+        updateFileVariables: mockUpdateFileVariables,
+        onComplete: mockOnComplete
+      })
+    );
+
+    result.current.addFiles([mockFile]);
+
+    expect(mockEnqueue).toHaveBeenCalledWith([mockFile]);
+  });
+
+  it('should call dequeue with correct uuid when cancelFile is called', () => {
+    const { result } = renderHook(() =>
+      useRegularUpload({
+        updateFileVariables: mockUpdateFileVariables,
+        onComplete: mockOnComplete
+      })
+    );
+
+    result.current.cancelFile(mockFile.uuid);
+
+    expect(mockDequeue).toHaveBeenCalledWith(mockFile.uuid, 'uuid');
+  });
+
+  it('should call save with correct payload and update file variables on success', async () => {
+    const { result } = renderHook(() =>
+      useRegularUpload({
+        updateFileVariables: mockUpdateFileVariables,
+        onComplete: mockOnComplete
+      })
+    );
+
+    result.current.addFiles([mockFile]);
+
+    if (mockProcessCallback) {
+      await mockProcessCallback(mockFile);
+    }
+
+    const mockFormData = new FormData();
+    mockFormData.append('file', mockFile.file);
+    mockFormData.append('destination_path', mockFile.filePath);
+
+    expect(mockSave).toHaveBeenCalledWith(
+      mockFormData,
+      expect.objectContaining({
+        onSuccess: expect.any(Function),
+        onError: expect.any(Function)
+      })
+    );
+    expect(mockUpdateFileVariables).toHaveBeenCalledWith(mockFile.uuid, {
+      status: FileStatus.Uploading
+    });
+
+    expect(mockOnComplete).not.toHaveBeenCalled();
+  });
+
+  it('should call updateFileVariables with correct status on success', async () => {
+    const { result } = renderHook(() =>
+      useRegularUpload({
+        updateFileVariables: mockUpdateFileVariables,
+        onComplete: mockOnComplete
+      })
+    );
+
+    result.current.addFiles([mockFile]);
+
+    if (mockProcessCallback) {
+      mockSave.mockImplementationOnce((_, { onSuccess }) => {
+        onSuccess();
+      });
+
+      await mockProcessCallback(mockFile);
+    }
+
+    await waitFor(() => {
+      expect(mockUpdateFileVariables).toHaveBeenCalledWith(mockFile.uuid, {
+        status: FileStatus.Uploaded
+      });
+    });
+  });
+
+  it('should call updateFileVariables with correct status on error', async () => {
+    const { result } = renderHook(() =>
+      useRegularUpload({
+        updateFileVariables: mockUpdateFileVariables,
+        onComplete: mockOnComplete
+      })
+    );
+
+    result.current.addFiles([mockFile]);
+
+    if (mockProcessCallback) {
+      mockSave.mockImplementationOnce((_, { onError }) => {
+        onError(new Error('Upload failed'));
+      });
+
+      await mockProcessCallback(mockFile);
+    }
+
+    await waitFor(() => {
+      expect(mockUpdateFileVariables).toHaveBeenCalledWith(mockFile.uuid, {
+        status: FileStatus.Failed,
+        error: new Error('Upload failed')
+      });
+    });
+  });
+
+  it('should reflect loading state from useQueueProcessor', () => {
+    mockIsLoading.mockReturnValue(true);
+
+    const { result } = renderHook(() =>
+      useRegularUpload({
+        updateFileVariables: mockUpdateFileVariables,
+        onComplete: mockOnComplete
+      })
+    );
+
+    expect(result.current.isLoading).toBe(true);
+  });
+
+  it('should call onComplete when all files are processed', async () => {
+    const { result } = renderHook(() =>
+      useRegularUpload({
+        updateFileVariables: mockUpdateFileVariables,
+        onComplete: mockOnComplete
+      })
+    );
+
+    result.current.addFiles([mockFile]);
+
+    if (mockProcessCallback) {
+      mockSave.mockImplementationOnce((_, { onSuccess }) => {
+        onSuccess();
+      });
+
+      await mockProcessCallback(mockFile);
+
+      if (mockOnSuccessCallback) {
+        mockOnSuccessCallback();
+      }
+    }
+
+    await waitFor(() => {
+      expect(mockOnComplete).toHaveBeenCalled();
+    });
+  });
+});

+ 513 - 0
desktop/core/src/desktop/js/utils/hooks/useFileUpload/utils.test.ts

@@ -0,0 +1,513 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { get } from '../../../api/utils';
+import { TaskStatus } from '../../../reactComponents/TaskServer/types';
+import {
+  getTotalChunk,
+  getMetaData,
+  createChunks,
+  getStatusHashMap,
+  getChunkItemPayload,
+  getChunksCompletePayload,
+  getItemProgress,
+  getItemsTotalProgress,
+  addChunkToInProcess,
+  isSpaceAvailableInServer,
+  isAllChunksOfFileUploaded
+} from './utils';
+import { FileStatus, RegularFile, ChunkedFile, ChunkedFilesInProgress } from './types';
+
+jest.mock('../../../api/utils');
+const mockGet = jest.mocked(get);
+
+describe('utils', () => {
+  describe('getTotalChunk function', () => {
+    it('should calculate correct number of chunks for exact division', () => {
+      expect(getTotalChunk(1000, 100)).toBe(10);
+    });
+
+    it('should calculate correct number of chunks with remainder', () => {
+      expect(getTotalChunk(1050, 100)).toBe(11);
+    });
+
+    it('should return 1 for file smaller than chunk size', () => {
+      expect(getTotalChunk(50, 100)).toBe(1);
+    });
+
+    it('should handle zero file size', () => {
+      expect(getTotalChunk(0, 100)).toBe(0);
+    });
+
+    it('should handle single byte file', () => {
+      expect(getTotalChunk(1, 100)).toBe(1);
+    });
+  });
+
+  describe('getMetaData function', () => {
+    const mockChunkedFile: ChunkedFile = {
+      uuid: 'test-uuid',
+      filePath: '/test/path/file.txt',
+      file: new Blob(['test content']),
+      fileName: 'file.txt',
+      totalSize: 1000,
+      chunkNumber: 0,
+      chunkStartOffset: 0,
+      chunkEndOffset: 100,
+      totalChunks: 10,
+      status: FileStatus.Pending,
+      progress: 0
+    };
+
+    it('should return correct metadata object', () => {
+      const result = getMetaData(mockChunkedFile);
+
+      expect(result).toEqual({
+        qqtotalparts: '10',
+        qqtotalfilesize: '1000',
+        qqfilename: 'file.txt',
+        inputName: 'hdfs_file',
+        dest: '/test/path/file.txt',
+        qquuid: 'test-uuid'
+      });
+    });
+
+    it('should convert numbers to strings', () => {
+      const result = getMetaData(mockChunkedFile);
+
+      expect(typeof result.qqtotalparts).toBe('string');
+      expect(typeof result.qqtotalfilesize).toBe('string');
+    });
+  });
+
+  describe('createChunks function', () => {
+    const mockFile = new File(['a'.repeat(250)], 'test.txt', { type: 'text/plain' });
+    const mockRegularFile: RegularFile = {
+      uuid: 'test-uuid',
+      filePath: '/test/path/test.txt',
+      file: mockFile,
+      status: FileStatus.Pending,
+      progress: 0
+    };
+
+    it('should create correct number of chunks', () => {
+      const chunks = createChunks(mockRegularFile, 100);
+      expect(chunks).toHaveLength(3);
+      expect(chunks[0].chunkEndOffset).toBe(100);
+      expect(chunks[1].chunkEndOffset).toBe(200);
+      expect(chunks[2].chunkEndOffset).toBe(250);
+    });
+
+    it('should set correct chunk properties', () => {
+      const chunks = createChunks(mockRegularFile, 100);
+
+      expect(chunks[0]).toMatchObject({
+        uuid: 'test-uuid',
+        filePath: '/test/path/test.txt',
+        fileName: 'test.txt',
+        totalSize: 250,
+        totalChunks: 3,
+        chunkNumber: 0,
+        chunkStartOffset: 0,
+        chunkEndOffset: 100
+      });
+
+      expect(chunks[2]).toMatchObject({
+        chunkNumber: 2,
+        chunkStartOffset: 200,
+        chunkEndOffset: 250
+      });
+    });
+
+    it('should create blob chunks with correct sizes', () => {
+      const chunks = createChunks(mockRegularFile, 100);
+
+      expect(chunks[0].file.size).toBe(100);
+      expect(chunks[1].file.size).toBe(100);
+    });
+
+    it('should handle single chunk for small files', () => {
+      const smallFile = new File(['small'], 'small.txt');
+      const smallRegularFile: RegularFile = {
+        ...mockRegularFile,
+        file: smallFile
+      };
+
+      const chunks = createChunks(smallRegularFile, 100);
+      expect(chunks).toHaveLength(1);
+      expect(chunks[0].chunkStartOffset).toBe(0);
+      expect(chunks[0].chunkEndOffset).toBe(5);
+    });
+  });
+
+  describe('getStatusHashMap function', () => {
+    it('should create correct status hash map', () => {
+      const serverResponse = [
+        { taskId: 'task1', status: TaskStatus.Success, dateDone: '2023-01-01' },
+        { taskId: 'task2', status: TaskStatus.Failure, dateDone: '2023-01-02' },
+        { taskId: 'task3', status: TaskStatus.Running, dateDone: '2023-01-03' }
+      ];
+
+      const result = getStatusHashMap(serverResponse);
+
+      expect(result).toEqual({
+        task1: TaskStatus.Success,
+        task2: TaskStatus.Failure,
+        task3: TaskStatus.Running
+      });
+    });
+
+    it('should handle empty array', () => {
+      const result = getStatusHashMap([]);
+      expect(result).toEqual({});
+    });
+
+    it('should handle duplicate task IDs by keeping the last one', () => {
+      const serverResponse = [
+        { taskId: 'task1', status: TaskStatus.Success, dateDone: '2023-01-01' },
+        { taskId: 'task1', status: TaskStatus.Failure, dateDone: '2023-01-02' }
+      ];
+
+      const result = getStatusHashMap(serverResponse);
+      expect(result.task1).toBe(TaskStatus.Failure);
+    });
+  });
+
+  describe('getChunkItemPayload function', () => {
+    const mockChunkedFile: ChunkedFile = {
+      uuid: 'test-uuid',
+      filePath: '/test/path/file.txt',
+      file: new Blob(['test content']),
+      fileName: 'file.txt',
+      totalSize: 1000,
+      chunkNumber: 2,
+      chunkStartOffset: 200,
+      chunkEndOffset: 300,
+      totalChunks: 10,
+      status: FileStatus.Pending,
+      progress: 0
+    };
+
+    it('should return correct payload structure', () => {
+      const result = getChunkItemPayload(mockChunkedFile);
+
+      expect(result).toHaveProperty('url');
+      expect(result).toHaveProperty('payload');
+      expect(result.payload).toBeInstanceOf(FormData);
+    });
+
+    it('should include correct query parameters in URL', () => {
+      const result = getChunkItemPayload(mockChunkedFile);
+
+      expect(result.url).toContain('qqtotalparts=10');
+      expect(result.url).toContain('qqtotalfilesize=1000');
+      expect(result.url).toContain('qqfilename=file.txt');
+      expect(result.url).toContain('qqpartindex=2');
+      expect(result.url).toContain('qqpartbyteoffset=200');
+      expect(result.url).toContain('qqchunksize=100');
+    });
+
+    it('should append file to FormData payload', () => {
+      const result = getChunkItemPayload(mockChunkedFile);
+
+      expect(result.payload.has('hdfs_file')).toBe(true);
+    });
+  });
+
+  describe('getChunksCompletePayload function', () => {
+    const mockChunkedFile: ChunkedFile = {
+      uuid: 'test-uuid',
+      filePath: '/test/path/file.txt',
+      file: new Blob(['test content']),
+      fileName: 'file.txt',
+      totalSize: 1000,
+      chunkNumber: 0,
+      chunkStartOffset: 0,
+      chunkEndOffset: 100,
+      totalChunks: 10,
+      status: FileStatus.Pending,
+      progress: 0
+    };
+
+    it('should return correct payload structure', () => {
+      const result = getChunksCompletePayload(mockChunkedFile);
+
+      expect(result).toHaveProperty('url');
+      expect(result).toHaveProperty('payload');
+      expect(result.payload).toBeInstanceOf(FormData);
+    });
+
+    it('should include all metadata in FormData', () => {
+      const result = getChunksCompletePayload(mockChunkedFile);
+
+      expect(result.payload.has('qqtotalparts')).toBe(true);
+      expect(result.payload.has('qqtotalfilesize')).toBe(true);
+      expect(result.payload.has('qqfilename')).toBe(true);
+      expect(result.payload.has('inputName')).toBe(true);
+      expect(result.payload.has('dest')).toBe(true);
+      expect(result.payload.has('qquuid')).toBe(true);
+    });
+  });
+
+  describe('getItemProgress function', () => {
+    it('should calculate correct progress percentage', () => {
+      const progressEvent = {
+        loaded: 50,
+        total: 100
+      } as ProgressEvent;
+
+      expect(getItemProgress(progressEvent)).toBe(50);
+    });
+
+    it('should return 0 for undefined progress', () => {
+      expect(getItemProgress(undefined)).toBe(0);
+    });
+
+    it('should return 0 when total is 0', () => {
+      const progressEvent = {
+        loaded: 50,
+        total: 0
+      } as ProgressEvent;
+
+      expect(getItemProgress(progressEvent)).toBe(0);
+    });
+
+    it('should return 0 when loaded or total is missing', () => {
+      const progressEvent = {
+        loaded: 0
+      } as ProgressEvent;
+
+      expect(getItemProgress(progressEvent)).toBe(0);
+    });
+
+    it('should round to nearest integer', () => {
+      const progressEvent = {
+        loaded: 33,
+        total: 100
+      } as ProgressEvent;
+
+      expect(getItemProgress(progressEvent)).toBe(33);
+    });
+  });
+
+  describe('getItemsTotalProgress function', () => {
+    const mockChunkItem: ChunkedFile = {
+      uuid: 'test-uuid',
+      filePath: '/test/path/file.txt',
+      file: new Blob(),
+      fileName: 'file.txt',
+      totalSize: 1000,
+      chunkNumber: 0,
+      chunkStartOffset: 0,
+      chunkEndOffset: 100,
+      totalChunks: 10,
+      status: FileStatus.Pending,
+      progress: 0
+    };
+
+    const mockChunks = [
+      { chunkNumber: 0, progress: 100, chunkSize: 100 },
+      { chunkNumber: 1, progress: 50, chunkSize: 100 },
+      { chunkNumber: 2, progress: 0, chunkSize: 100 }
+    ];
+
+    it('should calculate correct total progress', () => {
+      const result = getItemsTotalProgress(mockChunkItem, mockChunks);
+      expect(result).toBe(15);
+    });
+
+    it('should return 0 when chunkItem is undefined', () => {
+      expect(getItemsTotalProgress(undefined, mockChunks)).toBe(0);
+    });
+
+    it('should return 0 when chunks is undefined', () => {
+      expect(getItemsTotalProgress(mockChunkItem, undefined)).toBe(0);
+    });
+
+    it('should handle empty chunks array', () => {
+      expect(getItemsTotalProgress(mockChunkItem, [])).toBe(0);
+    });
+  });
+
+  describe('addChunkToInProcess function', () => {
+    const mockChunkItem: ChunkedFile = {
+      uuid: 'test-uuid',
+      filePath: '/test/path/file.txt',
+      file: new Blob(['content']),
+      fileName: 'file.txt',
+      totalSize: 1000,
+      chunkNumber: 0,
+      chunkStartOffset: 0,
+      chunkEndOffset: 100,
+      totalChunks: 10,
+      status: FileStatus.Pending,
+      progress: 0
+    };
+
+    it('should add new chunk to empty in-process object', () => {
+      const inProcess: ChunkedFilesInProgress = {};
+      const result = addChunkToInProcess(inProcess, mockChunkItem);
+
+      expect(result['test-uuid']).toHaveLength(1);
+      expect(result['test-uuid'][0]).toEqual({
+        chunkNumber: 0,
+        progress: 0,
+        chunkSize: 7
+      });
+    });
+
+    it('should add chunk to existing file chunks', () => {
+      const inProcess: ChunkedFilesInProgress = {
+        'test-uuid': [{ chunkNumber: 0, progress: 100, chunkSize: 100 }]
+      };
+
+      const newChunk = { ...mockChunkItem, chunkNumber: 1 };
+      const result = addChunkToInProcess(inProcess, newChunk);
+
+      expect(result['test-uuid']).toHaveLength(2);
+      expect(result['test-uuid'][1].chunkNumber).toBe(1);
+    });
+
+    it('should not affect other files in progress', () => {
+      const inProcess: ChunkedFilesInProgress = {
+        'other-uuid': [{ chunkNumber: 0, progress: 50, chunkSize: 100 }]
+      };
+
+      const result = addChunkToInProcess(inProcess, mockChunkItem);
+
+      expect(result['other-uuid']).toHaveLength(1);
+      expect(result['test-uuid']).toHaveLength(1);
+    });
+  });
+
+  describe('isSpaceAvailableInServer function', () => {
+    beforeEach(() => {
+      jest.clearAllMocks();
+    });
+
+    it('should return true when sufficient space is available', async () => {
+      mockGet.mockResolvedValue({
+        upload_available_space: 1000
+      });
+
+      const result = await isSpaceAvailableInServer(500);
+      expect(result).toBe(true);
+    });
+
+    it('should return false when insufficient space is available', async () => {
+      mockGet.mockResolvedValue({
+        upload_available_space: 100
+      });
+
+      const result = await isSpaceAvailableInServer(500);
+      expect(result).toBe(false);
+    });
+
+    it('should return false when upload_available_space is 0', async () => {
+      mockGet.mockResolvedValue({
+        upload_available_space: 0
+      });
+
+      const result = await isSpaceAvailableInServer(500);
+      expect(result).toBe(false);
+    });
+
+    it('should return false when upload_available_space is missing', async () => {
+      mockGet.mockResolvedValue({});
+
+      const result = await isSpaceAvailableInServer(500);
+      expect(result).toBe(false);
+    });
+
+    it('should return false when API call fails', async () => {
+      mockGet.mockResolvedValue(null);
+
+      const result = await isSpaceAvailableInServer(500);
+      expect(result).toBe(false);
+    });
+  });
+
+  describe('isAllChunksOfFileUploaded function', () => {
+    const mockChunk: ChunkedFile = {
+      uuid: 'test-uuid',
+      filePath: '/test/path/file.txt',
+      file: new Blob(),
+      fileName: 'file.txt',
+      totalSize: 1000,
+      chunkNumber: 0,
+      chunkStartOffset: 0,
+      chunkEndOffset: 100,
+      totalChunks: 3,
+      status: FileStatus.Pending,
+      progress: 0
+    };
+
+    it('should return true when all chunks are uploaded', () => {
+      const filesInProgress: ChunkedFilesInProgress = {
+        'test-uuid': [
+          { chunkNumber: 0, progress: 100, chunkSize: 100 },
+          { chunkNumber: 1, progress: 100, chunkSize: 100 },
+          { chunkNumber: 2, progress: 100, chunkSize: 100 }
+        ]
+      };
+
+      const result = isAllChunksOfFileUploaded(filesInProgress, mockChunk);
+      expect(result).toBe(true);
+    });
+
+    it('should return false when chunk count does not match', () => {
+      const filesInProgress: ChunkedFilesInProgress = {
+        'test-uuid': [
+          { chunkNumber: 0, progress: 100, chunkSize: 100 },
+          { chunkNumber: 1, progress: 100, chunkSize: 100 }
+        ]
+      };
+
+      const result = isAllChunksOfFileUploaded(filesInProgress, mockChunk);
+      expect(result).toBe(false);
+    });
+
+    it('should return false when some chunks are not fully uploaded', () => {
+      const filesInProgress: ChunkedFilesInProgress = {
+        'test-uuid': [
+          { chunkNumber: 0, progress: 100, chunkSize: 100 },
+          { chunkNumber: 2, progress: 100, chunkSize: 100 }
+        ]
+      };
+
+      const result = isAllChunksOfFileUploaded(filesInProgress, mockChunk);
+      expect(result).toBe(false);
+    });
+
+    it('should throw error when file is not in progress (current implementation behavior)', () => {
+      const filesInProgress: ChunkedFilesInProgress = {};
+
+      expect(() => {
+        isAllChunksOfFileUploaded(filesInProgress, mockChunk);
+      }).toThrow("Cannot read properties of undefined (reading 'length')");
+    });
+
+    it('should handle empty chunks array', () => {
+      const filesInProgress: ChunkedFilesInProgress = {
+        'test-uuid': []
+      };
+
+      const mockChunkWithZeroTotal = { ...mockChunk, totalChunks: 0 };
+      const result = isAllChunksOfFileUploaded(filesInProgress, mockChunkWithZeroTotal);
+      expect(result).toBe(true);
+    });
+  });
+});

+ 1 - 1
desktop/core/src/desktop/js/utils/hooks/useFileUpload/utils.ts

@@ -43,7 +43,7 @@ export const getMetaData = (item: ChunkedFile): FileChunkMetaData => ({
 });
 
 export const createChunks = (item: RegularFile, chunkSize: number): ChunkedFile[] => {
-  const totalChunks = getTotalChunk(item.file.size, chunkSize);
+  const totalChunks = Math.max(1, getTotalChunk(item.file.size, chunkSize));
 
   const chunks = Array.from({ length: totalChunks }, (_, i) => {
     const chunkStartOffset = i * chunkSize;

+ 103 - 1
desktop/core/src/desktop/js/utils/hooks/useLoadData/useLoadData.test.tsx

@@ -19,7 +19,6 @@ import useLoadData from './useLoadData';
 import { get } from '../../../api/utils';
 import { convertKeysToCamelCase } from '../../string/changeCasing';
 
-// Mock the `get` function
 jest.mock('../../../api/utils', () => ({
   get: jest.fn()
 }));
@@ -221,6 +220,109 @@ describe('useLoadData', () => {
     });
   });
 
+  it('should force fetch data when reloadData is called even with skip option', async () => {
+    const { result } = renderHook(() => useLoadData(mockUrl, { skip: true }));
+
+    expect(result.current.data).toBeUndefined();
+    expect(result.current.error).toBeUndefined();
+    expect(result.current.loading).toBe(false);
+    expect(mockGet).not.toHaveBeenCalled();
+
+    await act(async () => {
+      await result.current.reloadData();
+    });
+
+    await waitFor(() => {
+      expect(mockGet).toHaveBeenCalledWith(mockUrl, undefined, expect.any(Object));
+      expect(result.current.data).toEqual(mockDataResponse);
+      expect(result.current.loading).toBe(false);
+    });
+  });
+
+  it('should poll data at specified interval', async () => {
+    jest.useFakeTimers();
+
+    const pollInterval = 5000;
+    const { result } = renderHook(() => useLoadData(mockUrl, { pollInterval }));
+
+    await waitFor(() => {
+      expect(mockGet).toHaveBeenCalledTimes(1);
+      expect(result.current.data).toEqual(mockDataResponse);
+    });
+
+    mockGet.mockClear();
+
+    act(() => {
+      jest.advanceTimersByTime(pollInterval);
+    });
+
+    await waitFor(() => {
+      expect(mockGet).toHaveBeenCalledTimes(1);
+    });
+
+    mockGet.mockClear();
+    act(() => {
+      jest.advanceTimersByTime(pollInterval);
+    });
+
+    await waitFor(() => {
+      expect(mockGet).toHaveBeenCalledTimes(1);
+    });
+
+    jest.useRealTimers();
+  });
+
+  it('should clear polling interval when component unmounts', async () => {
+    jest.useFakeTimers();
+
+    const clearIntervalSpy = jest.spyOn(global, 'clearInterval');
+
+    const pollInterval = 5000; // 5 seconds
+    const { result, unmount } = renderHook(() => useLoadData(mockUrl, { pollInterval }));
+
+    await waitFor(() => {
+      expect(result.current.data).toEqual(mockDataResponse);
+    });
+
+    unmount();
+
+    expect(clearIntervalSpy).toHaveBeenCalled();
+
+    clearIntervalSpy.mockRestore();
+    jest.useRealTimers();
+  });
+
+  it('should clear previous interval when pollInterval changes', async () => {
+    jest.useFakeTimers();
+
+    const clearIntervalSpy = jest.spyOn(global, 'clearInterval');
+    const setIntervalSpy = jest.spyOn(global, 'setInterval');
+
+    const { result, rerender } = renderHook(
+      (props: { pollInterval?: number }) =>
+        useLoadData(mockUrl, { pollInterval: props.pollInterval }),
+      { initialProps: { pollInterval: 5000 } }
+    );
+
+    await waitFor(() => {
+      expect(result.current.data).toEqual(mockDataResponse);
+    });
+
+    expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 5000);
+
+    rerender({ pollInterval: 10000 });
+
+    await waitFor(() => {
+      expect(result.current.data).toEqual(mockDataResponse);
+      expect(clearIntervalSpy).toHaveBeenCalled();
+      expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 10000);
+    });
+
+    clearIntervalSpy.mockRestore();
+    setIntervalSpy.mockRestore();
+    jest.useRealTimers();
+  });
+
   describe('transformKeys option', () => {
     it('should fetch data and transform keys to camelCase when transformKeys is "camelCase"', async () => {
       const { result } = renderHook(() =>

+ 11 - 18
desktop/core/src/desktop/js/utils/hooks/useSaveData/useSaveData.test.tsx

@@ -30,19 +30,12 @@ const mockData = { id: 1, product: 'Hue' };
 const mockBody = { id: 1 };
 
 describe('useSaveData', () => {
-  beforeAll(() => {
-    jest.clearAllMocks();
-  });
-
   beforeEach(() => {
-    mockPost.mockResolvedValue(mockData);
-  });
-
-  afterEach(() => {
     jest.clearAllMocks();
+    mockPost.mockResolvedValue(mockData);
   });
 
-  it('should save data with body successfully', async () => {
+  it('should save data successfully and update state', async () => {
     const { result } = renderHook(() => useSaveData(mockUrl));
 
     expect(result.current.data).toBeUndefined();
@@ -64,7 +57,7 @@ describe('useSaveData', () => {
     });
   });
 
-  it('should handle save errors', async () => {
+  it('should handle errors and update error state', async () => {
     const mockError = new Error('Save error');
     mockPost.mockRejectedValue(mockError);
 
@@ -88,7 +81,7 @@ describe('useSaveData', () => {
     });
   });
 
-  it('should respect the skip option', () => {
+  it('should not call post when skip option is true', () => {
     const { result } = renderHook(() => useSaveData(mockUrl, { skip: true }));
 
     act(() => {
@@ -101,7 +94,7 @@ describe('useSaveData', () => {
     expect(mockPost).not.toHaveBeenCalled();
   });
 
-  it('should update options correctly', async () => {
+  it('should update options when props change', async () => {
     const { result, rerender } = renderHook((props: { url: string }) => useSaveData(props.url), {
       initialProps: { url: mockUrl }
     });
@@ -143,7 +136,7 @@ describe('useSaveData', () => {
     });
   });
 
-  it('should call onSuccess callback', async () => {
+  it('should call onSuccess callback when provided', async () => {
     const mockOnSuccess = jest.fn();
     const mockOnError = jest.fn();
     const { result } = renderHook(() =>
@@ -173,7 +166,7 @@ describe('useSaveData', () => {
     });
   });
 
-  it('should call onError callback', async () => {
+  it('should call onError callback when provided', async () => {
     const mockError = new Error('Save error');
     mockPost.mockRejectedValue(mockError);
 
@@ -206,7 +199,7 @@ describe('useSaveData', () => {
     });
   });
 
-  it('should auto set qsEncodeData to true when body is not a FormData or JSON', async () => {
+  it('should auto set qsEncodeData to true for non-JSON and non-FormData payloads', async () => {
     const { result } = renderHook(() => useSaveData(mockUrl));
 
     act(() => {
@@ -225,7 +218,7 @@ describe('useSaveData', () => {
     });
   });
 
-  it('should not auto set qsEncodeData to true when body is a FormData', async () => {
+  it('should not auto set qsEncodeData for FormData payloads', async () => {
     const payload = new FormData();
 
     const { result } = renderHook(() => useSaveData(mockUrl));
@@ -246,7 +239,7 @@ describe('useSaveData', () => {
     });
   });
 
-  it('should not auto set qsEncodeData to true when body is a JSON', async () => {
+  it('should not auto set qsEncodeData for JSON payloads', async () => {
     const payload = { project: 'hue' };
 
     const { result } = renderHook(() => useSaveData(mockUrl));
@@ -267,7 +260,7 @@ describe('useSaveData', () => {
     });
   });
 
-  it('should take qsEncodeData value from saveOptions.postOptions when available', async () => {
+  it('should prioritize qsEncodeData from saveOptions.postOptions', async () => {
     const payload = new FormData();
 
     const { result } = renderHook(() =>