Skip to content

Commit

Permalink
Merge pull request #1354 from nickgros/SWC-7064c
Browse files Browse the repository at this point in the history
  • Loading branch information
nickgros authored Nov 5, 2024
2 parents 30072f5 + 16a2094 commit 5808f71
Show file tree
Hide file tree
Showing 3 changed files with 286 additions and 4 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,7 @@ export function getUseQueryErrorMock<TError>(
}
}

export function getUseMutationMock<TData, TError, TVariables>(
data: TData,
): UseMutationResult<TData, TError, TVariables> {
export function getUseMutationMock<TData, TError, TVariables>(data: TData) {
return {
context: undefined,
data: undefined,
Expand All @@ -119,5 +117,5 @@ export function getUseMutationMock<TData, TError, TVariables>(
failureReason: null,
isPending: false,
submittedAt: 0,
}
} satisfies UseMutationResult<TData, TError, TVariables>
}
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.',
)
})
})
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 }
},
})
}

0 comments on commit 5808f71

Please sign in to comment.