Skip to content
261 changes: 258 additions & 3 deletions src/components/inputs/dropzone/__tests__/dropzone.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,22 @@ let mockCapturedOptions = {};
jest.mock('dropzone', () => {
return jest.fn().mockImplementation((element, options) => {
mockCapturedOptions = options;
return {
const dz = {
options,
on: jest.fn(),
off: jest.fn(),
destroy: jest.fn(() => null),
getActiveFiles: jest.fn(() => [])
};
// Mimics Dropzone's real Emitter: replays every handler registered via `on`
// for that event, in registration order - the same "multiple listeners on
// one event" behavior the ontimeout/pollUploadStatus fixes rely on.
dz.emit = jest.fn((event, ...args) => {
dz.on.mock.calls
.filter(([evt]) => evt === event)
.forEach(([, handler]) => handler(...args));
});
return dz;
});
});

Expand Down Expand Up @@ -183,6 +192,7 @@ describe('DropzoneJS - HTTP 202 Polling UX', () => {
// Mock fetch to return complete status
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({
status: 'complete',
name: 'test.pdf',
Expand Down Expand Up @@ -244,6 +254,7 @@ describe('DropzoneJS - HTTP 202 Polling UX', () => {
test('test_dropzone_poll_upload_status_encodes_file_id_in_url', (done) => {
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({
status: 'complete',
name: 'HPE_OCPSanJose_Backdrop_20x10ft50_Ver1.4_PRINT.pdf',
Expand Down Expand Up @@ -308,7 +319,7 @@ describe('DropzoneJS - HTTP 202 Polling UX', () => {
test('test_dropzone_cancel_stops_polling_for_that_file_only', (done) => {
// Server is still processing: every poll answers 'uploading', so polling keeps going.
global.fetch = jest.fn(() =>
Promise.resolve({ json: () => Promise.resolve({ status: 'uploading' }) })
Promise.resolve({ ok: true, json: () => Promise.resolve({ status: 'uploading' }) })
);

const ref = React.createRef();
Expand Down Expand Up @@ -369,6 +380,7 @@ describe('DropzoneJS - HTTP 202 Polling UX', () => {
new Promise((resolve) => {
respondComplete = () =>
resolve({
ok: true,
json: () =>
Promise.resolve({ status: 'complete', name: 'test.pdf', size: 1024000 })
});
Expand Down Expand Up @@ -424,7 +436,7 @@ describe('DropzoneJS - HTTP 202 Polling UX', () => {
*/
test('test_dropzone_unmount_stops_polling_for_every_file', (done) => {
global.fetch = jest.fn(() =>
Promise.resolve({ json: () => Promise.resolve({ status: 'uploading' }) })
Promise.resolve({ ok: true, json: () => Promise.resolve({ status: 'uploading' }) })
);

const ref = React.createRef();
Expand Down Expand Up @@ -515,6 +527,249 @@ describe('DropzoneJS - HTTP 202 Polling UX', () => {
done();
}, 10);
});

/**
* Test Case 10: a chunk that times out releases its concurrency slot
*
* Dropzone's own default xhr.ontimeout (set before the 'sending' handler runs)
* still fires 'error', but without wrapping it here onChunkComplete() never
* runs, so chunksInFlight never decrements and later chunks stay queued forever.
*/
test('test_dropzone_ontimeout_releases_chunk_slot', () => {
const ref = React.createRef();

render(
<DropzoneJS
{...defaultProps}
ref={ref}
onUploadComplete={onUploadCompleteMock}
onError={onErrorMock}
/>
);

const instance = ref.current;
// Only requests that went through the chunk-throttle queue occupy a concurrency slot.
const mockFile = { name: 'test.pdf', size: 1024000, _isThrottledChunk: true };
const dropzoneOnTimeout = jest.fn();
const mockXhr = {
readyState: XMLHttpRequest.DONE,
setRequestHeader: jest.fn(),
onload: jest.fn(),
onerror: jest.fn(),
ontimeout: dropzoneOnTimeout,
abort: jest.fn()
};

instance.chunksInFlight = 1;
getEventHandler(instance, 'sending')(mockFile, mockXhr, { append: jest.fn() });

mockXhr.ontimeout({});

expect(instance.chunksInFlight).toBe(0);
// Dropzone's own timeout handling (which still reports the error) must still run.
expect(dropzoneOnTimeout).toHaveBeenCalledTimes(1);
});

/**
* Test Case 10b: a non-chunked upload's completion must not free a concurrency slot
* it never occupied.
*
* chunksInFlight is only ever incremented for requests routed through the chunk-throttle
* queue (setupChunkThrottle). A file that bypasses the queue (no _isThrottledChunk tag)
* finishing its single request used to still call onChunkComplete() unconditionally,
* decrementing the counter for chunked uploads that were still genuinely in flight and
* letting more than maxConcurrentChunks run at once.
*/
test('test_dropzone_non_chunked_upload_does_not_release_a_chunk_slot', () => {
const ref = React.createRef();

render(
<DropzoneJS
{...defaultProps}
ref={ref}
onUploadComplete={onUploadCompleteMock}
onError={onErrorMock}
/>
);

const instance = ref.current;
// No _isThrottledChunk tag - this file bypassed the throttle queue.
const mockFile = { name: 'small.pdf', size: 1024 };
const mockXhr = {
readyState: XMLHttpRequest.DONE,
status: 200,
responseText: JSON.stringify({ name: 'small.pdf', path: 'uploads/', size: 1024 }),
setRequestHeader: jest.fn(),
onload: jest.fn(),
onerror: jest.fn(),
abort: jest.fn()
};

// Two real chunked uploads for a different file are genuinely in flight.
instance.chunksInFlight = 2;
getEventHandler(instance, 'sending')(mockFile, mockXhr, { append: jest.fn() });

mockXhr.onload({});

expect(instance.chunksInFlight).toBe(2);
});

/**
* Test Cases 11-13: pollUploadStatus's three failure branches route through the
* file-level error channel (dropzone.emit('error', file, message)) instead of
* calling onError directly, so the row clears and the consumer is told exactly once.
*/
test('test_dropzone_poll_timeout_emits_a_string_message_and_calls_onError_once', async () => {
jest.useFakeTimers({ doNotFake: ['queueMicrotask'] });
global.fetch = jest.fn(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve({ status: 'uploading' }) })
);

const ref = React.createRef();
render(
<DropzoneJS
{...defaultProps}
ref={ref}
onUploadComplete={onUploadCompleteMock}
onError={onErrorMock}
/>
);

const instance = ref.current;
const mockFile = { name: 'big.pdf', size: 1024000 };
instance.pollUploadStatus('file-timeout', 'https://example.com/upload', mockFile);

// maxAttempts is 300 at 2s/tick - advance one tick past the ceiling.
await jest.advanceTimersByTimeAsync(2000 * 301);

expect(onErrorMock).toHaveBeenCalledTimes(1);
const [message] = onErrorMock.mock.calls[0];
expect(typeof message).toBe('string');
expect(message).toBe('Upload timed out');

jest.useRealTimers();
}, 20000);

test('test_dropzone_poll_server_error_status_emits_readable_message_not_object', (done) => {
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ status: 'error', message: 'processing failed' })
})
);

const ref = React.createRef();
render(
<DropzoneJS
{...defaultProps}
ref={ref}
onUploadComplete={onUploadCompleteMock}
onError={onErrorMock}
/>
);

setTimeout(() => {
const instance = ref.current;
const mockFile = { name: 'test.pdf', size: 1024000 };
instance.pollUploadStatus('file-server-error', 'https://example.com/upload', mockFile);

setTimeout(() => {
expect(onErrorMock).toHaveBeenCalledTimes(1);
const [message] = onErrorMock.mock.calls[0];
expect(message).toBe('processing failed');
expect(message).not.toBe('[object Object]');
done();
}, 2500);
}, 10);
}, 10000);

/**
* Test Case 14: a slow status request must not let a second one start alongside it.
*
* The old setInterval-based poll fired every 2s regardless of whether the previous
* tick's fetch had resolved yet, so a slow response could still be in flight when a
* later tick's request resolved first - letting two terminal results race (e.g. a
* stray error landing after an already-committed completion). The self-scheduling loop
* only calls sleep(2000) again after its current request fully resolves, so no second
* request can ever start while one is outstanding - this asserts that directly, then
* confirms the single in-flight request still completes normally once it resolves.
*/
test('test_dropzone_poll_does_not_start_a_new_request_while_one_is_still_in_flight', async () => {
jest.useFakeTimers({ doNotFake: ['queueMicrotask'] });
let resolveFetch;
global.fetch = jest.fn(
() => new Promise((resolve) => { resolveFetch = resolve; })
);

const ref = React.createRef();
render(
<DropzoneJS
{...defaultProps}
ref={ref}
onUploadComplete={onUploadCompleteMock}
onError={onErrorMock}
/>
);

const instance = ref.current;
const mockFile = {
name: 'test.pdf',
size: 1024000,
_asyncProcessing: true,
_chunksUploadedDone: jest.fn()
};

instance.pollUploadStatus('file-123', 'https://example.com/upload', mockFile);

// First tick fires at 2000ms and parks on the still-unresolved request.
await jest.advanceTimersByTimeAsync(2000);
expect(global.fetch).toHaveBeenCalledTimes(1);

// Several more tick-intervals' worth of time pass while that request is still
// outstanding - a second request must not start.
await jest.advanceTimersByTimeAsync(2000 * 5);
expect(global.fetch).toHaveBeenCalledTimes(1);

// Now the one in-flight request finally resolves with a terminal result.
resolveFetch({
ok: true,
json: () => Promise.resolve({ status: 'complete', name: 'test.pdf', size: 1024000 })
});
await jest.advanceTimersByTimeAsync(0);

expect(mockFile._chunksUploadedDone).toHaveBeenCalledTimes(1);
expect(onUploadCompleteMock).toHaveBeenCalledTimes(1);
expect(onErrorMock).not.toHaveBeenCalled();

jest.useRealTimers();
}, 10000);

test('test_dropzone_poll_fetch_rejection_emits_readable_message_and_calls_onError_once', (done) => {
global.fetch = jest.fn(() => Promise.reject(new Error('network down')));

const ref = React.createRef();
render(
<DropzoneJS
{...defaultProps}
ref={ref}
onUploadComplete={onUploadCompleteMock}
onError={onErrorMock}
/>
);

setTimeout(() => {
const instance = ref.current;
const mockFile = { name: 'test.pdf', size: 1024000 };
instance.pollUploadStatus('file-network-error', 'https://example.com/upload', mockFile);

setTimeout(() => {
expect(onErrorMock).toHaveBeenCalledTimes(1);
const [message] = onErrorMock.mock.calls[0];
expect(message).toBe('Network error');
done();
}, 2500);
}, 10);
}, 10000);
});

describe('DropzoneJS - Progress Bar Monotonicity', () => {
Expand Down
Loading
Loading