diff --git a/src/__test__/utils/upload/__snapshots__/process10XUpload.test.js.snap b/src/__test__/utils/upload/__snapshots__/process10XUpload.test.js.snap index 039e75a22e..8d69fa1852 100644 --- a/src/__test__/utils/upload/__snapshots__/process10XUpload.test.js.snap +++ b/src/__test__/utils/upload/__snapshots__/process10XUpload.test.js.snap @@ -1,5 +1,230 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`processUpload Should not upload files if there are errors beginning the multipart upload in the api: fetch calls 1`] = ` +[ + [ + "http://localhost:3000/v2/experiments/project-uuid/samples", + { + "body": "[{"name":"WT13","sampleTechnology":"10x","options":{}}]", + "headers": { + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], + [ + "http://localhost:3000/v2/experiments/project-uuid/samples/mockSampleId/sampleFiles/features10x", + { + "body": "{"sampleFileId":"mockSampleFileId","size":1024}", + "headers": { + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], + [ + "http://localhost:3000/v2/experiments/project-uuid/samples/mockSampleId/sampleFiles/barcodes10x", + { + "body": "{"sampleFileId":"mockSampleFileId","size":1024}", + "headers": { + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], + [ + "http://localhost:3000/v2/experiments/project-uuid/samples/mockSampleId/sampleFiles/matrix10x", + { + "body": "{"sampleFileId":"mockSampleFileId","size":1024}", + "headers": { + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], + [ + "http://localhost:3000/v2/experiments/project-uuid/sampleFiles/mockSampleFileId/beginUpload", + { + "body": "{"size":1024,"metadata":{"cellranger_version":"v3"}}", + "headers": { + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], + [ + "http://localhost:3000/v2/experiments/project-uuid/sampleFiles/mockSampleFileId/beginUpload", + { + "body": "{"size":1024,"metadata":{}}", + "headers": { + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], + [ + "http://localhost:3000/v2/experiments/project-uuid/sampleFiles/mockSampleFileId/beginUpload", + { + "body": "{"size":1024,"metadata":{}}", + "headers": { + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], + [ + "http://localhost:3000/v2/experiments/project-uuid/samples/mockSampleId/sampleFiles/features10x", + { + "body": "{"uploadStatus":"uploadError"}", + "headers": { + "Content-Type": "application/json", + }, + "method": "PATCH", + }, + ], + [ + "http://localhost:3000/v2/experiments/project-uuid/samples/mockSampleId/sampleFiles/barcodes10x", + { + "body": "{"uploadStatus":"uploadError"}", + "headers": { + "Content-Type": "application/json", + }, + "method": "PATCH", + }, + ], + [ + "http://localhost:3000/v2/experiments/project-uuid/samples/mockSampleId/sampleFiles/matrix10x", + { + "body": "{"uploadStatus":"uploadError"}", + "headers": { + "Content-Type": "application/json", + }, + "method": "PATCH", + }, + ], +] +`; + +exports[`processUpload Should not upload files if there are errors creating samples in the api: fetch calls 1`] = ` +[ + [ + "http://localhost:3000/v2/experiments/project-uuid/samples", + { + "body": "[{"name":"WT13","sampleTechnology":"10x","options":{}}]", + "headers": { + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], +] +`; + +exports[`processUpload Updates redux correctly when there are file upload errors: fetch calls 1`] = ` +[ + [ + "http://localhost:3000/v2/experiments/project-uuid/samples", + { + "body": "[{"name":"WT13","sampleTechnology":"10x","options":{}}]", + "headers": { + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], + [ + "http://localhost:3000/v2/experiments/project-uuid/samples/mockSampleId/sampleFiles/features10x", + { + "body": "{"sampleFileId":"mockSampleFileId","size":1024}", + "headers": { + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], + [ + "http://localhost:3000/v2/experiments/project-uuid/samples/mockSampleId/sampleFiles/barcodes10x", + { + "body": "{"sampleFileId":"mockSampleFileId","size":1024}", + "headers": { + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], + [ + "http://localhost:3000/v2/experiments/project-uuid/samples/mockSampleId/sampleFiles/matrix10x", + { + "body": "{"sampleFileId":"mockSampleFileId","size":1024}", + "headers": { + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], + [ + "http://localhost:3000/v2/experiments/project-uuid/sampleFiles/mockSampleFileId/beginUpload", + { + "body": "{"size":1024,"metadata":{"cellranger_version":"v3"}}", + "headers": { + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], + [ + "http://localhost:3000/v2/experiments/project-uuid/sampleFiles/mockSampleFileId/beginUpload", + { + "body": "{"size":1024,"metadata":{}}", + "headers": { + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], + [ + "http://localhost:3000/v2/experiments/project-uuid/sampleFiles/mockSampleFileId/beginUpload", + { + "body": "{"size":1024,"metadata":{}}", + "headers": { + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], + [ + "http://localhost:3000/v2/experiments/project-uuid/samples/mockSampleId/sampleFiles/features10x", + { + "body": "{"uploadStatus":"uploadError"}", + "headers": { + "Content-Type": "application/json", + }, + "method": "PATCH", + }, + ], + [ + "http://localhost:3000/v2/experiments/project-uuid/samples/mockSampleId/sampleFiles/barcodes10x", + { + "body": "{"uploadStatus":"uploadError"}", + "headers": { + "Content-Type": "application/json", + }, + "method": "PATCH", + }, + ], + [ + "http://localhost:3000/v2/experiments/project-uuid/samples/mockSampleId/sampleFiles/matrix10x", + { + "body": "{"uploadStatus":"uploadError"}", + "headers": { + "Content-Type": "application/json", + }, + "method": "PATCH", + }, + ], +] +`; + exports[`processUpload Uploads and updates redux correctly when there are no errors with cellranger v2 1`] = ` [ { @@ -32,6 +257,141 @@ exports[`processUpload Uploads and updates redux correctly when there are no err ] `; +exports[`processUpload Uploads and updates redux correctly when there are no errors with cellranger v2: fetch calls 1`] = ` +[ + [ + "http://localhost:3000/v2/experiments/project-uuid/samples", + { + "body": "[{"name":"WT13","sampleTechnology":"10x","options":{}}]", + "headers": { + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], + [ + "http://localhost:3000/v2/experiments/project-uuid/samples/mockSampleId/sampleFiles/features10x", + { + "body": "{"sampleFileId":"mockSampleFileId","size":1024}", + "headers": { + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], + [ + "http://localhost:3000/v2/experiments/project-uuid/samples/mockSampleId/sampleFiles/barcodes10x", + { + "body": "{"sampleFileId":"mockSampleFileId","size":1024}", + "headers": { + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], + [ + "http://localhost:3000/v2/experiments/project-uuid/samples/mockSampleId/sampleFiles/matrix10x", + { + "body": "{"sampleFileId":"mockSampleFileId","size":1024}", + "headers": { + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], + [ + "http://localhost:3000/v2/experiments/project-uuid/sampleFiles/mockSampleFileId/beginUpload", + { + "body": "{"size":1024,"metadata":{"cellranger_version":"v2"}}", + "headers": { + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], + [ + "http://localhost:3000/v2/experiments/project-uuid/sampleFiles/mockSampleFileId/beginUpload", + { + "body": "{"size":1024,"metadata":{}}", + "headers": { + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], + [ + "http://localhost:3000/v2/experiments/project-uuid/sampleFiles/mockSampleFileId/beginUpload", + { + "body": "{"size":1024,"metadata":{}}", + "headers": { + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], + [ + "http://localhost:3000/v2/completeMultipartUpload", + { + "body": "{"parts":[{"ETag":"etag-blah","PartNumber":1}],"uploadId":"some_id","fileId":"mockSampleFileId","type":"sample"}", + "headers": { + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], + [ + "http://localhost:3000/v2/completeMultipartUpload", + { + "body": "{"parts":[{"ETag":"etag-blah","PartNumber":1}],"uploadId":"some_id","fileId":"mockSampleFileId","type":"sample"}", + "headers": { + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], + [ + "http://localhost:3000/v2/completeMultipartUpload", + { + "body": "{"parts":[{"ETag":"etag-blah","PartNumber":1}],"uploadId":"some_id","fileId":"mockSampleFileId","type":"sample"}", + "headers": { + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], + [ + "http://localhost:3000/v2/experiments/project-uuid/samples/mockSampleId/sampleFiles/features10x", + { + "body": "{"uploadStatus":"uploaded"}", + "headers": { + "Content-Type": "application/json", + }, + "method": "PATCH", + }, + ], + [ + "http://localhost:3000/v2/experiments/project-uuid/samples/mockSampleId/sampleFiles/barcodes10x", + { + "body": "{"uploadStatus":"uploaded"}", + "headers": { + "Content-Type": "application/json", + }, + "method": "PATCH", + }, + ], + [ + "http://localhost:3000/v2/experiments/project-uuid/samples/mockSampleId/sampleFiles/matrix10x", + { + "body": "{"uploadStatus":"uploaded"}", + "headers": { + "Content-Type": "application/json", + }, + "method": "PATCH", + }, + ], +] +`; + exports[`processUpload Uploads and updates redux correctly when there are no errors with cellranger v3 1`] = ` [ { @@ -63,3 +423,138 @@ exports[`processUpload Uploads and updates redux correctly when there are no err }, ] `; + +exports[`processUpload Uploads and updates redux correctly when there are no errors with cellranger v3: fetch calls 1`] = ` +[ + [ + "http://localhost:3000/v2/experiments/project-uuid/samples", + { + "body": "[{"name":"WT13","sampleTechnology":"10x","options":{}}]", + "headers": { + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], + [ + "http://localhost:3000/v2/experiments/project-uuid/samples/mockSampleId/sampleFiles/features10x", + { + "body": "{"sampleFileId":"mockSampleFileId","size":1024}", + "headers": { + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], + [ + "http://localhost:3000/v2/experiments/project-uuid/samples/mockSampleId/sampleFiles/barcodes10x", + { + "body": "{"sampleFileId":"mockSampleFileId","size":1024}", + "headers": { + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], + [ + "http://localhost:3000/v2/experiments/project-uuid/samples/mockSampleId/sampleFiles/matrix10x", + { + "body": "{"sampleFileId":"mockSampleFileId","size":1024}", + "headers": { + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], + [ + "http://localhost:3000/v2/experiments/project-uuid/sampleFiles/mockSampleFileId/beginUpload", + { + "body": "{"size":1024,"metadata":{"cellranger_version":"v3"}}", + "headers": { + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], + [ + "http://localhost:3000/v2/experiments/project-uuid/sampleFiles/mockSampleFileId/beginUpload", + { + "body": "{"size":1024,"metadata":{}}", + "headers": { + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], + [ + "http://localhost:3000/v2/experiments/project-uuid/sampleFiles/mockSampleFileId/beginUpload", + { + "body": "{"size":1024,"metadata":{}}", + "headers": { + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], + [ + "http://localhost:3000/v2/completeMultipartUpload", + { + "body": "{"parts":[{"ETag":"etag-blah","PartNumber":1}],"uploadId":"some_id","fileId":"mockSampleFileId","type":"sample"}", + "headers": { + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], + [ + "http://localhost:3000/v2/completeMultipartUpload", + { + "body": "{"parts":[{"ETag":"etag-blah","PartNumber":1}],"uploadId":"some_id","fileId":"mockSampleFileId","type":"sample"}", + "headers": { + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], + [ + "http://localhost:3000/v2/completeMultipartUpload", + { + "body": "{"parts":[{"ETag":"etag-blah","PartNumber":1}],"uploadId":"some_id","fileId":"mockSampleFileId","type":"sample"}", + "headers": { + "Content-Type": "application/json", + }, + "method": "POST", + }, + ], + [ + "http://localhost:3000/v2/experiments/project-uuid/samples/mockSampleId/sampleFiles/features10x", + { + "body": "{"uploadStatus":"uploaded"}", + "headers": { + "Content-Type": "application/json", + }, + "method": "PATCH", + }, + ], + [ + "http://localhost:3000/v2/experiments/project-uuid/samples/mockSampleId/sampleFiles/barcodes10x", + { + "body": "{"uploadStatus":"uploaded"}", + "headers": { + "Content-Type": "application/json", + }, + "method": "PATCH", + }, + ], + [ + "http://localhost:3000/v2/experiments/project-uuid/samples/mockSampleId/sampleFiles/matrix10x", + { + "body": "{"uploadStatus":"uploaded"}", + "headers": { + "Content-Type": "application/json", + }, + "method": "PATCH", + }, + ], +] +`; diff --git a/src/__test__/utils/upload/process10XUpload.test.js b/src/__test__/utils/upload/process10XUpload.test.js index 1991539f77..3678541502 100644 --- a/src/__test__/utils/upload/process10XUpload.test.js +++ b/src/__test__/utils/upload/process10XUpload.test.js @@ -109,8 +109,10 @@ jest.mock('utils/upload/loadAndCompressIfNecessary', }, )); +const sampleFileId = 'mockSampleFileId'; + jest.mock('uuid', () => ({ - v4: jest.fn(() => 'sample-uuid'), + v4: jest.fn(() => sampleFileId), })); jest.mock('axios', () => ({ @@ -123,18 +125,44 @@ jest.mock('utils/upload/validate10x'); let store = null; +const mockProcessUploadCalls = () => { + const sampleId = 'mockSampleId'; + + const mockUploadUrlParams = { + signedUrls: ['theSignedUrl'], + uploadId: 'some_id', + }; + + fetchMock.mockIf(/.*/, ({ url }) => { + let result; + + if (url.endsWith(`/v2/experiments/${mockExperimentId}/samples`)) { + result = { status: 200, body: JSON.stringify({ WT13: sampleId }) }; + } + + if (new RegExp(`/v2/experiments/${mockExperimentId}/samples/.*/sampleFiles/.*`).test(url)) { + result = { status: 200, body: JSON.stringify({}) }; + } + + if (url.endsWith(`/v2/experiments/${mockExperimentId}/sampleFiles/${sampleFileId}/beginUpload`)) { + result = { status: 200, body: JSON.stringify(mockUploadUrlParams) }; + } + + if (url.endsWith('/v2/completeMultipartUpload')) { + result = { status: 200, body: JSON.stringify({}) }; + } + + return Promise.resolve(result); + }); +}; + describe('processUpload', () => { beforeEach(() => { jest.clearAllMocks(); - const mockUploadUrlParams = { - signedUrls: ['theSignedUrl'], - uploadId: 'some_id', - }; - fetchMock.resetMocks(); fetchMock.doMock(); - fetchMock.mockResponse(JSON.stringify(mockUploadUrlParams), { status: 200 }); + mockProcessUploadCalls(); store = mockStore(initialState); }); @@ -214,6 +242,8 @@ describe('processUpload', () => { }], { matcher: waitForActions.matchers.containing }, ); + + expect(fetchMock.mock.calls).toMatchSnapshot('fetch calls'); }); it('Uploads and updates redux correctly when there are no errors with cellranger v2', async () => { @@ -284,6 +314,8 @@ describe('processUpload', () => { }], { matcher: waitForActions.matchers.containing }, ); + + expect(fetchMock.mock.calls).toMatchSnapshot('fetch calls'); }); it('Updates redux correctly when there are file upload errors', async () => { @@ -340,6 +372,8 @@ describe('processUpload', () => { expect(errorFileProperties.length).toEqual(3); // There are no file actions with status successfully uploaded expect(uploadedFileProperties.length).toEqual(0); + + expect(fetchMock.mock.calls).toMatchSnapshot('fetch calls'); }); it('Should not upload files if there are errors creating samples in the api', async () => { @@ -357,6 +391,55 @@ describe('processUpload', () => { await waitFor(() => { expect(axios.request).not.toHaveBeenCalled(); }); + + expect(fetchMock.mock.calls).toMatchSnapshot('fetch calls'); + + // Informs user of error + expect(pushNotificationMessage).toHaveBeenCalledWith('error', 'We couldn\'t create your sample. Please try uploading it again'); + }); + + it('Should not upload files if there are errors beginning the multipart upload in the api', async () => { + const sampleId = 'mockSampleId'; + + fetchMock.mockIf(/.*/, ({ url }) => { + let result; + + if (url.endsWith(`/v2/experiments/${mockExperimentId}/samples`)) { + result = { status: 200, body: JSON.stringify({ WT13: sampleId }) }; + } + + if (new RegExp(`/v2/experiments/${mockExperimentId}/samples/.*/sampleFiles/.*`).test(url)) { + result = { status: 200, body: JSON.stringify({}) }; + } + + if (url.endsWith(`/v2/experiments/${mockExperimentId}/sampleFiles/${sampleFileId}/beginUpload`)) { + return Promise.reject(new Error('Some error in the api')); + } + + return Promise.resolve(result); + }); + + await processUpload( + getValidFiles('v3'), + sampleType, + store.getState().samples, + mockExperimentId, + store.dispatch, + ); + + await waitForActions( + store, + new Array(3).fill({ + type: SAMPLES_FILE_UPDATE, + payload: { fileDiff: { upload: { status: UploadStatus.UPLOAD_ERROR } } }, + }), + { matcher: waitForActions.matchers.containing }, + ); + + // Uploads didn't begin + expect(axios.request).not.toHaveBeenCalled(); + + expect(fetchMock.mock.calls).toMatchSnapshot('fetch calls'); }); it('Should not upload sample and show notification if uploaded sample is invalid', async () => { diff --git a/src/redux/actions/samples/createSampleFile.js b/src/redux/actions/samples/createSampleFile.js index 7bf942ea65..ec91f235a0 100644 --- a/src/redux/actions/samples/createSampleFile.js +++ b/src/redux/actions/samples/createSampleFile.js @@ -12,8 +12,6 @@ const createSampleFile = ( experimentId, sampleId, type, - size, - metadata, fileForApiV1, ) => async (dispatch) => { const updatedAt = dayjs().toISOString(); @@ -22,8 +20,7 @@ const createSampleFile = ( const url = `/v2/experiments/${experimentId}/samples/${sampleId}/sampleFiles/${type}`; const body = { sampleFileId: uuidv4(), - size, - metadata, + size: fileForApiV1.size, }; dispatch({ @@ -39,7 +36,7 @@ const createSampleFile = ( }, }); - const uploadUrlParams = await fetchAPI( + await fetchAPI( url, { method: 'POST', @@ -50,10 +47,7 @@ const createSampleFile = ( }, ); - return { - ...uploadUrlParams, - fileId: body.sampleFileId, - }; + return body.sampleFileId; } catch (e) { dispatch(updateSampleFileUpload(experimentId, sampleId, type, UploadStatus.UPLOAD_ERROR)); diff --git a/src/utils/upload/processUpload.js b/src/utils/upload/processUpload.js index 829085f9a9..607be41d30 100644 --- a/src/utils/upload/processUpload.js +++ b/src/utils/upload/processUpload.js @@ -18,7 +18,7 @@ import endUserMessages from 'utils/endUserMessages'; import pushNotificationMessage from 'utils/pushNotificationMessage'; const prepareAndUploadFileToS3 = async ( - file, uploadUrlParams, type, onStatusUpdate = () => {}, + file, uploadUrlParams, type, onStatusUpdate = () => { }, ) => { let parts = null; const { signedUrls, uploadId, fileId } = uploadUrlParams; @@ -64,17 +64,14 @@ const prepareAndUploadFileToS3 = async ( const createAndUploadSampleFile = async (file, experimentId, sampleId, dispatch, selectedTech) => { const fileType = getFileTypeV2(file.name, selectedTech); - let uploadUrlParams; - try { - const metadata = getMetadata(file, selectedTech); + let sampleFileId; - uploadUrlParams = await dispatch( + try { + sampleFileId = await dispatch( createSampleFile( experimentId, sampleId, fileType, - file.size, - metadata, file, ), ); @@ -90,6 +87,7 @@ const createAndUploadSampleFile = async (file, experimentId, sampleId, dispatch, experimentId, sampleId, fileType, UploadStatus.COMPRESSING, )); }); + file.size = Buffer.byteLength(file.fileObject); } catch (e) { const fileErrorStatus = e.message === 'aborted' @@ -101,14 +99,41 @@ const createAndUploadSampleFile = async (file, experimentId, sampleId, dispatch, } } - const updateSampleFileUploadProgress = (status, percentProgress = 0) => dispatch( - updateSampleFileUpload( - experimentId, sampleId, fileType, status, percentProgress, - ), - ); - await prepareAndUploadFileToS3(file, uploadUrlParams, 'sample', updateSampleFileUploadProgress); + try { + const { signedUrls, uploadId } = await beginSampleFileUpload( + experimentId, + sampleFileId, + file.size, + getMetadata(file, selectedTech), + ); + + const updateSampleFileUploadProgress = (status, percentProgress = 0) => dispatch( + updateSampleFileUpload( + experimentId, sampleId, fileType, status, percentProgress, + ), + ); + + const uploadUrlParams = { signedUrls, uploadId, fileId: sampleFileId }; + + await prepareAndUploadFileToS3(file, uploadUrlParams, 'sample', updateSampleFileUploadProgress); + } catch (e) { + dispatch(updateSampleFileUpload( + experimentId, sampleId, fileType, UploadStatus.UPLOAD_ERROR, + )); + } }; +const beginSampleFileUpload = async (experimentId, sampleFileId, size, metadata) => await fetchAPI( + `/v2/experiments/${experimentId}/sampleFiles/${sampleFileId}/beginUpload`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ size, metadata }), + }, +); + const getMetadata = (file, selectedTech) => { const metadata = {}; if (selectedTech === sampleTech['10X']) {