UNPKG

@fsdk/upchunk

Version:

Dead simple chunked file uploads using Fetch

334 lines (273 loc) 7.86 kB
import * as nock from 'nock'; import { UpChunk, createUpload, UpChunkOptions } from './upchunk'; beforeEach(() => { if (!nock.isActive()) { nock.activate(); } }); afterEach(() => { nock.restore(); nock.cleanAll(); }); const createUploadFixture = ( options?: Partial<UpChunkOptions>, specifiedFile?: File ) => { const file = specifiedFile || new File([new ArrayBuffer(524288)], 'test.mp4'); return createUpload({ file, endpoint: `https://example.com/upload/endpoint`, chunkSize: 256, ...options, }); }; test('files can be uploading using POST', (done) => { nock('https://example.com') .post('/upload/endpoint') .twice() .reply(200) const upload = createUploadFixture({ method: 'POST', }); upload.on('success', () => { done(); }); }); test('files can be uploading using PATCH', (done) => { nock('https://example.com') .patch('/upload/endpoint') .twice() .reply(200); const upload = createUploadFixture({ method: 'PATCH', }); upload.on('success', () => { done(); }); }); test('a file is uploaded using the correct content-range headers', (done) => { const fileBytes = 524288; const upload = createUploadFixture( {}, new File([new ArrayBuffer(fileBytes)], 'test.mp4') ); const scopes = [ nock('https://example.com') .matchHeader('content-range', `bytes 0-${fileBytes / 2 - 1}/${fileBytes}`) .put('/upload/endpoint') .reply(200), nock('https://example.com') .matchHeader( 'content-range', `bytes ${fileBytes / 2}-${fileBytes - 1}/${fileBytes}` ) .put('/upload/endpoint') .reply(200), ]; upload.on('error', (err) => { done(err); }); upload.on('success', () => { scopes.forEach((scope) => { if (!scope.isDone()) { done('All scopes not completed'); } }); done(); }); }); test('an error is thrown if a request does not complete', (done) => { nock('https://example.com').put('/upload/endpoint').reply(500); const upload = createUploadFixture(); upload.on('error', (err) => { done(); }); upload.on('success', () => { done('Ironic failure, should not have been successful'); }); }); test('fires an attempt event before each attempt', (done) => { let ATTEMPT_COUNT = 0; const MAX_ATTEMPTS = 2; // because we set the chunk size to 256kb, half of our file size in bytes. nock('https://example.com') .put('/upload/endpoint') .reply(200) .put('/upload/endpoint') .reply(200); const upload = createUploadFixture(); upload.on('attempt', (err) => { ATTEMPT_COUNT += 1; }); upload.on('success', () => { if (ATTEMPT_COUNT === MAX_ATTEMPTS) { done(); } else { done( `Attempted ${ATTEMPT_COUNT} times and it should have been ${MAX_ATTEMPTS}` ); } }); }); test('a chunk failing to upload fires an attemptFailure event', (done) => { nock('https://example.com').put('/upload/endpoint').reply(502); const upload = createUploadFixture(); upload.on('attemptFailure', (err) => { upload.pause(); done(); }); }); test('a single chunk failing is retried multiple times until successful', (done) => { let ATTEMPT_FAILURE_COUNT = 0; const FAILURES = 2; nock('https://example.com') .put('/upload/endpoint') .times(FAILURES) .reply(502) .put('/upload/endpoint') .twice() .reply(200); const upload = createUploadFixture({ delayBeforeRetry: 0.1 }); upload.on('attemptFailure', (err) => { ATTEMPT_FAILURE_COUNT += 1; }); upload.on('error', done); upload.on('success', () => { if (ATTEMPT_FAILURE_COUNT === FAILURES) { return done(); } done( `Expected ${FAILURES} attempt failures, received ${ATTEMPT_FAILURE_COUNT}` ); }); }); test('a single chunk failing the max number of times fails the upload', (done) => { nock('https://example.com') .put('/upload/endpoint') .times(5) .reply(502) .put('/upload/endpoint') .twice() .reply(200); const upload = createUploadFixture({ delayBeforeRetry: 0.1 }); upload.on('error', (err) => { try { expect(err.detail.chunk).toBe(0); expect(err.detail.attempts).toBe(5); done(); } catch (err) { done(err); } }); upload.on('success', () => { done(`Expected upload to fail due to failed attempts`); }); }); test('chunkSuccess event is fired after each successful upload', (done) => { nock('https://example.com') .put('/upload/endpoint') .reply(200) .put('/upload/endpoint') .reply(200); const upload = createUploadFixture(); const successCallback = jest.fn(); upload.on('chunkSuccess', successCallback); upload.on('success', () => { expect(successCallback).toBeCalledTimes(2); done(); }); }); const isNumberArraySorted = (a: number[]): boolean => { for (let i = 0; i < a.length - 1; i += 1) { if (a[i] > a[i + 1]) { return false; } } return true; }; test('progress event fires the correct upload percentage', (done) => { const fileBytes = 1048576; const upload = createUploadFixture( {}, new File([new ArrayBuffer(fileBytes)], 'test.mp4') ); const scopes = [ nock('https://example.com') .matchHeader('content-range', `bytes 0-${fileBytes / 4 - 1}/${fileBytes}`) .put('/upload/endpoint') .reply(200), nock('https://example.com') .matchHeader( 'content-range', `bytes ${fileBytes / 4}-${fileBytes / 2 - 1}/${fileBytes}` ) .put('/upload/endpoint') .reply(200), nock('https://example.com') .matchHeader( 'content-range', `bytes ${fileBytes / 2}-${3 * fileBytes / 4 - 1}/${fileBytes}` ) .put('/upload/endpoint') .reply(200), nock('https://example.com') .matchHeader( 'content-range', `bytes ${3 * fileBytes / 4}-${fileBytes - 1}/${fileBytes}` ) .put('/upload/endpoint') .reply(200), ]; const progressCallback = jest.fn((percentage) => percentage); upload.on('error', (err) => { done(err); }); upload.on('progress', (progress) => { progressCallback(progress.detail); }); upload.on('success', () => { scopes.forEach((scope) => { if (!scope.isDone()) { done('All scopes not completed'); } }); expect(progressCallback).toHaveBeenCalledTimes(7); const progressPercentageArray = progressCallback.mock.calls.map(([percentage]) => percentage); expect(isNumberArraySorted(progressPercentageArray)).toBeTruthy(); done(); }); }, 10000); test('abort pauses the upload and cancels the current XHR request', (done) => { /* This is hacky and I don't love it, but the gist is: - Set up a 'attempt' callback listener - We abort the upload afterthe first attempt - If we ever get past the first attempt, fail the test - Give it 100ms and then check that the 1st upload request happened and the 2nd upload request did not happen */ let upload: UpChunk; let attemptCt = 0; const scope = nock('https://example.com') .put('/upload/endpoint') .times(2) .reply(200) upload = createUploadFixture(); const chunkSuccessCallback = jest.fn(); upload.on('attempt', () => { attemptCt += 1; if (attemptCt === 1) { upload.abort(); } else { done(`Error: never should have gotten past attempt 1. Currently attempting ${attemptCt}`); } }); upload.on('success', chunkSuccessCallback); setTimeout(() => { /* * We set up 2 mocks for the upload endpoint, check that there is exactly 1 * left that didn't fire after the upload was aborted */ expect(scope.activeMocks().length).toEqual(1); expect(chunkSuccessCallback).toHaveBeenCalledTimes(0); done(); }, 100); });