diff --git a/packages/synapse-react-client/src/testutils/ReactQueryMockUtils.ts b/packages/synapse-react-client/src/testutils/ReactQueryMockUtils.ts index 032e1354e6..ef64dc50cd 100644 --- a/packages/synapse-react-client/src/testutils/ReactQueryMockUtils.ts +++ b/packages/synapse-react-client/src/testutils/ReactQueryMockUtils.ts @@ -99,9 +99,7 @@ export function getUseQueryErrorMock( } } -export function getUseMutationMock( - data: TData, -): UseMutationResult { +export function getUseMutationMock(data: TData) { return { context: undefined, data: undefined, @@ -119,5 +117,5 @@ export function getUseMutationMock( failureReason: null, isPending: false, submittedAt: 0, - } + } satisfies UseMutationResult } diff --git a/packages/synapse-react-client/src/utils/hooks/useUploadFileEntity/usePrepareFileEntityUpload.test.ts b/packages/synapse-react-client/src/utils/hooks/useUploadFileEntity/usePrepareFileEntityUpload.test.ts new file mode 100644 index 0000000000..5e7b69a5fe --- /dev/null +++ b/packages/synapse-react-client/src/utils/hooks/useUploadFileEntity/usePrepareFileEntityUpload.test.ts @@ -0,0 +1,171 @@ +import { renderHook as _renderHook } from '@testing-library/react' +import { MOCK_CONTEXT_VALUE } from '../../../mocks/MockSynapseContext' +import { getUseMutationMock } from '../../../testutils/ReactQueryMockUtils' +import { createWrapper } from '../../../testutils/TestingLibraryUtils' +import * as GetFileEntityIdWithSameNameModule from './getFileEntityIdWithSameName' +import * as UseCreatePathsAndGetParentIdModule from './useCreatePathsAndGetParentId' + +import { usePrepareFileEntityUpload } from './usePrepareFileEntityUpload' + +const mockUseCreatePathsAndGetParentId = jest.spyOn( + UseCreatePathsAndGetParentIdModule, + 'useCreatePathsAndGetParentId', +) +const mockGetFileEntityIdWithSameName = jest.spyOn( + GetFileEntityIdWithSameNameModule, + 'getFileEntityIdWithSameName', +) + +describe('usePrepareFileEntityUpload', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + function renderHook() { + return _renderHook(() => usePrepareFileEntityUpload(), { + wrapper: createWrapper(), + }) + } + const newFile = new File([''], 'newFile.txt') + const updatedFile = new File([''], 'existingFile.txt') + const files = [newFile, updatedFile] + const parentId = 'syn123' + const updatedEntityId = 'syn456' + + test('prepares a new file entity for upload and an existing file entity for update', async () => { + const useCreatePathsAndGetParentIdMockResult = getUseMutationMock(null) + useCreatePathsAndGetParentIdMockResult.mutateAsync.mockImplementation( + ({ file }) => ({ file, parentId }), + ) + mockUseCreatePathsAndGetParentId.mockReturnValue( + useCreatePathsAndGetParentIdMockResult, + ) + + mockGetFileEntityIdWithSameName.mockImplementation(fileName => { + if (fileName === 'newFile.txt') { + return Promise.resolve(null) + } else return Promise.resolve(updatedEntityId) + }) + + const { result: hook } = renderHook() + const result = await hook.current.mutateAsync({ + files: files, + parentId: parentId, + }) + + expect(result).toEqual({ + newFileEntities: [ + { file: newFile, parentId: parentId, existingEntityId: null }, + ], + updatedFileEntities: [ + { + file: updatedFile, + parentId: parentId, + existingEntityId: updatedEntityId, + }, + ], + }) + + expect( + useCreatePathsAndGetParentIdMockResult.mutateAsync, + ).toHaveBeenCalledTimes(2) + expect( + useCreatePathsAndGetParentIdMockResult.mutateAsync, + ).toHaveBeenNthCalledWith(1, { file: newFile, parentId }) + expect( + useCreatePathsAndGetParentIdMockResult.mutateAsync, + ).toHaveBeenNthCalledWith(2, { file: updatedFile, parentId }) + + expect(mockGetFileEntityIdWithSameName).toHaveBeenCalledTimes(2) + expect(mockGetFileEntityIdWithSameName).toHaveBeenCalledWith( + 'newFile.txt', + parentId, + MOCK_CONTEXT_VALUE.synapseClient, + 'The file could not be uploaded.', + ) + expect(mockGetFileEntityIdWithSameName).toHaveBeenCalledWith( + 'existingFile.txt', + parentId, + MOCK_CONTEXT_VALUE.synapseClient, + 'The file could not be uploaded.', + ) + }) + + test('creating directory fails', async () => { + const useCreatePathsAndGetParentIdMockResult = getUseMutationMock(null) + useCreatePathsAndGetParentIdMockResult.mutateAsync.mockRejectedValue( + new Error('Failed to create directory'), + ) + mockUseCreatePathsAndGetParentId.mockReturnValue( + useCreatePathsAndGetParentIdMockResult, + ) + + const { result: hook } = renderHook() + await expect( + hook.current.mutateAsync({ + files: files, + parentId: parentId, + }), + ).rejects.toThrow( + `Unable to create target folder structure for file newFile.txt: Failed to create directory`, + ) + + expect( + useCreatePathsAndGetParentIdMockResult.mutateAsync, + ).toHaveBeenCalledTimes(1) + expect( + useCreatePathsAndGetParentIdMockResult.mutateAsync, + ).toHaveBeenCalledWith({ file: newFile, parentId }) + + expect(mockGetFileEntityIdWithSameName).not.toHaveBeenCalled() + }) + + test('file lookup fails', async () => { + const useCreatePathsAndGetParentIdMockResult = getUseMutationMock(null) + useCreatePathsAndGetParentIdMockResult.mutateAsync.mockImplementation( + ({ file }) => ({ file, parentId }), + ) + mockUseCreatePathsAndGetParentId.mockReturnValue( + useCreatePathsAndGetParentIdMockResult, + ) + + mockGetFileEntityIdWithSameName.mockImplementation(fileName => + Promise.reject(new Error(`Failed to lookup entity ${fileName}`)), + ) + + const { result: hook } = renderHook() + + await expect( + hook.current.mutateAsync({ + files: files, + parentId: parentId, + }), + ).rejects.toThrow( + `Files could not be uploaded:\n\tFailed to lookup entity newFile.txt\n\tFailed to lookup entity existingFile.txt`, + ) + + expect( + useCreatePathsAndGetParentIdMockResult.mutateAsync, + ).toHaveBeenCalledTimes(2) + expect( + useCreatePathsAndGetParentIdMockResult.mutateAsync, + ).toHaveBeenNthCalledWith(1, { file: newFile, parentId }) + expect( + useCreatePathsAndGetParentIdMockResult.mutateAsync, + ).toHaveBeenNthCalledWith(2, { file: updatedFile, parentId }) + + expect(mockGetFileEntityIdWithSameName).toHaveBeenCalledTimes(2) + expect(mockGetFileEntityIdWithSameName).toHaveBeenCalledWith( + 'newFile.txt', + parentId, + MOCK_CONTEXT_VALUE.synapseClient, + 'The file could not be uploaded.', + ) + expect(mockGetFileEntityIdWithSameName).toHaveBeenCalledWith( + 'existingFile.txt', + parentId, + MOCK_CONTEXT_VALUE.synapseClient, + 'The file could not be uploaded.', + ) + }) +}) diff --git a/packages/synapse-react-client/src/utils/hooks/useUploadFileEntity/usePrepareFileEntityUpload.ts b/packages/synapse-react-client/src/utils/hooks/useUploadFileEntity/usePrepareFileEntityUpload.ts new file mode 100644 index 0000000000..85e4b515dc --- /dev/null +++ b/packages/synapse-react-client/src/utils/hooks/useUploadFileEntity/usePrepareFileEntityUpload.ts @@ -0,0 +1,113 @@ +import { SynapseClientError } from '@sage-bionetworks/synapse-client' +import { useMutation, UseMutationOptions } from '@tanstack/react-query' +import { useSynapseContext } from '../../context/index' +import { getFileEntityIdWithSameName } from './getFileEntityIdWithSameName' +import { useCreatePathsAndGetParentId } from './useCreatePathsAndGetParentId' + +export type FilePreparedForUpload = { + file: File + parentId: string + existingEntityId: string | null +} + +export type PrepareDirsForUploadReturn = { + newFileEntities: FilePreparedForUpload[] + updatedFileEntities: FilePreparedForUpload[] +} + +/** + * Mutation used to check and prepare the entity tree just before uploading a list of files. + * + * Given a list of files and a parent ID in which the files should be uploaded, the mutation will: + * + * 1. Create the necessary Folder entities in which the files to be uploaded (e.g. if the user uploaded a folder) + * 2. Check if any of the files to be uploaded already exist in the target parent folder + * + * The mutation will return two lists of files and their destination parentIds: + * - newFileEntities: New files that do not have corresponding file entities in the target parent folder + * - updatedFileEntities: Files that have corresponding file entities in the target parent folder. The user + * should be prompted to accept or reject the creation of a new version of the file. + * + * In the future, this sequence could be amended to check if the storage location has enough space to accommodate all the + * new files, and return an error if not. + * + * @param options + */ +export function usePrepareFileEntityUpload( + options?: Partial< + UseMutationOptions< + PrepareDirsForUploadReturn, + SynapseClientError, + { files: File[]; parentId: string } + > + >, +) { + const { synapseClient } = useSynapseContext() + const { mutateAsync: createDirsForFileList } = useCreatePathsAndGetParentId() + + return useMutation({ + ...options, + mutationFn: async (args: { files: File[]; parentId: string }) => { + const { files, parentId } = args + + // 1. Create directories for the files as needed + const fileAndParentIds: { file: File; parentId: string }[] = [] + for (const file of files) { + try { + // Create the directories serially; if multiple files are uploaded, they may share new directories + // Creating folders in parallel could cause race conditions + fileAndParentIds.push(await createDirsForFileList({ file, parentId })) + } catch (e) { + throw new Error( + `Unable to create target folder structure for file ${file.name}${ + Object.hasOwn(e, 'message') ? `: ${e.message}` : null + }`, + { cause: e }, + ) + } + } + + // 2. Check for existing files, and prompt the user if a new version should be created + const getExistingFilesResults = await Promise.allSettled( + fileAndParentIds.map(fileAndParentId => + getFileEntityIdWithSameName( + fileAndParentId.file.name, + fileAndParentId.parentId, + synapseClient, + 'The file could not be uploaded.', + ).then(existingEntityId => ({ + ...fileAndParentId, + existingEntityId, + })), + ), + ) + + const filesWithError: PromiseRejectedResult[] = + getExistingFilesResults.filter(promise => promise.status === 'rejected') + + if (filesWithError.length > 0) { + throw new Error( + `Files could not be uploaded:\n\t${filesWithError + .map(promise => (promise.reason as Error).message) + .join('\n\t')}`, + ) + } + + // Split the results into new and updated files + const filesPreparedForUpload = getExistingFilesResults + // All of these promises are fulfilled -- we use this filter to narrow the type + .filter(promise => promise.status === 'fulfilled') + .map(promise => promise.value) + + const newFileEntities = filesPreparedForUpload.filter( + f => f.existingEntityId == null, + ) + + const updatedFileEntities = filesPreparedForUpload.filter( + f => f.existingEntityId != null, + ) + + return { newFileEntities, updatedFileEntities } + }, + }) +}