-
Notifications
You must be signed in to change notification settings - Fork 23
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1354 from nickgros/SWC-7064c
- Loading branch information
Showing
3 changed files
with
286 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
171 changes: 171 additions & 0 deletions
171
...napse-react-client/src/utils/hooks/useUploadFileEntity/usePrepareFileEntityUpload.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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.', | ||
) | ||
}) | ||
}) |
113 changes: 113 additions & 0 deletions
113
...es/synapse-react-client/src/utils/hooks/useUploadFileEntity/usePrepareFileEntityUpload.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 } | ||
}, | ||
}) | ||
} |